Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdCQUZGMEVCMEZBOTk5RTcKUldUbm1ha1A2L0N2ZTlOZjN5T3pGOHBHRUlibytMY2tPeWJkQ01heDJzdTJqK3B3a2lBdDZ1T1oK",
"windows": {
"installMode": "quiet"
"installMode": "passive"
}
}
},
Expand All @@ -50,7 +50,14 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"targets": [
"app",
"dmg",
"deb",
"rpm",
"appimage",
"nsis"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ function AppContent() {
// into the new version — so a restart picks up updates with no clicks.
// Otherwise just surface the dismissible banner for a manual install.
useEffect(() => {
// Never auto-update under `pnpm dev`. A dev build would otherwise detect a
// newer published release, install it over this build, and relaunch — so
// your local changes would vanish before you could see them. Production
// builds (import.meta.env.DEV === false) are unaffected.
if (import.meta.env.DEV) return;

const checkForUpdates = async () => {
try {
const { checkForUpdate } = await import('@/lib/updates');
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/features/featuresets/FeatureSetsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AlertCircle,
CheckCircle2,
Zap,
Sparkles,
} from 'lucide-react';
import {
Card,
Expand Down Expand Up @@ -268,6 +269,32 @@ export function FeatureSetsPage() {
</div>
</div>

{/* @mux self-optimization hint — let the assistant curate the toolset from chat */}
<div className="flex-shrink-0 px-8 pt-6">
<div
className="mx-auto flex max-w-[2000px] items-start gap-3 rounded-xl border border-violet-200/70 bg-gradient-to-r from-violet-50/60 to-transparent p-4 dark:border-violet-800/40 dark:from-violet-900/15"
data-testid="featuresets-mux-hint"
>
<div className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white shadow-[0_4px_10px_-2px_rgb(139_92_246/0.45)]">
<Sparkles className="h-4 w-4 fill-current" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[rgb(var(--foreground))]">
Let your AI build these for you
</p>
<p className="mt-0.5 text-xs leading-relaxed text-[rgb(var(--muted))]">
In any connected client, just say{' '}
<code className="rounded bg-[rgb(var(--surface))] px-1.5 py-0.5 font-mono text-[11px] text-violet-600 dark:text-violet-300">
@mux optimize
</code>{' '}
— your assistant can discover the available tools, compose a FeatureSet, and pin it
to the current folder. Every change is gated behind a one-click approval, so you
stay in control.
</p>
</div>
</div>
</div>

{/* Feature-set model explainer */}
<div className="flex-shrink-0 px-8 pt-6">
<div className="mx-auto flex max-w-[2000px] items-start gap-3 rounded-xl border border-emerald-200/70 bg-gradient-to-r from-emerald-50/60 to-transparent p-4 dark:border-emerald-800/40 dark:from-emerald-900/15">
Expand Down
33 changes: 28 additions & 5 deletions apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
import { useNavigateTo } from '@/stores';

/**
* Incoming approval request emitted by the gateway's ApprovalBroker.
Expand Down Expand Up @@ -53,6 +54,7 @@ type Decision = 'allow_once' | 'always_for_this_session_and_client' | 'deny';
export function MetaToolApprovalDialog() {
const [queue, setQueue] = useState<ApprovalRequest[]>([]);
const current = queue[0];
const navigateTo = useNavigateTo();

useEffect(() => {
const unlistenPromise = listen<ApprovalRequest>(
Expand Down Expand Up @@ -87,6 +89,16 @@ export function MetaToolApprovalDialog() {
[current]
);

// "Prefer not to be asked?" escape hatch. Deny the current request first —
// fail-closed and immediate, so the calling client isn't left hanging for
// the full 60s broker timeout — then jump to the Built-in tab, where the
// "Require approval for tool changes" switch lets the user turn these
// prompts off entirely.
const manageApprovals = useCallback(() => {
void respond('deny');
navigateTo('builtin-servers');
}, [respond, navigateTo]);

// Normalize the freeform diff defensively — a missing field must never
// throw (this previously crashed on `mcpmux_create_feature_set`, whose diff
// is `{ added_tools }` and has no `after`).
Expand Down Expand Up @@ -206,11 +218,22 @@ export function MetaToolApprovalDialog() {
</Button>
</div>

{queue.length > 1 && (
<p className="text-[11px] text-[rgb(var(--muted))] text-right pt-1">
{queue.length - 1} more pending…
</p>
)}
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={manageApprovals}
className="text-[11px] text-[rgb(var(--muted))] underline-offset-2 hover:text-[rgb(var(--foreground))] hover:underline"
title="Deny this request and open the Built-in tab, where you can turn off approval prompts for tool changes"
data-testid="meta-tool-approval-manage-link"
>
Prefer not to be asked? Manage approval prompts →
</button>
{queue.length > 1 && (
<span className="text-[11px] text-[rgb(var(--muted))]">
{queue.length - 1} more pending…
</span>
)}
</div>
</CardContent>
</Card>
</div>
Expand Down
5 changes: 4 additions & 1 deletion crates/mcpmux-gateway/src/mcp/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,10 @@ impl ServerHandler for McpMuxGatewayHandler {
list_feature_sets) are safe to call freely once the user has opted \
in; writes (manage_feature_set, bind_current_workspace) prompt the \
user for approval. Most operations accept an optional `space_id` \
(from mcpmux_list_spaces) to target a specific Space."
(from mcpmux_list_spaces) to target a specific Space. When \
optimizing, start minimal: search for the few tools the task needs, \
compose a small set, then expand it later as needs arise rather than \
dumping the whole catalog upfront."
.to_string(),
);
info
Expand Down
7 changes: 4 additions & 3 deletions crates/mcpmux-gateway/src/services/meta_tools/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ impl MetaTool for ListAllToolsTool {
fn description(&self) -> &'static str {
"List every tool installed in a Space (default: the caller's resolved \
Space; pass `space_id` to target another), without the current \
FeatureSet filter applied. Use this to see what could be exposed \
before composing a custom FeatureSet. Returns an array of \
FeatureSet filter applied. Prefer `mcpmux_search_tools` unless you need \
the full list — this dump is token-heavy. Returns an array of \
{server_id, qualified_name, description, available}."
}

Expand Down Expand Up @@ -716,7 +716,8 @@ impl MetaTool for ManageFeatureSetTool {
(needs `feature_set_id`). Tool names are the qualified names from \
`mcpmux_list_all_tools`/`mcpmux_search_tools`. Built-in sets can't be \
modified. Requires user approval. Route a workspace through a FeatureSet \
with `mcpmux_bind_current_workspace`."
with `mcpmux_bind_current_workspace`. Prefer a small initial set you \
expand later over adding everything upfront."
}

fn input_schema(&self) -> Value {
Expand Down
5 changes: 5 additions & 0 deletions tests/ts/components/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ describe('App – dynamic gateway URL display', () => {

describe('App – update banner', () => {
beforeEach(() => {
// The startup auto-update check is gated behind `!import.meta.env.DEV`
// (it must never run under `pnpm dev`). Vitest runs in dev mode, so stub
// DEV=false here to exercise the production update flow.
vi.stubEnv('DEV', false);
vi.useFakeTimers();
gatewayEventCallbacks = [];
setupInvoke({ get_version: '0.1.2' });
Expand All @@ -296,6 +300,7 @@ describe('App – update banner', () => {

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});

it('should show update banner when update is available', async () => {
Expand Down
40 changes: 36 additions & 4 deletions tests/ts/components/MetaToolApprovalDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/**
* Approval dialog wiring:
* - renders the target-Space chip (so a cross-Space write is obvious), and
* - renders the target-Space chip (so a cross-Space write is obvious),
* - survives a freeform `diff` shape (`{ added_tools }`) without crashing —
* a regression guard for the earlier "Cannot read properties of undefined
* (reading 'length')" bug.
* (reading 'length')" bug, and
* - the "Manage approval prompts" link denies the request (fail-closed) and
* routes to the Built-in tab where prompts can be disabled.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const { handlers } = vi.hoisted(() => ({
const { handlers, navigateToSpy } = vi.hoisted(() => ({
handlers: new Map<string, (e: { payload: unknown }) => void>(),
navigateToSpy: vi.fn(),
}));

// Capture the dialog's `listen('meta-tool-approval-request', cb)` so the test
Expand All @@ -22,7 +26,9 @@ vi.mock('@tauri-apps/api/event', () => ({
}),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/stores', () => ({ useNavigateTo: () => navigateToSpy }));

import { invoke } from '@tauri-apps/api/core';
import { MetaToolApprovalDialog } from '@/features/metaTools/MetaToolApprovalDialog';

async function emitRequest(payload: Record<string, unknown>) {
Expand All @@ -40,7 +46,11 @@ async function emitRequest(payload: Record<string, unknown>) {
}

describe('MetaToolApprovalDialog', () => {
beforeEach(() => handlers.clear());
beforeEach(() => {
handlers.clear();
navigateToSpy.mockClear();
vi.mocked(invoke).mockClear();
});

it('names the target Space when present', async () => {
render(<MetaToolApprovalDialog />);
Expand Down Expand Up @@ -82,4 +92,26 @@ describe('MetaToolApprovalDialog', () => {
expect(screen.getByText(/github_create_issue/)).toBeInTheDocument();
expect(screen.getByText(/slack_send/)).toBeInTheDocument();
});

it('"Manage approval prompts" denies the request and routes to the Built-in tab', async () => {
const user = userEvent.setup();
render(<MetaToolApprovalDialog />);
await emitRequest({
tool_name: 'mcpmux_bind_current_workspace',
summary: 'Bind this folder',
diff: null,
raw_args: {},
affects_other_clients: false,
});

await user.click(await screen.findByTestId('meta-tool-approval-manage-link'));

// Fail-closed: the pending request is denied rather than left to time out.
expect(invoke).toHaveBeenCalledWith(
'respond_to_meta_tool_approval',
expect.objectContaining({ decision: 'deny' })
);
// ...and the user lands on the tab that hosts the approval toggle.
expect(navigateToSpy).toHaveBeenCalledWith('builtin-servers');
});
});
Loading