From 46a92a1166915d8dd88dad5a619da64f3a1f73b1 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 18:01:31 -0600 Subject: [PATCH 001/148] =?UTF-8?q?feat(port):=20Phase=201=20=E2=80=94=20F?= =?UTF-8?q?oundation:=20shared=20UI=20library=20+=20backend=20facade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous decisions: - Reverted packages/ui/src/components/layout/Sidebar.tsx and AppShell.tsx to main versions — main is ahead of i18n on these files (accent strip, hint prop, group-hover animations); porting i18n's older versions would have been a regression. - Ported apps/desktop/src/lib/api/ shim files (app.ts, configExport.ts, settings.ts, transport.ts, oauth.ts, serverClone.ts, workspaceAppearances.ts, fetch-api.ts/helpers/types) — required by backend/shell/index.ts and build-info.helpers.ts; all are @deprecated re-export shims pointing at the new backend facade. - In api/index.ts: selective named exports from oauth.ts instead of export * — avoids duplicate symbol conflicts with existing gateway.ts which still exports OAuthClient, RegistrationType, UpdateClientRequest, and the OAuth client CRUD functions; only oauth.ts-unique additions (flushPendingDeepLink, ConsentRequestDetails, getPendingConsent, approveOAuthConsent) are re-exported. - Ported scripts/build-date.helpers.mjs alongside the spec'd scripts — it is a peer dependency of build-stamp.mjs and build-web-admin.mjs; omitting it would make those scripts fail at runtime. - Updated apps/desktop/src/lib/api/index.ts to export new api shim modules — required so backend/index.ts export * from '../api' resolves all symbols the facade depends on. --- apps/desktop/package.json | 2 + apps/desktop/src/lib/analytics.ts | 24 +- apps/desktop/src/lib/api/app.ts | 32 ++ apps/desktop/src/lib/api/configExport.ts | 67 +++ apps/desktop/src/lib/api/fetch-api.helpers.ts | 2 + apps/desktop/src/lib/api/fetch-api.ts | 2 + apps/desktop/src/lib/api/fetch-api.types.ts | 2 + apps/desktop/src/lib/api/index.ts | 13 + apps/desktop/src/lib/api/oauth.ts | 153 +++++++ apps/desktop/src/lib/api/serverClone.ts | 105 +++++ apps/desktop/src/lib/api/settings.ts | 149 +++++++ apps/desktop/src/lib/api/transport.ts | 2 + .../src/lib/api/workspaceAppearances.ts | 56 +++ .../src/lib/backend/data/fetch-api.helpers.ts | 14 + .../fetch-api.routes/app-settings.routes.ts | 65 +++ .../data/fetch-api.routes/catalog.routes.ts | 43 ++ .../fetch-api.routes/config-export.routes.ts | 35 ++ .../data/fetch-api.routes/gateway.routes.ts | 62 +++ .../backend/data/fetch-api.routes/index.ts | 38 ++ .../fetch-api.routes/permissions.routes.ts | 117 ++++++ .../data/fetch-api.routes/servers.routes.ts | 126 ++++++ .../data/fetch-api.routes/spaces.routes.ts | 37 ++ .../fetch-api.routes/workspaces.routes.ts | 57 +++ .../desktop/src/lib/backend/data/fetch-api.ts | 216 ++++++++++ .../src/lib/backend/data/fetch-api.types.ts | 9 + .../desktop/src/lib/backend/data/transport.ts | 23 ++ .../src/lib/backend/events/admin-sse-hub.ts | 155 +++++++ apps/desktop/src/lib/backend/events/index.ts | 63 +++ .../src/lib/backend/events/tauri-adapter.ts | 16 + .../events/use-backend-event-subscription.ts | 63 +++ .../src/lib/backend/events/useDomainEvents.ts | 385 ++++++++++++++++++ .../lib/backend/events/useDomainEventsWeb.ts | 87 ++++ .../lib/backend/events/useMetaToolEvents.ts | 81 ++++ .../backend/events/useMetaToolEventsWeb.ts | 46 +++ .../backend/events/useOAuthClientEvents.ts | 88 ++++ .../backend/events/useOAuthClientEventsWeb.ts | 49 +++ .../lib/backend/events/useWorkspaceEvents.ts | 159 ++++++++ .../backend/events/useWorkspaceEventsWeb.ts | 94 +++++ apps/desktop/src/lib/backend/index.ts | 10 + apps/desktop/src/lib/backend/shell/index.ts | 326 +++++++++++++++ apps/desktop/src/lib/build-info.helpers.ts | 138 +++++++ apps/desktop/src/lib/contribute.ts | 17 +- apps/desktop/src/lib/desktop-shell.ts | 2 + apps/desktop/src/lib/monaco-setup.ts | 20 + apps/desktop/src/utils/build-date.helpers.ts | 77 ++++ docs/planning/dev-to-main-port.md | 349 ++++++++++++++++ package.json | 5 + .../ui/src/components/common/ChipButton.tsx | 44 ++ .../src/components/common/ConfirmDialog.tsx | 67 +-- .../ui/src/components/common/DropdownMenu.tsx | 259 ++++++++++++ .../ui/src/components/common/HoverTooltip.tsx | 188 +++++++++ .../ui/src/components/common/SearchField.tsx | 50 +++ .../components/common/use-confirm.hook.tsx | 62 +++ packages/ui/src/hooks/useClickOutside.ts | 27 ++ packages/ui/src/index.ts | 24 +- scripts/admin-e2e-fixture.mjs | 223 ++++++++++ scripts/build-date.helpers.mjs | 90 ++++ scripts/build-stamp.mjs | 89 ++++ scripts/build-web-admin.mjs | 32 ++ scripts/cf-access-env.mjs | 89 ++++ scripts/count-meta-tool-tokens.py | 84 ++++ scripts/dev-admin.mjs | 158 +++++++ scripts/dev-web-admin.mjs | 138 +++++++ scripts/remote-gateway-smoke.mjs | 109 +++++ scripts/run-with-repo-env.mjs | 29 ++ 65 files changed, 5347 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/src/lib/api/app.ts create mode 100644 apps/desktop/src/lib/api/configExport.ts create mode 100644 apps/desktop/src/lib/api/fetch-api.helpers.ts create mode 100644 apps/desktop/src/lib/api/fetch-api.ts create mode 100644 apps/desktop/src/lib/api/fetch-api.types.ts create mode 100644 apps/desktop/src/lib/api/oauth.ts create mode 100644 apps/desktop/src/lib/api/serverClone.ts create mode 100644 apps/desktop/src/lib/api/settings.ts create mode 100644 apps/desktop/src/lib/api/transport.ts create mode 100644 apps/desktop/src/lib/api/workspaceAppearances.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.helpers.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/app-settings.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/catalog.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/config-export.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/gateway.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/index.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/permissions.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/servers.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/spaces.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.routes/workspaces.routes.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.ts create mode 100644 apps/desktop/src/lib/backend/data/fetch-api.types.ts create mode 100644 apps/desktop/src/lib/backend/data/transport.ts create mode 100644 apps/desktop/src/lib/backend/events/admin-sse-hub.ts create mode 100644 apps/desktop/src/lib/backend/events/index.ts create mode 100644 apps/desktop/src/lib/backend/events/tauri-adapter.ts create mode 100644 apps/desktop/src/lib/backend/events/use-backend-event-subscription.ts create mode 100644 apps/desktop/src/lib/backend/events/useDomainEvents.ts create mode 100644 apps/desktop/src/lib/backend/events/useDomainEventsWeb.ts create mode 100644 apps/desktop/src/lib/backend/events/useMetaToolEvents.ts create mode 100644 apps/desktop/src/lib/backend/events/useMetaToolEventsWeb.ts create mode 100644 apps/desktop/src/lib/backend/events/useOAuthClientEvents.ts create mode 100644 apps/desktop/src/lib/backend/events/useOAuthClientEventsWeb.ts create mode 100644 apps/desktop/src/lib/backend/events/useWorkspaceEvents.ts create mode 100644 apps/desktop/src/lib/backend/events/useWorkspaceEventsWeb.ts create mode 100644 apps/desktop/src/lib/backend/index.ts create mode 100644 apps/desktop/src/lib/backend/shell/index.ts create mode 100644 apps/desktop/src/lib/build-info.helpers.ts create mode 100644 apps/desktop/src/lib/desktop-shell.ts create mode 100644 apps/desktop/src/lib/monaco-setup.ts create mode 100644 apps/desktop/src/utils/build-date.helpers.ts create mode 100644 docs/planning/dev-to-main-port.md create mode 100644 packages/ui/src/components/common/ChipButton.tsx create mode 100644 packages/ui/src/components/common/DropdownMenu.tsx create mode 100644 packages/ui/src/components/common/HoverTooltip.tsx create mode 100644 packages/ui/src/components/common/SearchField.tsx create mode 100644 packages/ui/src/components/common/use-confirm.hook.tsx create mode 100644 packages/ui/src/hooks/useClickOutside.ts create mode 100644 scripts/admin-e2e-fixture.mjs create mode 100644 scripts/build-date.helpers.mjs create mode 100644 scripts/build-stamp.mjs create mode 100644 scripts/build-web-admin.mjs create mode 100644 scripts/cf-access-env.mjs create mode 100644 scripts/count-meta-tool-tokens.py create mode 100644 scripts/dev-admin.mjs create mode 100644 scripts/dev-web-admin.mjs create mode 100644 scripts/remote-gateway-smoke.mjs create mode 100644 scripts/run-with-repo-env.mjs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 38b5412d..87bb59fd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "tauri": "tauri", "dev:web": "vite", + "dev:web:admin": "VITE_ADMIN_WEB=true vite", "build:web": "tsc && vite build", + "build:web:admin": "node ../../scripts/build-web-admin.mjs", "lint": "eslint src", "lint:fix": "eslint src --fix", "typecheck": "tsc --noEmit", diff --git a/apps/desktop/src/lib/analytics.ts b/apps/desktop/src/lib/analytics.ts index 4b064747..a85e37e9 100644 --- a/apps/desktop/src/lib/analytics.ts +++ b/apps/desktop/src/lib/analytics.ts @@ -16,13 +16,7 @@ let initialized = false; /** Initialize PostHog with app-level super properties. */ export function initAnalytics(appVersion: string) { if (initialized || typeof window === 'undefined') return; - if (!POSTHOG_KEY) { - // The #1 reason no events show up in local dev: the key isn't loaded. - // Vite only reads env at startup and only exposes `VITE_`-prefixed vars, - // so a missing/empty key here means restart dev or check apps/desktop/.env*. - console.info('[analytics] disabled — VITE_POSTHOG_KEY is not set; events are dropped'); - return; - } + if (!POSTHOG_KEY) return; posthog.init(POSTHOG_KEY, { api_host: POSTHOG_HOST, @@ -41,25 +35,11 @@ export function initAnalytics(appVersion: string) { }); initialized = true; - console.info(`[analytics] initialized (host=${POSTHOG_HOST})`); } /** Capture an analytics event (no-op if not initialized or opted out). */ export function capture(event: string, properties?: Record) { - if (!initialized) { - // Dev aid: surface dropped events so a missing key / un-run init is obvious - // when debugging "why isn't in PostHog?". - if (import.meta.env.DEV) { - console.debug(`[analytics] dropped "${event}" — analytics not initialized`); - } - return; - } - if (posthog.has_opted_out_capturing()) { - if (import.meta.env.DEV) { - console.debug(`[analytics] dropped "${event}" — user opted out of analytics`); - } - return; - } + if (!initialized) return; posthog.capture(event, properties); } diff --git a/apps/desktop/src/lib/api/app.ts b/apps/desktop/src/lib/api/app.ts new file mode 100644 index 00000000..1fa09592 --- /dev/null +++ b/apps/desktop/src/lib/api/app.ts @@ -0,0 +1,32 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +import { apiCall } from './transport'; + +/** + * Read the running application version from the Rust backend. + */ +export async function getVersion(): Promise { + return apiCall('get_version'); +} + +/** + * Read the on-disk bundle version when it differs from the running process + * (e.g. after a Homebrew Cask upgrade). Returns null on non-macOS platforms. + */ +export async function getBundleVersion(): Promise { + return apiCall('get_bundle_version'); +} + +/** Git/build metadata the running backend was compiled from. */ +export interface BuildInfo { + git_sha: string; + git_branch: string; + commit_time: string; + build_time: string; +} + +/** + * Read build metadata from the Rust backend (git SHA stamped at compile time). + */ +export async function getBuildInfo(): Promise { + return apiCall('get_build_info'); +} diff --git a/apps/desktop/src/lib/api/configExport.ts b/apps/desktop/src/lib/api/configExport.ts new file mode 100644 index 00000000..9fcc5758 --- /dev/null +++ b/apps/desktop/src/lib/api/configExport.ts @@ -0,0 +1,67 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +import { exportConfigToFile as shellExportConfigToFile } from '@/lib/backend/shell'; + +import { apiCall } from './transport'; + +/** Supported MCP client config export targets. */ +export type ExportClientType = 'cursor' | 'vscode' | 'claude'; + +/** Parameters for preview and file export commands. */ +export interface ExportConfigRequest { + client_type: ExportClientType; + space_id: string; + mask_credentials?: boolean; +} + +/** Preview/export payload returned by the backend. */ +export interface ExportConfigResponse { + content: string; + default_path: string | null; + suggested_filename: string; +} + +/** + * Preview generated MCP client config JSON without writing to disk. + */ +export async function previewConfigExport( + request: ExportConfigRequest +): Promise { + return apiCall('preview_config_export', { request }); +} + +/** + * Write generated MCP client config JSON to the given file path (desktop shell only). + * + * @returns Absolute path of the written file. + */ +export async function exportConfigToFile( + request: ExportConfigRequest, + path: string +): Promise { + return shellExportConfigToFile(request, path); +} + +/** + * Default config file paths per client type (`cursor`, `vscode`, `claude`). + */ +export async function getConfigPaths(): Promise> { + return apiCall('get_config_paths'); +} + +/** + * Whether a config file already exists at the default path for a client type. + */ +export async function checkConfigExists(clientType: ExportClientType): Promise { + return apiCall('check_config_exists', { clientType }); +} + +/** + * Copy an existing default config to a `.json.bak` sibling before overwrite. + * + * @returns Backup path when a file existed; otherwise `null`. + */ +export async function backupExistingConfig( + clientType: ExportClientType +): Promise { + return apiCall('backup_existing_config', { clientType }); +} diff --git a/apps/desktop/src/lib/api/fetch-api.helpers.ts b/apps/desktop/src/lib/api/fetch-api.helpers.ts new file mode 100644 index 00000000..bf72806a --- /dev/null +++ b/apps/desktop/src/lib/api/fetch-api.helpers.ts @@ -0,0 +1,2 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +export * from '../backend/data/fetch-api.helpers'; diff --git a/apps/desktop/src/lib/api/fetch-api.ts b/apps/desktop/src/lib/api/fetch-api.ts new file mode 100644 index 00000000..c4ea0a01 --- /dev/null +++ b/apps/desktop/src/lib/api/fetch-api.ts @@ -0,0 +1,2 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +export * from '../backend/data/fetch-api'; diff --git a/apps/desktop/src/lib/api/fetch-api.types.ts b/apps/desktop/src/lib/api/fetch-api.types.ts new file mode 100644 index 00000000..0ed1f08e --- /dev/null +++ b/apps/desktop/src/lib/api/fetch-api.types.ts @@ -0,0 +1,2 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +export * from '../backend/data/fetch-api.types'; diff --git a/apps/desktop/src/lib/api/index.ts b/apps/desktop/src/lib/api/index.ts index e20f78ee..3ecb9d88 100644 --- a/apps/desktop/src/lib/api/index.ts +++ b/apps/desktop/src/lib/api/index.ts @@ -1,5 +1,6 @@ // API layer for communicating with Tauri backend +export * from './app'; export * from './spaces'; export * from './registry'; export * from './featureSets'; @@ -10,3 +11,15 @@ export * from './gateway'; export * from './serverManager'; export * from './workspaceBindings'; export * from './metaTools'; +export * from './configExport'; +export type { + ConsentRequestDetails, + ConsentError, + ConsentApprovalRequest, + ConsentApprovalResponse, +} from './oauth'; +export { flushPendingDeepLink, getPendingConsent, approveOAuthConsent } from './oauth'; +export * from './serverClone'; +export * from './settings'; +export * from './workspaceAppearances'; +export * from './logs'; diff --git a/apps/desktop/src/lib/api/oauth.ts b/apps/desktop/src/lib/api/oauth.ts new file mode 100644 index 00000000..18c10e55 --- /dev/null +++ b/apps/desktop/src/lib/api/oauth.ts @@ -0,0 +1,153 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +import { flushPendingDeepLink } from '@/lib/backend/shell'; + +import { apiCall } from './transport'; + +/** Desktop-only: replay a buffered OAuth consent deep link after listeners attach. */ +export { flushPendingDeepLink }; + +/** Inbound client registration type (per MCP spec 2025-11-25). */ +export type RegistrationType = 'cimd' | 'dcr' | 'preregistered'; + +/** + * Inbound OAuth client (Cursor, Claude Desktop, etc.) connecting to McpMux. + */ +export interface OAuthClient { + client_id: string; + registration_type: RegistrationType; + client_name: string; + client_alias: string | null; + redirect_uris: string[]; + scope: string | null; + approved: boolean; + logo_uri?: string | null; + client_uri?: string | null; + software_id?: string | null; + software_version?: string | null; + metadata_url?: string | null; + metadata_cached_at?: string | null; + metadata_cache_ttl?: number | null; + last_seen: string | null; + created_at: string; + reports_roots: boolean; + roots_capability_known: boolean; +} + +/** Editable OAuth client fields. */ +export interface UpdateClientRequest { + client_alias?: string; +} + +/** Full consent request details returned by the backend. */ +export interface ConsentRequestDetails { + requestId: string; + clientId: string; + clientName: string; + redirectUri: string; + scope: string; + state: string | null; + expiresAt: number; + consentToken: string; +} + +/** Consent validation or approval error from the backend. */ +export interface ConsentError { + code: 'NOT_FOUND' | 'EXPIRED' | 'ALREADY_PROCESSED' | 'GATEWAY_UNAVAILABLE'; + message: string; +} + +/** Payload sent when approving or denying OAuth consent. */ +export interface ConsentApprovalRequest { + request_id: string; + approved: boolean; + consent_token: string; + client_alias: string | null; +} + +/** Response from consent approval. */ +export interface ConsentApprovalResponse { + success: boolean; + redirect_url: string; + error: string | null; +} + +/** + * Validate a pending OAuth consent request and load authoritative details. + */ +export async function getPendingConsent(requestId: string): Promise { + return apiCall('get_pending_consent', { requestId }); +} + +/** + * Approve or deny a pending OAuth consent request. + */ +export async function approveOAuthConsent( + request: ConsentApprovalRequest +): Promise { + const command = request.approved ? 'approve_oauth_consent' : 'reject_oauth_consent'; + return apiCall(command, { request }); +} + +/** + * List all registered OAuth clients. + */ +export async function listOAuthClients(): Promise { + return apiCall('get_oauth_clients'); +} + +/** + * Update an OAuth client's settings. + */ +export async function updateOAuthClient( + clientId: string, + settings: UpdateClientRequest +): Promise { + return apiCall('update_oauth_client', { clientId, settings }); +} + +/** + * Delete an OAuth client registration. + */ +export async function deleteOAuthClient(clientId: string): Promise { + return apiCall('delete_oauth_client', { clientId }); +} + +/** + * Read FeatureSet ids granted to a rootless OAuth client in a space. + */ +export async function getOAuthClientGrants( + clientId: string, + spaceId: string +): Promise { + return apiCall('get_oauth_client_grants', { clientId, spaceId }); +} + +/** + * Grant a FeatureSet to an OAuth client in a space. + */ +export async function grantOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return apiCall('grant_oauth_client_feature_set', { + clientId, + spaceId, + featureSetId, + }); +} + +/** + * Revoke a FeatureSet from an OAuth client in a space. + */ +export async function revokeOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return apiCall('revoke_oauth_client_feature_set', { + clientId, + spaceId, + featureSetId, + }); +} diff --git a/apps/desktop/src/lib/api/serverClone.ts b/apps/desktop/src/lib/api/serverClone.ts new file mode 100644 index 00000000..ef8223d6 --- /dev/null +++ b/apps/desktop/src/lib/api/serverClone.ts @@ -0,0 +1,105 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +/** + * Server clone API — Tauri wrappers for multi-account cloning. + */ + +import { apiCall } from './transport'; +import type { InstalledServerState } from '@/types/registry'; + +/** Default suffix suggestions shown in the clone wizard */ +export const CLONE_SUFFIX_SUGGESTIONS = ['work', 'personal', 'prod', 'staging'] as const; + +/** Installed server row returned by clone_server (includes clone lineage). */ +export interface ClonedInstalledServer extends InstalledServerState { + cloned_from?: string | null; +} + +/** + * Clone an installed server into a new suffixed manual-entry install in the same space. + * + * `displayName` is optional; when set, it is stored as the user-supplied display label + * (`display_name_override`) and survives later definition refreshes. When omitted, the + * UI falls back to the auto `"Source (suffix)"` cached definition name. + */ +export async function cloneServer( + spaceId: string, + sourceServerId: string, + suffix: string, + alias?: string, + displayName?: string +): Promise { + return apiCall('clone_server', { + spaceId, + sourceServerId, + suffix, + alias: alias ?? null, + displayName: displayName ?? null, + }); +} + +/** + * Return whether a suffixed clone ID is available in the given space. + */ +export async function isCloneIdAvailable( + spaceId: string, + sourceServerId: string, + suffix: string +): Promise { + return apiCall('is_clone_id_available', { + spaceId, + sourceServerId, + suffix, + }); +} + +/** + * Suggest the first available default suffix for cloning a server. + */ +export async function suggestCloneSuffix(spaceId: string, sourceServerId: string): Promise { + return apiCall('suggest_clone_suffix', { + spaceId, + sourceServerId, + }); +} + +/** + * List account clones that were created from the given source server in a space. + */ +export async function listCloneDependents( + spaceId: string, + sourceServerId: string +): Promise { + return apiCall('list_clone_dependents', { + spaceId, + sourceServerId, + }); +} + +/** + * Normalize a server ID the same way the backend does (lowercase, strip underscores/spaces). + */ +export function normalizeServerId(id: string): string { + return id + .split('') + .filter((c) => /[a-zA-Z0-9]/.test(c) || c === '-' || c === '.') + .map((c) => (/[a-zA-Z0-9]/.test(c) ? c.toLowerCase() : c)) + .join(''); +} + +/** + * Derive the clone server ID preview from a base install ID and user suffix. + */ +export function deriveCloneServerId(baseServerId: string, suffix: string): string { + const normalizedSuffix = normalizeServerId(suffix); + if (!normalizedSuffix) { + return ''; + } + return normalizeServerId(`${baseServerId}-${normalizedSuffix}`); +} + +/** + * Derive the tool-name alias preview for a clone suffix. + */ +export function deriveCloneAlias(suffix: string): string { + return normalizeServerId(suffix).replace(/_/g, '-'); +} diff --git a/apps/desktop/src/lib/api/settings.ts b/apps/desktop/src/lib/api/settings.ts new file mode 100644 index 00000000..af495529 --- /dev/null +++ b/apps/desktop/src/lib/api/settings.ts @@ -0,0 +1,149 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +import { + getAdminWebSettings as shellGetAdminWebSettings, + openLogsFolder as shellOpenLogsFolder, + updateAdminWebSettings as shellUpdateAdminWebSettings, +} from '@/lib/backend/shell'; + +import { apiCall } from './transport'; + +/** Startup and system tray settings. */ +export interface StartupSettings { + autoLaunch: boolean; + startMinimized: boolean; + closeToTray: boolean; +} + +/** Per-server package update policy. */ +export type UpdatePolicy = 'auto' | 'notify' | 'pinned'; + +/** App-wide default update policy for new server installs. */ +export interface ServerUpdateSettings { + defaultUpdatePolicy: UpdatePolicy; + /** ISO timestamp of the last bulk version probe, when available. */ + lastCheckedAt?: string | null; +} + +/** Persisted gateway port override, default, and currently active port. */ +export interface GatewayPortSettings { + configuredPort: number | null; + defaultPort: number; + activePort: number | null; + publicUrl: string | null; +} + +/** Web admin HTTP server settings (loopback remote UI). */ +export interface AdminWebSettings { + enabled: boolean; + port: number; + trustCfAccess: boolean; + cfTeamDomain: string; +} + +/** + * Load startup and system tray preferences. + */ +export async function getStartupSettings(): Promise { + return apiCall('get_startup_settings'); +} + +/** + * Persist startup and system tray preferences. + */ +export async function updateStartupSettings(settings: StartupSettings): Promise { + return apiCall('update_startup_settings', { settings }); +} + +/** + * Load the default update policy for newly installed servers. + */ +export async function getServerUpdateSettings(): Promise { + return apiCall('get_server_update_settings'); +} + +/** + * Persist the default update policy for newly installed servers. + */ +export async function updateServerUpdateSettings(settings: ServerUpdateSettings): Promise { + return apiCall('update_server_update_settings', { settings }); +} + +/** Probe all notify/auto package-managed servers for updates. */ +export async function checkAllServerUpdates(): Promise<{ + checked: number; + updatesAvailable: number; + checkedAt: string; +}> { + return apiCall('check_all_server_updates'); +} + +/** Probe a single installed server for package updates. */ +export async function checkServerVersion( + spaceId: string, + serverId: string +): Promise<{ + spaceId: string; + serverId: string; + currentVersion: string | null; + latestVersion: string | null; + updateAvailable: boolean; + checkedAt: string; +}> { + return apiCall('check_server_version', { spaceId, serverId }); +} + +/** + * Load gateway port settings (configured override, default, active). + */ +export async function getGatewayPortSettings(): Promise { + return apiCall('get_gateway_port_settings'); +} + +/** + * Persist a custom gateway port. Takes effect on the next gateway start. + */ +export async function setGatewayPort(port: number): Promise { + return apiCall('set_gateway_port', { port }); +} + +/** + * Clear the persisted gateway port override. + */ +export async function resetGatewayPort(): Promise { + return apiCall('reset_gateway_port'); +} + +/** + * Persist the public HTTPS URL advertised in OAuth metadata for tunnel clients. + */ +export async function setGatewayPublicUrl(publicUrl: string): Promise { + return apiCall('set_gateway_public_url', { publicUrl }); +} + +/** + * Resolve the on-disk application logs directory path. + */ +export async function getLogsPath(): Promise { + return apiCall('get_logs_path'); +} + +/** + * Open the application logs folder in the system file manager. + */ +export async function openLogsFolder(): Promise { + return shellOpenLogsFolder(); +} + +/** + * Load web admin mode settings (desktop only — controls :45819 server). + */ +export async function getAdminWebSettings(): Promise { + return shellGetAdminWebSettings(); +} + +/** + * Persist web admin settings and restart the admin HTTP server. + */ +export async function updateAdminWebSettings(settings: AdminWebSettings): Promise { + return shellUpdateAdminWebSettings(settings); +} diff --git a/apps/desktop/src/lib/api/transport.ts b/apps/desktop/src/lib/api/transport.ts new file mode 100644 index 00000000..8d688984 --- /dev/null +++ b/apps/desktop/src/lib/api/transport.ts @@ -0,0 +1,2 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +export * from '../backend/data/transport'; diff --git a/apps/desktop/src/lib/api/workspaceAppearances.ts b/apps/desktop/src/lib/api/workspaceAppearances.ts new file mode 100644 index 00000000..4fa05882 --- /dev/null +++ b/apps/desktop/src/lib/api/workspaceAppearances.ts @@ -0,0 +1,56 @@ +/** @deprecated Prefer `@/lib/backend` — shim during facade migration. */ +import { fileSrcFromAbsolutePath } from '@/lib/backend/shell'; +import { apiCall, isTauri } from './transport'; + +/** Persisted per-root icon used before a binding exists. */ +export interface WorkspaceAppearance { + workspace_root: string; + icon: string; + updated_at: string; +} + +export interface WorkspaceAppearanceInput { + workspace_root: string; + icon: string; +} + +/** List all saved workspace appearances. */ +export async function listWorkspaceAppearances(): Promise { + return apiCall('list_workspace_appearances'); +} + +/** Upsert appearance for a normalized workspace root. */ +export async function upsertWorkspaceAppearance( + input: WorkspaceAppearanceInput +): Promise { + return apiCall('upsert_workspace_appearance', { input }); +} + +/** Delete appearance for a workspace root. */ +export async function deleteWorkspaceAppearance(workspaceRoot: string): Promise { + return apiCall('delete_workspace_appearance', { workspaceRoot }); +} + +/** Copy a source image into app data and return local: ref. */ +export async function uploadWorkspaceIcon(sourcePath: string): Promise { + return apiCall('upload_workspace_icon', { sourcePath }); +} + +/** Resolve a local:workspace-icons ref to an absolute file path. */ +export async function resolveWorkspaceIconPath(iconRef: string): Promise { + return apiCall('resolve_workspace_icon_path', { iconRef }); +} + +/** + * Resolve a local icon ref to a displayable URL (Tauri asset URL or admin HTTP path). + */ +export async function resolveWorkspaceIconDisplaySrc(iconRef: string): Promise { + if (!iconRef.startsWith('local:')) { + return null; + } + if (!isTauri()) { + return `/api/v1/workspaces/icon?iconRef=${encodeURIComponent(iconRef)}`; + } + const absolutePath = await resolveWorkspaceIconPath(iconRef); + return fileSrcFromAbsolutePath(absolutePath); +} diff --git a/apps/desktop/src/lib/backend/data/fetch-api.helpers.ts b/apps/desktop/src/lib/backend/data/fetch-api.helpers.ts new file mode 100644 index 00000000..c05b6db2 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.helpers.ts @@ -0,0 +1,14 @@ +/** + * Build a query string from optional args, omitting null/undefined values. + */ +export function buildQuery(args: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(args)) { + if (value === undefined || value === null) { + continue; + } + params.set(key, String(value)); + } + const query = params.toString(); + return query ? `?${query}` : ''; +} diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/app-settings.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/app-settings.routes.ts new file mode 100644 index 00000000..a957ee6c --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/app-settings.routes.ts @@ -0,0 +1,65 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** App settings, logs, and meta-tool admin routes. */ +export const appSettingsRoutes: Record = { + get_startup_settings: () => ({ method: 'GET', path: '/api/v1/settings/startup' }), + get_server_update_settings: () => ({ method: 'GET', path: '/api/v1/settings/server-updates' }), + get_meta_tools_enabled: () => ({ method: 'GET', path: '/api/v1/settings/meta-tools-enabled' }), + get_version: () => ({ method: 'GET', path: '/api/v1/app/version' }), + get_bundle_version: () => ({ method: 'GET', path: '/api/v1/app/bundle-version' }), + get_build_info: () => ({ method: 'GET', path: '/api/v1/app/build-info' }), + get_logs_path: () => ({ method: 'GET', path: '/api/v1/app/logs-path' }), + get_server_logs: (args) => ({ + method: 'GET', + path: `/api/v1/logs/server/${encodeURIComponent(String(args.serverId))}${buildQuery({ + limit: args.limit, + levelFilter: args.levelFilter, + })}`, + }), + get_server_log_file: (args) => ({ + method: 'GET', + path: `/api/v1/logs/server/${encodeURIComponent(String(args.serverId))}/file`, + }), + get_log_retention_days: () => ({ method: 'GET', path: '/api/v1/logs/retention-days' }), + update_startup_settings: (args) => ({ + method: 'PUT', + path: '/api/v1/settings/startup', + body: args.settings as Record, + }), + update_server_update_settings: (args) => ({ + method: 'PUT', + path: '/api/v1/settings/server-updates', + body: args.settings as Record, + }), + set_meta_tools_enabled: (args) => ({ + method: 'PUT', + path: '/api/v1/settings/meta-tools-enabled', + body: { enabled: args.enabled }, + }), + clear_server_logs: (args) => ({ + method: 'DELETE', + path: `/api/v1/logs/server/${encodeURIComponent(String(args.serverId))}`, + }), + set_log_retention_days: (args) => ({ + method: 'PUT', + path: '/api/v1/logs/retention-days', + body: { days: args.days }, + }), + list_meta_tool_grants: () => ({ method: 'GET', path: '/api/v1/meta-tools/grants' }), + respond_to_meta_tool_approval: (args) => ({ + method: 'POST', + path: '/api/v1/meta-tools/approval', + body: { + request_id: args.requestId, + client_id: args.clientId, + tool_name: args.toolName, + decision: args.decision, + }, + }), + revoke_meta_tool_grant: (args) => ({ + method: 'POST', + path: '/api/v1/meta-tools/grants/revoke', + body: { client_id: args.clientId, tool_name: args.toolName }, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/catalog.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/catalog.routes.ts new file mode 100644 index 00000000..bdec0b28 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/catalog.routes.ts @@ -0,0 +1,43 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** Registry discovery and server feature catalog routes. */ +export const catalogRoutes: Record = { + discover_servers: () => ({ method: 'GET', path: '/api/v1/registry/discover' }), + get_server_definition: (args) => ({ + method: 'GET', + path: `/api/v1/registry/definition/${encodeURIComponent(String(args.serverId))}`, + }), + get_registry_ui_config: () => ({ method: 'GET', path: '/api/v1/registry/ui-config' }), + get_registry_home_config: () => ({ method: 'GET', path: '/api/v1/registry/home-config' }), + is_registry_offline: () => ({ method: 'GET', path: '/api/v1/registry/offline' }), + refresh_registry: () => ({ method: 'POST', path: '/api/v1/registry/refresh' }), + list_server_features: (args) => ({ + method: 'GET', + path: `/api/v1/server-features${buildQuery({ + spaceId: args.spaceId, + includeUnavailable: args.includeUnavailable, + })}`, + }), + list_server_features_by_server: (args) => ({ + method: 'GET', + path: `/api/v1/server-features/by-server${buildQuery({ + spaceId: args.spaceId, + serverId: args.serverId, + includeUnavailable: args.includeUnavailable, + })}`, + }), + list_server_features_by_type: (args) => ({ + method: 'GET', + path: `/api/v1/server-features/by-type${buildQuery({ + spaceId: args.spaceId, + serverId: args.serverId, + featureType: args.featureType, + includeUnavailable: args.includeUnavailable, + })}`, + }), + get_server_feature: (args) => ({ + method: 'GET', + path: `/api/v1/server-features/${encodeURIComponent(String(args.id))}`, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/config-export.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/config-export.routes.ts new file mode 100644 index 00000000..7e6b6392 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/config-export.routes.ts @@ -0,0 +1,35 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** MCP client config export admin routes (preview, paths, backup). */ +export const configExportRoutes: Record = { + preview_config_export: (args) => { + const request = args.request as + | { client_type?: string; space_id?: string; mask_credentials?: boolean } + | undefined; + return { + method: 'GET', + path: `/api/v1/config-export/preview${buildQuery({ + clientType: request?.client_type, + spaceId: request?.space_id, + maskCredentials: request?.mask_credentials, + })}`, + }; + }, + get_config_paths: () => ({ method: 'GET', path: '/api/v1/config-export/paths' }), + check_config_exists: (args) => ({ + method: 'POST', + path: '/api/v1/config-export/check', + body: { clientType: args.clientType }, + }), + backup_existing_config: (args) => ({ + method: 'POST', + path: '/api/v1/config-export/backup', + body: { clientType: args.clientType }, + }), + export_config_to_file: (args) => ({ + method: 'POST', + path: '/api/v1/config-export/export', + body: { request: args.request, path: args.path }, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/gateway.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/gateway.routes.ts new file mode 100644 index 00000000..2bbebc75 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/gateway.routes.ts @@ -0,0 +1,62 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** Gateway lifecycle and pool admin routes. */ +export const gatewayRoutes: Record = { + get_gateway_status: (args) => ({ + method: 'GET', + path: `/api/v1/gateway/status${buildQuery({ spaceId: args.spaceId })}`, + }), + probe_gateway_start: (args) => ({ + method: 'GET', + path: `/api/v1/gateway/probe-start${buildQuery({ port: args.port })}`, + }), + take_pending_port_conflict: () => ({ + method: 'GET', + path: '/api/v1/gateway/pending-port-conflict', + }), + get_gateway_port_settings: () => ({ + method: 'GET', + path: '/api/v1/gateway/port-settings', + }), + reset_gateway_port: () => ({ method: 'GET', path: '/api/v1/gateway/reset-port' }), + list_connected_servers: () => ({ + method: 'GET', + path: '/api/v1/gateway/connected-servers', + }), + get_pool_stats: () => ({ method: 'GET', path: '/api/v1/gateway/pool-stats' }), + start_gateway: (args) => ({ + method: 'POST', + path: '/api/v1/gateway/start', + body: { port: args.port, allowDynamicFallback: args.allowDynamicFallback }, + }), + stop_gateway: () => ({ method: 'POST', path: '/api/v1/gateway/stop' }), + restart_gateway: (args) => ({ + method: 'POST', + path: '/api/v1/gateway/restart', + body: { port: args.port, allowDynamicFallback: args.allowDynamicFallback }, + }), + disconnect_server: (args) => ({ + method: 'POST', + path: '/api/v1/gateway/disconnect', + body: { serverId: args.serverId, spaceId: args.spaceId, logout: args.logout }, + }), + connect_all_enabled_servers: () => ({ + method: 'POST', + path: '/api/v1/gateway/connect-all', + }), + refresh_oauth_tokens_on_startup: () => ({ + method: 'POST', + path: '/api/v1/gateway/refresh-oauth-tokens', + }), + set_gateway_port: (args) => ({ + method: 'PUT', + path: '/api/v1/gateway/port', + body: { port: args.port }, + }), + set_gateway_public_url: (args) => ({ + method: 'PUT', + path: '/api/v1/gateway/public-url', + body: { publicUrl: args.publicUrl }, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/index.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/index.ts new file mode 100644 index 00000000..3a164a6b --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/index.ts @@ -0,0 +1,38 @@ +import { appSettingsRoutes } from './app-settings.routes'; +import { catalogRoutes } from './catalog.routes'; +import { configExportRoutes } from './config-export.routes'; +import { gatewayRoutes } from './gateway.routes'; +import { permissionsRoutes } from './permissions.routes'; +import { serversRoutes } from './servers.routes'; +import { spacesRoutes } from './spaces.routes'; +import { workspacesRoutes } from './workspaces.routes'; +import type { ApiRoute } from '../fetch-api.types'; + +const COMMAND_ROUTES = { + ...gatewayRoutes, + ...spacesRoutes, + ...serversRoutes, + ...catalogRoutes, + ...permissionsRoutes, + ...workspacesRoutes, + ...appSettingsRoutes, + ...configExportRoutes, +}; + +/** + * Map a Tauri IPC command name and its argument object to an admin REST route. + * + * @param command - Tauri command identifier (e.g. `list_spaces`, `start_gateway`). + * @param args - Command-specific payload; keys use camelCase matching the TS API layer. + * @returns HTTP method, path, and optional JSON body for `fetchApi`. + */ +export function routeFor(command: string, args: Record = {}): ApiRoute { + const handler = COMMAND_ROUTES[command as keyof typeof COMMAND_ROUTES]; + if (!handler) { + throw new Error(`Unknown command: ${command}`); + } + return handler(args); +} + +/** All registered admin transport command names (for tests and diagnostics). */ +export const registeredCommands = Object.keys(COMMAND_ROUTES).sort(); diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/permissions.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/permissions.routes.ts new file mode 100644 index 00000000..8c6014f2 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/permissions.routes.ts @@ -0,0 +1,117 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** Clients, feature sets, and OAuth grant admin routes. */ +export const permissionsRoutes: Record = { + list_clients: () => ({ method: 'GET', path: '/api/v1/clients' }), + get_client: (args) => ({ + method: 'GET', + path: `/api/v1/clients/${encodeURIComponent(String(args.id))}`, + }), + create_client: (args) => ({ + method: 'POST', + path: '/api/v1/clients', + body: args.input as Record, + }), + delete_client: (args) => ({ + method: 'DELETE', + path: `/api/v1/clients/${encodeURIComponent(String(args.id))}`, + }), + init_preset_clients: () => ({ method: 'POST', path: '/api/v1/clients/init-presets' }), + list_feature_sets: () => ({ method: 'GET', path: '/api/v1/feature-sets' }), + list_feature_sets_by_space: (args) => ({ + method: 'GET', + path: `/api/v1/feature-sets/by-space/${encodeURIComponent(String(args.spaceId))}`, + }), + get_feature_set: (args) => ({ + method: 'GET', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.id))}`, + }), + get_feature_set_with_members: (args) => ({ + method: 'GET', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.id))}/with-members`, + }), + create_feature_set: (args) => ({ + method: 'POST', + path: '/api/v1/feature-sets', + body: args.input as Record, + }), + update_feature_set: (args) => ({ + method: 'PUT', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.id))}`, + body: args.input as Record, + }), + delete_feature_set: (args) => ({ + method: 'DELETE', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.id))}`, + }), + add_feature_set_member: (args) => ({ + method: 'POST', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.featureSetId))}/members`, + body: args.input as Record, + }), + remove_feature_set_member: (args) => ({ + method: 'DELETE', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.featureSetId))}/members/${encodeURIComponent(String(args.memberId))}`, + }), + set_feature_set_members: (args) => ({ + method: 'PUT', + path: `/api/v1/feature-sets/${encodeURIComponent(String(args.featureSetId))}/members`, + body: { members: args.members }, + }), + get_oauth_clients: () => ({ method: 'GET', path: '/api/v1/oauth/clients' }), + get_oauth_client_grants: (args) => ({ + method: 'GET', + path: `/api/v1/oauth/clients/${encodeURIComponent(String(args.clientId))}/grants/${encodeURIComponent(String(args.spaceId))}`, + }), + update_oauth_client: (args) => ({ + method: 'PUT', + path: `/api/v1/oauth/clients/${encodeURIComponent(String(args.clientId))}`, + body: { + client_alias: (args.settings as { client_alias?: string } | undefined)?.client_alias, + }, + }), + delete_oauth_client: (args) => ({ + method: 'DELETE', + path: `/api/v1/oauth/clients/${encodeURIComponent(String(args.clientId))}`, + }), + grant_oauth_client_feature_set: (args) => ({ + method: 'POST', + path: `/api/v1/oauth/clients/${encodeURIComponent(String(args.clientId))}/grants`, + body: { space_id: args.spaceId, feature_set_id: args.featureSetId }, + }), + revoke_oauth_client_feature_set: (args) => ({ + method: 'POST', + path: `/api/v1/oauth/clients/${encodeURIComponent(String(args.clientId))}/grants/revoke`, + body: { space_id: args.spaceId, feature_set_id: args.featureSetId }, + }), + get_pending_consent: (args) => ({ + method: 'GET', + path: `/api/v1/oauth/consent/pending${buildQuery({ requestId: args.requestId })}`, + }), + approve_oauth_consent: (args) => { + const request = args.request as + | { request_id?: string; consent_token?: string; client_alias?: string | null } + | undefined; + return { + method: 'POST', + path: '/api/v1/oauth/consent/approve', + body: { + request_id: request?.request_id, + consent_token: request?.consent_token, + client_alias: request?.client_alias ?? null, + }, + }; + }, + reject_oauth_consent: (args) => { + const request = args.request as { request_id?: string; consent_token?: string } | undefined; + return { + method: 'POST', + path: '/api/v1/oauth/consent/reject', + body: { + request_id: request?.request_id, + consent_token: request?.consent_token, + }, + }; + }, +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/servers.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/servers.routes.ts new file mode 100644 index 00000000..959d19f6 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/servers.routes.ts @@ -0,0 +1,126 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** Installed servers, connections, and clone admin routes. */ +export const serversRoutes: Record = { + list_installed_servers: (args) => ({ + method: 'GET', + path: `/api/v1/servers/installed${buildQuery({ spaceId: args.spaceId })}`, + }), + get_server_statuses: (args) => ({ + method: 'GET', + path: `/api/v1/servers/connections${buildQuery({ spaceId: args.spaceId })}`, + }), + install_server: (args) => ({ + method: 'POST', + path: '/api/v1/servers/install', + body: { id: args.id, space_id: args.spaceId }, + }), + uninstall_server: (args) => ({ + method: 'DELETE', + path: `/api/v1/servers/${encodeURIComponent(String(args.id))}`, + body: { space_id: args.spaceId }, + }), + save_server_inputs: (args) => ({ + method: 'PUT', + path: `/api/v1/servers/${encodeURIComponent(String(args.id))}/inputs`, + body: { + input_values: args.inputValues, + space_id: args.spaceId, + env_overrides: args.envOverrides, + args_append: args.argsAppend, + extra_headers: args.extraHeaders, + default_params: args.defaultParams, + display_name_override: args.displayNameOverride, + update_policy: args.updatePolicy, + pinned_version: args.pinnedVersion, + }, + }), + set_server_display_name: (args) => ({ + method: 'PUT', + path: `/api/v1/servers/${encodeURIComponent(String(args.id))}/display-name`, + body: { space_id: args.spaceId, display_name: args.displayName }, + }), + set_server_oauth_connected: (args) => ({ + method: 'PUT', + path: `/api/v1/servers/${encodeURIComponent(String(args.id))}/oauth-connected`, + body: { space_id: args.spaceId, connected: args.connected }, + }), + enable_server_v2: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/enable', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + disable_server_v2: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/disable', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + start_auth_v2: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/start-auth', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + cancel_auth_v2: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/cancel-auth', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + retry_connection: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/retry', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + update_server_package: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/update-package', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + logout_server: (args) => ({ + method: 'POST', + path: '/api/v1/servers/connections/logout', + body: { space_id: args.spaceId, server_id: args.serverId }, + }), + clone_server: (args) => ({ + method: 'POST', + path: '/api/v1/servers/clones', + body: { + space_id: args.spaceId, + source_server_id: args.sourceServerId, + suffix: args.suffix, + alias: args.alias, + display_name: args.displayName, + }, + }), + is_clone_id_available: (args) => ({ + method: 'GET', + path: `/api/v1/servers/clones/available${buildQuery({ + spaceId: args.spaceId, + sourceServerId: args.sourceServerId, + suffix: args.suffix, + })}`, + }), + suggest_clone_suffix: (args) => ({ + method: 'GET', + path: `/api/v1/servers/clones/suggest${buildQuery({ + spaceId: args.spaceId, + sourceServerId: args.sourceServerId, + })}`, + }), + list_clone_dependents: (args) => ({ + method: 'GET', + path: `/api/v1/servers/clones/dependents${buildQuery({ + spaceId: args.spaceId, + sourceServerId: args.sourceServerId, + })}`, + }), + check_all_server_updates: () => ({ + method: 'POST', + path: '/api/v1/servers/updates/check-all', + }), + check_server_version: (args) => ({ + method: 'POST', + path: `/api/v1/servers/${encodeURIComponent(String(args.serverId))}/updates/check`, + body: { space_id: args.spaceId }, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/spaces.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/spaces.routes.ts new file mode 100644 index 00000000..aa2a8172 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/spaces.routes.ts @@ -0,0 +1,37 @@ +import type { RouteHandler } from '../fetch-api.types'; + +/** Space CRUD and config admin routes. */ +export const spacesRoutes: Record = { + list_spaces: () => ({ method: 'GET', path: '/api/v1/spaces' }), + get_space: (args) => ({ + method: 'GET', + path: `/api/v1/spaces/${encodeURIComponent(String(args.id))}`, + }), + read_space_config: (args) => ({ + method: 'GET', + path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/config`, + }), + create_space: (args) => ({ + method: 'POST', + path: '/api/v1/spaces', + body: { name: args.name, icon: args.icon }, + }), + update_space: (args) => ({ + method: 'PUT', + path: `/api/v1/spaces/${encodeURIComponent(String(args.id))}`, + body: args.input as Record, + }), + delete_space: (args) => ({ + method: 'DELETE', + path: `/api/v1/spaces/${encodeURIComponent(String(args.id))}`, + }), + save_space_config: (args) => ({ + method: 'PUT', + path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/config`, + body: { content: args.content }, + }), + remove_server_from_config: (args) => ({ + method: 'DELETE', + path: `/api/v1/spaces/${encodeURIComponent(String(args.spaceId))}/config/servers/${encodeURIComponent(String(args.serverId))}`, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.routes/workspaces.routes.ts b/apps/desktop/src/lib/backend/data/fetch-api.routes/workspaces.routes.ts new file mode 100644 index 00000000..5e333c2f --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.routes/workspaces.routes.ts @@ -0,0 +1,57 @@ +import { buildQuery } from '../fetch-api.helpers'; +import type { RouteHandler } from '../fetch-api.types'; + +/** Workspace bindings and appearances routes. */ +export const workspacesRoutes: Record = { + list_workspace_bindings: () => ({ method: 'GET', path: '/api/v1/workspaces/bindings' }), + list_workspace_bindings_for_space: (args) => ({ + method: 'GET', + path: `/api/v1/workspaces/bindings/space/${encodeURIComponent(String(args.spaceId))}`, + }), + list_reported_workspace_roots: () => ({ + method: 'GET', + path: '/api/v1/workspaces/reported-roots', + }), + validate_workspace_root: (args) => ({ + method: 'GET', + path: `/api/v1/workspaces/validate-root${buildQuery({ path: args.path })}`, + }), + get_workspace_effective_features: (args) => ({ + method: 'GET', + path: `/api/v1/workspaces/effective-features${buildQuery({ workspaceRoot: args.workspaceRoot })}`, + }), + list_workspace_appearances: () => ({ method: 'GET', path: '/api/v1/workspaces/appearances' }), + resolve_workspace_icon_path: (args) => ({ + method: 'GET', + path: `/api/v1/workspaces/icon-path${buildQuery({ iconRef: args.iconRef })}`, + }), + create_workspace_binding: (args) => ({ + method: 'POST', + path: '/api/v1/workspaces/bindings', + body: args.input as Record, + }), + update_workspace_binding: (args) => ({ + method: 'PUT', + path: `/api/v1/workspaces/bindings/${encodeURIComponent(String(args.id))}`, + body: args.input as Record, + }), + delete_workspace_binding: (args) => ({ + method: 'DELETE', + path: `/api/v1/workspaces/bindings/${encodeURIComponent(String(args.id))}`, + }), + upsert_workspace_appearance: (args) => ({ + method: 'PUT', + path: '/api/v1/workspaces/appearances', + body: args.input as Record, + }), + delete_workspace_appearance: (args) => ({ + method: 'DELETE', + path: '/api/v1/workspaces/appearances', + body: { workspace_root: args.workspaceRoot }, + }), + upload_workspace_icon: (args) => ({ + method: 'POST', + path: '/api/v1/workspaces/appearances', + body: { source_path: args.sourcePath }, + }), +}; diff --git a/apps/desktop/src/lib/backend/data/fetch-api.ts b/apps/desktop/src/lib/backend/data/fetch-api.ts new file mode 100644 index 00000000..367076f9 --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.ts @@ -0,0 +1,216 @@ +export type { ApiRoute } from './fetch-api.types'; +export { routeFor, registeredCommands } from './fetch-api.routes'; + +import { routeFor } from './fetch-api.routes'; + +let cachedCsrfToken: string | null = null; +let csrfFetchInFlight: Promise | null = null; +let adminReadyPromise: Promise | null = null; + +const RETRYABLE_STATUS_CODES = new Set([500, 502, 503, 504]); +const MAX_GET_RETRIES = 4; +const MAX_MUTATION_RETRIES = 4; +const GET_RETRY_DELAYS_MS = [400, 800, 1600, 3200]; +const CSRF_RETRY_DELAY_MS = 200; + +/** + * Pause briefly before retrying a transient admin API failure. + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Returns true when the admin server rejected a stale CSRF token. + */ +function isCsrfRejection(status: number, message: string): boolean { + return status === 403 && message.toLowerCase().includes('csrf'); +} + +/** + * Clear the cached CSRF token so the next mutating request fetches a fresh one. + */ +function invalidateCsrfToken(): void { + cachedCsrfToken = null; + csrfFetchInFlight = null; +} + +/** + * Returns true when a GET should be retried (admin restarting, proxy blip, etc.). + */ +function isRetryableGetFailure(status: number | null): boolean { + return status === null || RETRYABLE_STATUS_CODES.has(status); +} + +/** + * Poll admin `/health` until the backend accepts requests (web startup / hot-reload). + * Single-flight per page load so React Strict Mode does not double-invalidate CSRF. + */ +export async function waitForAdminReady(timeoutMs = 15000): Promise { + if (!adminReadyPromise) { + adminReadyPromise = waitForAdminReadyOnce(timeoutMs); + } + return adminReadyPromise; +} + +/** + * One-shot admin readiness probe plus CSRF prefetch for the current admin process. + */ +async function waitForAdminReadyOnce(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let attempt = 0; + + while (Date.now() < deadline) { + try { + const response = await fetch('/api/v1/health', { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (response.ok) { + invalidateCsrfToken(); + if (attempt > 0) { + console.info(`[fetchApi] Admin API ready after ${attempt + 1} health probe(s)`); + } + // CSRF is fetched lazily on the first mutating request — do not block GET startup sync. + return; + } + } catch { + // Vite proxy or admin not listening yet + } + + const delayMs = GET_RETRY_DELAYS_MS[Math.min(attempt, GET_RETRY_DELAYS_MS.length - 1)] ?? 3200; + attempt += 1; + await sleep(delayMs); + } + + console.warn(`[fetchApi] Admin health probe timed out after ${timeoutMs}ms — proceeding anyway`); + invalidateCsrfToken(); +} + +/** + * Fetch and cache the CSRF token for mutating admin requests. + */ +async function ensureCsrfToken(): Promise { + if (cachedCsrfToken) { + return cachedCsrfToken; + } + if (csrfFetchInFlight) { + return csrfFetchInFlight; + } + + csrfFetchInFlight = (async () => { + const response = await fetch('/api/v1/csrf-token', { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error('Failed to fetch CSRF token'); + } + const body = (await response.json()) as { token?: string }; + if (!body.token) { + throw new Error('CSRF token missing from response'); + } + cachedCsrfToken = body.token; + return cachedCsrfToken; + })(); + + try { + return await csrfFetchInFlight; + } finally { + csrfFetchInFlight = null; + } +} + +/** + * Execute an admin REST request for the given command mapping. + */ +export async function fetchApi( + command: string, + args?: Record +): Promise { + const { method, path, body } = routeFor(command, args ?? {}); + const maxAttempts = method === 'GET' ? MAX_GET_RETRIES : MAX_MUTATION_RETRIES; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const headers: Record = { Accept: 'application/json' }; + const init: RequestInit = { + method, + headers, + credentials: 'same-origin', + }; + + if (method !== 'GET') { + headers['Content-Type'] = 'application/json'; + headers['X-CSRF-Token'] = await ensureCsrfToken(); + if (body !== undefined) { + init.body = JSON.stringify(body); + } else if (method === 'POST' || method === 'PUT' || method === 'DELETE') { + init.body = '{}'; + } + } + + let response: Response; + try { + response = await fetch(path, init); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (method === 'GET' && attempt < maxAttempts - 1 && isRetryableGetFailure(null)) { + const delayMs = GET_RETRY_DELAYS_MS[attempt] ?? 3200; + console.warn( + `[fetchApi] ${method} ${path} network error (attempt ${attempt + 1}/${maxAttempts}): ${message} — retrying in ${delayMs}ms` + ); + await sleep(delayMs); + continue; + } + throw new Error(`${message} (${method} ${path})`); + } + + if (!response.ok) { + const responseBody = await response.text(); + let message = responseBody || response.statusText; + try { + const parsed = JSON.parse(responseBody) as { error?: string }; + if (parsed.error) { + message = parsed.error; + } + } catch { + // keep raw body + } + + if ( + method === 'GET' && + attempt < maxAttempts - 1 && + isRetryableGetFailure(response.status) + ) { + const delayMs = GET_RETRY_DELAYS_MS[attempt] ?? 3200; + console.warn( + `[fetchApi] ${method} ${path} failed with ${response.status} (attempt ${attempt + 1}/${maxAttempts}): ${message} — retrying in ${delayMs}ms` + ); + await sleep(delayMs); + continue; + } + + if (method !== 'GET' && attempt < maxAttempts - 1 && isCsrfRejection(response.status, message)) { + console.warn( + `[fetchApi] ${method} ${path} CSRF rejected (attempt ${attempt + 1}/${maxAttempts}) — refreshing token` + ); + invalidateCsrfToken(); + await sleep(CSRF_RETRY_DELAY_MS); + continue; + } + + console.error( + `[fetchApi] ${method} ${path} failed with ${response.status}: ${message}` + ); + throw new Error(`${message} (${method} ${path})`); + } + + return response.json() as Promise; + } + + throw new Error(`Request failed after retries (${method} ${path})`); +} diff --git a/apps/desktop/src/lib/backend/data/fetch-api.types.ts b/apps/desktop/src/lib/backend/data/fetch-api.types.ts new file mode 100644 index 00000000..6ca94a7c --- /dev/null +++ b/apps/desktop/src/lib/backend/data/fetch-api.types.ts @@ -0,0 +1,9 @@ +/** HTTP route descriptor for admin REST transport. */ +export interface ApiRoute { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + body?: Record; +} + +/** Maps Tauri command args to an admin REST route. */ +export type RouteHandler = (args: Record) => ApiRoute; diff --git a/apps/desktop/src/lib/backend/data/transport.ts b/apps/desktop/src/lib/backend/data/transport.ts new file mode 100644 index 00000000..33f0089a --- /dev/null +++ b/apps/desktop/src/lib/backend/data/transport.ts @@ -0,0 +1,23 @@ +import { invoke } from '@tauri-apps/api/core'; + +import { fetchApi } from './fetch-api'; + +/** + * Returns true when running inside the Tauri desktop shell. + */ +export function isTauri(): boolean { + return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; +} + +/** + * Dispatch a backend command through Tauri IPC or the admin REST API. + */ +export async function apiCall( + command: string, + args?: Record +): Promise { + if (isTauri()) { + return invoke(command, args); + } + return fetchApi(command, args); +} diff --git a/apps/desktop/src/lib/backend/events/admin-sse-hub.ts b/apps/desktop/src/lib/backend/events/admin-sse-hub.ts new file mode 100644 index 00000000..cafeec49 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/admin-sse-hub.ts @@ -0,0 +1,155 @@ +/** + * Single shared EventSource for web admin — avoids HTTP/1.1 connection starvation + * when multiple hooks call `useDomainEvents()` (each used to open its own SSE). + */ + +import { isTauri } from '../data/transport'; + +import type { + AllEventsCallback, + DomainEventChannel, + DomainEventPayload, +} from './useDomainEvents'; + +/** All domain channels streamed over SSE. */ +export const ADMIN_SSE_CHANNELS: DomainEventChannel[] = [ + 'space-changed', + 'server-changed', + 'server-update-available', + 'server-status-changed', + 'server-auth-progress', + 'server-features-refreshed', + 'feature-set-changed', + 'client-changed', + 'client-grant-changed', + 'gateway-changed', + 'mcp-notification', +]; + +type ChannelHandler = (payload: DomainEventPayload) => void; + +let sharedSource: EventSource | null = null; +let consumerCount = 0; +let sseEnabled = false; +const channelHandlers = new Map>(); +const allHandlers = new Set(); +const lastEventListeners = new Set<(event: { channel: DomainEventChannel; payload: DomainEventPayload }) => void>(); + +/** + * Dispatch an SSE frame to all registered handlers. + */ +function dispatch(channel: DomainEventChannel, payload: DomainEventPayload): void { + const event = { channel, payload }; + lastEventListeners.forEach((listener) => listener(event)); + channelHandlers.get(channel)?.forEach((handler) => handler(payload)); + allHandlers.forEach((handler) => handler(channel, payload)); +} + +/** + * Open the shared admin SSE connection when the first consumer attaches. + */ +function ensureSharedSource(): void { + if (sharedSource || isTauri()) { + return; + } + + const source = new EventSource('/api/v1/events'); + sharedSource = source; + + for (const channel of ADMIN_SSE_CHANNELS) { + source.addEventListener(channel, (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as DomainEventPayload; + dispatch(channel, payload); + } catch { + // ignore malformed frames + } + }); + } +} + +/** + * Close the shared SSE connection when the last consumer detaches. + */ +function releaseSharedSource(): void { + if (consumerCount > 0 || !sharedSource) { + return; + } + sharedSource.close(); + sharedSource = null; +} + +/** + * Open the shared SSE connection after startup sync (listSpaces) has finished. + */ +export function enableAdminSse(): void { + if (isTauri()) { + return; + } + sseEnabled = true; + if (consumerCount > 0) { + ensureSharedSource(); + } +} + +/** + * Register a hook instance as an SSE consumer (ref-counted). + */ +export function acquireAdminSseConsumer(): void { + if (isTauri()) { + return; + } + consumerCount += 1; + if (sseEnabled) { + ensureSharedSource(); + } +} + +/** + * Unregister an SSE consumer; closes the connection when ref-count hits zero. + */ +export function releaseAdminSseConsumer(): void { + if (isTauri()) { + return; + } + consumerCount = Math.max(0, consumerCount - 1); + releaseSharedSource(); +} + +/** + * Subscribe to one SSE channel on the shared connection. + */ +export function subscribeAdminSseChannel( + channel: DomainEventChannel, + handler: ChannelHandler +): () => void { + if (!channelHandlers.has(channel)) { + channelHandlers.set(channel, new Set()); + } + channelHandlers.get(channel)!.add(handler); + return () => { + channelHandlers.get(channel)?.delete(handler); + }; +} + +/** + * Subscribe to all SSE channels on the shared connection. + */ +export function subscribeAdminSseAll(handler: AllEventsCallback): () => void { + allHandlers.add(handler); + return () => { + allHandlers.delete(handler); + }; +} + +/** + * Listen for the most recent SSE event (for hook `lastEvent` state). + */ +export function onAdminSseLastEvent( + listener: (event: { channel: DomainEventChannel; payload: DomainEventPayload }) => void +): () => void { + lastEventListeners.add(listener); + return () => { + lastEventListeners.delete(listener); + }; +} diff --git a/apps/desktop/src/lib/backend/events/index.ts b/apps/desktop/src/lib/backend/events/index.ts new file mode 100644 index 00000000..5ff0ddae --- /dev/null +++ b/apps/desktop/src/lib/backend/events/index.ts @@ -0,0 +1,63 @@ +/** + * Backend events facade — Tauri IPC on desktop, admin SSE on web. + * @see AGENTS.md Frontend Notes (`@/lib/backend` facade) + */ + +export { + useDomainEvents, + useSpaceEvents, + useServerStatusEvents, + useServerAuthProgress, + useClientEvents, + useGatewayEvents, +} from './useDomainEvents'; + +export type { + DomainEventChannel, + DomainEventPayload, + SpaceChangedPayload, + ServerChangedPayload, + ServerUpdateAvailablePayload, + ServerStatusChangedPayload, + ServerAuthProgressPayload, + ServerFeaturesRefreshedPayload, + FeatureSetChangedPayload, + ClientChangedPayload, + ClientGrantChangedPayload, + GatewayChangedPayload, + MCPNotificationPayload, + ChannelCallback, + AllEventsCallback, + PayloadTypeMap, +} from './useDomainEvents'; + +export { + useWorkspaceEvents, + useWorkspaceEventListener, +} from './useWorkspaceEvents'; + +export type { + WorkspaceEventChannel, + WorkspaceBindingChangedPayload, + WorkspaceNeedsBindingPayload, + WorkspaceChannelCallback, + WorkspaceEventsCallback, + WorkspacePayloadTypeMap, +} from './useWorkspaceEvents'; + +export { + useOAuthClientEvents, + useOAuthClientEventListener, +} from './useOAuthClientEvents'; + +export type { OAuthClientChangedPayload } from './useOAuthClientEvents'; + +export { + useMetaToolEvents, + useMetaToolEventListener, +} from './useMetaToolEvents'; + +export { + useBackendEventSubscription, + type BackendEventSubscriptionOptions, +} from './use-backend-event-subscription'; diff --git a/apps/desktop/src/lib/backend/events/tauri-adapter.ts b/apps/desktop/src/lib/backend/events/tauri-adapter.ts new file mode 100644 index 00000000..02fb0c12 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/tauri-adapter.ts @@ -0,0 +1,16 @@ +import { listen, type Event, type UnlistenFn } from '@tauri-apps/api/event'; + +import { isTauri } from '../data/transport'; + +/** + * Subscribe to a Tauri IPC event channel (desktop only; no-op on web). + */ +export async function listenWhenTauri( + event: string, + handler: (event: Event) => void +): Promise { + if (!isTauri()) { + return undefined; + } + return listen(event, handler); +} diff --git a/apps/desktop/src/lib/backend/events/use-backend-event-subscription.ts b/apps/desktop/src/lib/backend/events/use-backend-event-subscription.ts new file mode 100644 index 00000000..acc0f42d --- /dev/null +++ b/apps/desktop/src/lib/backend/events/use-backend-event-subscription.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; + +import { isTauri } from '../data/transport'; + +import { listenWhenTauri } from './tauri-adapter'; + +/** Options for {@link useBackendEventSubscription}. */ +export interface BackendEventSubscriptionOptions { + /** When false, skip SSE on web (desktop-only channels). Default true. */ + sse?: boolean; +} + +/** + * React hook that subscribes to a backend event channel via Tauri IPC or admin SSE. + */ +export function useBackendEventSubscription( + channel: string, + callback: (payload: T) => void, + options: BackendEventSubscriptionOptions = {} +): void { + const { sse = true } = options; + + useEffect(() => { + if (isTauri()) { + let disposed = false; + let unlistenFn: (() => void) | undefined; + + void listenWhenTauri(channel, (event) => { + callback(event.payload); + }).then((fn) => { + if (disposed) { + fn?.(); + } else { + unlistenFn = fn; + } + }); + + return () => { + disposed = true; + unlistenFn?.(); + }; + } + + if (!sse) { + return; + } + + const source = new EventSource('/api/v1/events'); + const onMessage = (event: MessageEvent) => { + try { + callback(JSON.parse(event.data) as T); + } catch { + // ignore malformed frames + } + }; + source.addEventListener(channel, onMessage); + + return () => { + source.removeEventListener(channel, onMessage); + source.close(); + }; + }, [channel, callback, sse]); +} diff --git a/apps/desktop/src/lib/backend/events/useDomainEvents.ts b/apps/desktop/src/lib/backend/events/useDomainEvents.ts new file mode 100644 index 00000000..54e6a86a --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useDomainEvents.ts @@ -0,0 +1,385 @@ +/** + * useDomainEvents - Hook for subscribing to domain events from backend + * + * This hook provides a reactive interface to all domain events emitted + * by the backend. Events are grouped by channel for easy subscription. + * + * ## Available Channels + * + * - `space-changed` - Space create/update/delete/activate + * - `server-changed` - Server install/uninstall/enable/disable + * - `server-status-changed` - Connection status updates + * - `server-auth-progress` - OAuth countdown timer + * - `server-features-refreshed` - Features discovered/updated + * - `feature-set-changed` - Feature set create/update/delete + * - `client-changed` - Client registration/update/delete + * - `client-grant-changed` - Per-client feature-set grant edits + * - `gateway-changed` - Gateway start/stop + * - `mcp-notification` - MCP capability notifications + * + * ## Usage + * + * ```tsx + * function MyComponent() { + * const { subscribe, subscribeAll, events } = useDomainEvents(); + * + * // Subscribe to specific channel + * useEffect(() => { + * return subscribe('server-status-changed', (payload) => { + * console.log('Server status:', payload.server_id, payload.status); + * }); + * }, [subscribe]); + * + * // Or subscribe to all events + * useEffect(() => { + * return subscribeAll((channel, payload) => { + * console.log(`[${channel}]`, payload); + * }); + * }, [subscribeAll]); + * } + * ``` + */ + +import { useEffect, useCallback, useRef, useState } from 'react'; +import { listen, UnlistenFn, Event } from '@tauri-apps/api/event'; + +import { isTauri } from '../data/transport'; + +import { useDomainEventsWeb } from './useDomainEventsWeb'; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** Domain event channels */ +export type DomainEventChannel = + | 'space-changed' + | 'server-changed' + | 'server-update-available' + | 'server-status-changed' + | 'server-auth-progress' + | 'server-features-refreshed' + | 'feature-set-changed' + | 'client-changed' + | 'client-grant-changed' + | 'gateway-changed' + | 'mcp-notification'; + +/** Base event payload */ +export interface DomainEventPayload { + action?: string; + [key: string]: unknown; +} + +/** Space event payloads */ +export interface SpaceChangedPayload extends DomainEventPayload { + action: 'created' | 'updated' | 'deleted' | 'activated'; + space_id: string; + name?: string; + icon?: string; + from_space_id?: string; + to_space_id?: string; + to_space_name?: string; +} + +/** Server lifecycle event payloads */ +export interface ServerChangedPayload extends DomainEventPayload { + action: 'installed' | 'uninstalled' | 'config_updated' | 'enabled' | 'disabled'; + space_id: string; + server_id: string; + server_name?: string; +} + +/** Server package update probe payload */ +export interface ServerUpdateAvailablePayload extends DomainEventPayload { + space_id: string; + server_id: string; + current_version?: string | null; + latest_version?: string | null; +} + +/** Server status event payload */ +export interface ServerStatusChangedPayload extends DomainEventPayload { + space_id: string; + server_id: string; + status: 'connected' | 'disconnected' | 'connecting' | 'error' | 'oauth_required' | 'refreshing' | 'authenticating'; + flow_id: number; + has_connected_before: boolean; + message?: string; + features?: { + tools_count: number; + prompts_count: number; + resources_count: number; + }; +} + +/** Server auth progress payload */ +export interface ServerAuthProgressPayload extends DomainEventPayload { + space_id: string; + server_id: string; + remaining_seconds: number; + flow_id: number; +} + +/** Server features refreshed payload */ +export interface ServerFeaturesRefreshedPayload extends DomainEventPayload { + space_id: string; + server_id: string; + tools_count: number; + prompts_count: number; + resources_count: number; + added: string[]; + removed: string[]; +} + +/** Feature set event payloads */ +export interface FeatureSetChangedPayload extends DomainEventPayload { + action: 'created' | 'updated' | 'deleted' | 'members_changed'; + space_id: string; + feature_set_id: string; + name?: string; + feature_set_type?: string; + added_count?: number; + removed_count?: number; +} + +/** Client event payloads */ +export interface ClientChangedPayload extends DomainEventPayload { + action: 'registered' | 'updated' | 'deleted' | 'token_issued'; + client_id: string; + client_name?: string; + registration_type?: string; +} + +/** Client grant event payload (matches gateway bridge emit). */ +export interface ClientGrantChangedPayload extends DomainEventPayload { + client_id: string; + space_id: string; +} + +/** Gateway event payloads */ +export interface GatewayChangedPayload extends DomainEventPayload { + action: 'started' | 'stopped'; + url?: string; + port?: number; +} + +/** MCP notification payload */ +export interface MCPNotificationPayload extends DomainEventPayload { + type: 'tools_changed' | 'prompts_changed' | 'resources_changed'; + space_id: string; + server_id: string; +} + +/** Payload type map for type safety */ +export interface PayloadTypeMap { + 'space-changed': SpaceChangedPayload; + 'server-changed': ServerChangedPayload; + 'server-update-available': ServerUpdateAvailablePayload; + 'server-status-changed': ServerStatusChangedPayload; + 'server-auth-progress': ServerAuthProgressPayload; + 'server-features-refreshed': ServerFeaturesRefreshedPayload; + 'feature-set-changed': FeatureSetChangedPayload; + 'client-changed': ClientChangedPayload; + 'client-grant-changed': ClientGrantChangedPayload; + 'gateway-changed': GatewayChangedPayload; + 'mcp-notification': MCPNotificationPayload; +} + +/** Type-safe callback for specific channels */ +export type ChannelCallback = ( + payload: PayloadTypeMap[T] +) => void; + +/** Callback for all events */ +export type AllEventsCallback = ( + channel: DomainEventChannel, + payload: DomainEventPayload +) => void; + +// ============================================================================ +// HOOK IMPLEMENTATION +// ============================================================================ + +/** All channels that can receive events */ +const ALL_CHANNELS: DomainEventChannel[] = [ + 'space-changed', + 'server-changed', + 'server-update-available', + 'server-status-changed', + 'server-auth-progress', + 'server-features-refreshed', + 'feature-set-changed', + 'client-changed', + 'client-grant-changed', + 'gateway-changed', + 'mcp-notification', +]; + +/** + * Hook for subscribing to domain events from the backend (Tauri IPC). + */ +function useDomainEventsTauri() { + const activeListeners = useRef([]); + const [lastEvent, setLastEvent] = useState<{ + channel: DomainEventChannel; + payload: DomainEventPayload; + } | null>(null); + + useEffect(() => { + return () => { + activeListeners.current.forEach((unlisten) => unlisten()); + activeListeners.current = []; + }; + }, []); + + /** + * Subscribe to a specific event channel + * Returns unsubscribe function + */ + const subscribe = useCallback( + (channel: T, callback: ChannelCallback): (() => void) => { + if (!isTauri()) { + return () => {}; + } + let unlistenFn: UnlistenFn | null = null; + + listen(channel, (event: Event) => { + callback(event.payload); + setLastEvent({ channel, payload: event.payload as DomainEventPayload }); + }).then((unlisten) => { + unlistenFn = unlisten; + activeListeners.current.push(unlisten); + }); + + return () => { + if (unlistenFn) { + unlistenFn(); + activeListeners.current = activeListeners.current.filter( + (fn) => fn !== unlistenFn + ); + } + }; + }, + [] + ); + + /** + * Subscribe to all event channels + * Returns unsubscribe function + */ + const subscribeAll = useCallback((callback: AllEventsCallback): (() => void) => { + const unlisteners: (() => void)[] = []; + + for (const channel of ALL_CHANNELS) { + const unsub = subscribe(channel, (payload) => { + callback(channel, payload); + }); + unlisteners.push(unsub); + } + + return () => { + unlisteners.forEach((unsub) => unsub()); + }; + }, [subscribe]); + + /** + * Subscribe to multiple channels with the same callback + */ + const subscribeMany = useCallback( + (channels: DomainEventChannel[], callback: AllEventsCallback): (() => void) => { + const unlisteners: (() => void)[] = []; + + for (const channel of channels) { + const unsub = subscribe(channel, (payload) => { + callback(channel, payload); + }); + unlisteners.push(unsub); + } + + return () => { + unlisteners.forEach((unsub) => unsub()); + }; + }, + [subscribe] + ); + + return { + subscribe, + subscribeAll, + subscribeMany, + lastEvent, + channels: ALL_CHANNELS, + }; +} + +/** + * Hook for subscribing to domain events from the backend. + * Uses Tauri events on desktop and SSE on web admin. + */ +export function useDomainEvents() { + const tauri = useDomainEventsTauri(); + const web = useDomainEventsWeb(); + return isTauri() ? tauri : web; +} + +// ============================================================================ +// CONVENIENCE HOOKS +// ============================================================================ + +/** + * Hook that subscribes to space changes + */ +export function useSpaceEvents(callback: ChannelCallback<'space-changed'>) { + const { subscribe } = useDomainEvents(); + + useEffect(() => { + return subscribe('space-changed', callback); + }, [subscribe, callback]); +} + +/** + * Hook that subscribes to server status changes + */ +export function useServerStatusEvents(callback: ChannelCallback<'server-status-changed'>) { + const { subscribe } = useDomainEvents(); + + useEffect(() => { + return subscribe('server-status-changed', callback); + }, [subscribe, callback]); +} + +/** + * Hook that subscribes to server auth progress + */ +export function useServerAuthProgress(callback: ChannelCallback<'server-auth-progress'>) { + const { subscribe } = useDomainEvents(); + + useEffect(() => { + return subscribe('server-auth-progress', callback); + }, [subscribe, callback]); +} + +/** + * Hook that subscribes to client registration and grant changes. + */ +export function useClientEvents(callback: AllEventsCallback) { + const { subscribeMany } = useDomainEvents(); + + useEffect(() => { + return subscribeMany(['client-changed', 'client-grant-changed'], callback); + }, [subscribeMany, callback]); +} + +/** + * Hook that subscribes to gateway state changes + */ +export function useGatewayEvents(callback: ChannelCallback<'gateway-changed'>) { + const { subscribe } = useDomainEvents(); + + useEffect(() => { + return subscribe('gateway-changed', callback); + }, [subscribe, callback]); +} + +export default useDomainEvents; diff --git a/apps/desktop/src/lib/backend/events/useDomainEventsWeb.ts b/apps/desktop/src/lib/backend/events/useDomainEventsWeb.ts new file mode 100644 index 00000000..c01d9047 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useDomainEventsWeb.ts @@ -0,0 +1,87 @@ +/** + * SSE-based domain event listener for web admin mode. + * + * Mirrors the `useDomainEvents` API (`subscribe`, `subscribeAll`, `subscribeMany`) + * using a single shared `GET /api/v1/events` connection (`admin-sse-hub.ts`). + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { + acquireAdminSseConsumer, + onAdminSseLastEvent, + releaseAdminSseConsumer, + subscribeAdminSseAll, + subscribeAdminSseChannel, +} from './admin-sse-hub'; + +import type { + AllEventsCallback, + ChannelCallback, + DomainEventChannel, + DomainEventPayload, + PayloadTypeMap, +} from './useDomainEvents'; +import { ADMIN_SSE_CHANNELS } from './admin-sse-hub'; + +/** + * Subscribe to admin SSE domain events in web mode (shared EventSource). + */ +export function useDomainEventsWeb() { + const [lastEvent, setLastEvent] = useState<{ + channel: DomainEventChannel; + payload: DomainEventPayload; + } | null>(null); + + useEffect(() => { + acquireAdminSseConsumer(); + const offLast = onAdminSseLastEvent(setLastEvent); + return () => { + offLast(); + releaseAdminSseConsumer(); + }; + }, []); + + /** + * Subscribe to a specific SSE event channel. + */ + const subscribe = useCallback( + (channel: T, callback: ChannelCallback): (() => void) => { + const wrapped = callback as (payload: DomainEventPayload) => void; + return subscribeAdminSseChannel(channel, wrapped); + }, + [] + ); + + /** + * Subscribe to all domain SSE channels. + */ + const subscribeAll = useCallback((callback: AllEventsCallback): () => void => { + return subscribeAdminSseAll(callback); + }, []); + + /** + * Subscribe to multiple domain SSE channels with one callback. + */ + const subscribeMany = useCallback( + (channels: DomainEventChannel[], callback: AllEventsCallback): (() => void) => { + const unsubs = channels.map((channel) => + subscribe(channel, (payload) => { + callback(channel, payload as PayloadTypeMap[typeof channel]); + }) + ); + return () => { + unsubs.forEach((unsub) => unsub()); + }; + }, + [subscribe] + ); + + return { + subscribe, + subscribeAll, + subscribeMany, + lastEvent, + channels: ADMIN_SSE_CHANNELS, + }; +} diff --git a/apps/desktop/src/lib/backend/events/useMetaToolEvents.ts b/apps/desktop/src/lib/backend/events/useMetaToolEvents.ts new file mode 100644 index 00000000..108ad342 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useMetaToolEvents.ts @@ -0,0 +1,81 @@ +/** + * useMetaToolEvents — meta-tool invocation audit stream. + * + * Subscribes to `meta-tool-invoked`, emitted by the EventBus → gateway bridge + * when any `mcpmux_*` tool runs (read or write, all decision outcomes). + */ + +import { useCallback, useEffect, useRef } from 'react'; +import { listen, UnlistenFn, Event } from '@tauri-apps/api/event'; + +import type { MetaToolAuditEvent } from '@/lib/api/metaTools'; + +import { isTauri } from '../data/transport'; + +import { useMetaToolEventsWeb } from './useMetaToolEventsWeb'; + +/** + * Hook for subscribing to meta-tool invocation events (Tauri). + */ +function useMetaToolEventsTauri() { + const activeListeners = useRef([]); + + useEffect(() => { + return () => { + activeListeners.current.forEach((unlisten) => unlisten()); + activeListeners.current = []; + }; + }, []); + + /** + * Subscribe to `meta-tool-invoked`. + * Returns an unsubscribe function. + */ + const subscribe = useCallback( + (callback: (event: MetaToolAuditEvent) => void): (() => void) => { + if (!isTauri()) { + return () => {}; + } + let unlistenFn: UnlistenFn | null = null; + + listen('meta-tool-invoked', (event: Event) => { + callback(event.payload); + }).then((unlisten) => { + unlistenFn = unlisten; + activeListeners.current.push(unlisten); + }); + + return () => { + if (unlistenFn) { + unlistenFn(); + activeListeners.current = activeListeners.current.filter((fn) => fn !== unlistenFn); + } + }; + }, + [] + ); + + return { subscribe }; +} + +/** + * Hook for meta-tool events — Tauri on desktop, SSE on web admin. + */ +export function useMetaToolEvents() { + const tauri = useMetaToolEventsTauri(); + const web = useMetaToolEventsWeb(); + return isTauri() ? tauri : web; +} + +/** + * Convenience hook — invokes callback on every meta-tool invocation. + */ +export function useMetaToolEventListener(callback: (event: MetaToolAuditEvent) => void): void { + const { subscribe } = useMetaToolEvents(); + + useEffect(() => { + return subscribe(callback); + }, [subscribe, callback]); +} + +export default useMetaToolEvents; diff --git a/apps/desktop/src/lib/backend/events/useMetaToolEventsWeb.ts b/apps/desktop/src/lib/backend/events/useMetaToolEventsWeb.ts new file mode 100644 index 00000000..412583ca --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useMetaToolEventsWeb.ts @@ -0,0 +1,46 @@ +/** + * SSE meta-tool invocation events for web admin mode. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +import type { MetaToolAuditEvent } from '@/lib/api/metaTools'; + +import { isTauri } from '../data/transport'; + +/** + * Subscribe to `meta-tool-invoked` over SSE in web admin mode. + */ +export function useMetaToolEventsWeb() { + const handlersRef = useRef void>>(new Set()); + + useEffect(() => { + if (isTauri()) { + return; + } + const source = new EventSource('/api/v1/events'); + + source.addEventListener('meta-tool-invoked', (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as MetaToolAuditEvent; + handlersRef.current.forEach((handler) => handler(payload)); + } catch { + // ignore malformed frames + } + }); + + return () => source.close(); + }, []); + + /** + * Subscribe to meta-tool SSE events. + */ + const subscribe = useCallback((callback: (event: MetaToolAuditEvent) => void): (() => void) => { + handlersRef.current.add(callback); + return () => { + handlersRef.current.delete(callback); + }; + }, []); + + return { subscribe }; +} diff --git a/apps/desktop/src/lib/backend/events/useOAuthClientEvents.ts b/apps/desktop/src/lib/backend/events/useOAuthClientEvents.ts new file mode 100644 index 00000000..79f8d536 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useOAuthClientEvents.ts @@ -0,0 +1,88 @@ +/** + * useOAuthClientEvents — OAuth dynamic client registration changes. + * + * Subscribes to `oauth-client-changed`, emitted directly from oauth.rs when + * OAuth clients are created, updated, or deleted (not via EventBus bridge). + */ + +import { useCallback, useEffect, useRef } from 'react'; +import { listen, UnlistenFn, Event } from '@tauri-apps/api/event'; + +import { isTauri } from '../data/transport'; + +import { useOAuthClientEventsWeb } from './useOAuthClientEventsWeb'; + +/** Payload for `oauth-client-changed`. */ +export interface OAuthClientChangedPayload { + action?: 'created' | 'updated' | 'deleted'; + client_id?: string; + [key: string]: unknown; +} + +/** + * Hook for subscribing to OAuth client change events (Tauri). + */ +function useOAuthClientEventsTauri() { + const activeListeners = useRef([]); + + useEffect(() => { + return () => { + activeListeners.current.forEach((unlisten) => unlisten()); + activeListeners.current = []; + }; + }, []); + + /** + * Subscribe to `oauth-client-changed`. + * Returns an unsubscribe function. + */ + const subscribe = useCallback( + (callback: (payload: OAuthClientChangedPayload) => void): (() => void) => { + if (!isTauri()) { + return () => {}; + } + let unlistenFn: UnlistenFn | null = null; + + listen('oauth-client-changed', (event: Event) => { + callback(event.payload); + }).then((unlisten) => { + unlistenFn = unlisten; + activeListeners.current.push(unlisten); + }); + + return () => { + if (unlistenFn) { + unlistenFn(); + activeListeners.current = activeListeners.current.filter((fn) => fn !== unlistenFn); + } + }; + }, + [] + ); + + return { subscribe }; +} + +/** + * Hook for OAuth client events — Tauri on desktop, SSE on web admin. + */ +export function useOAuthClientEvents() { + const tauri = useOAuthClientEventsTauri(); + const web = useOAuthClientEventsWeb(); + return isTauri() ? tauri : web; +} + +/** + * Convenience hook — invokes callback when an OAuth client changes. + */ +export function useOAuthClientEventListener(callback: () => void): void { + const { subscribe } = useOAuthClientEvents(); + + useEffect(() => { + return subscribe(() => { + callback(); + }); + }, [subscribe, callback]); +} + +export default useOAuthClientEvents; diff --git a/apps/desktop/src/lib/backend/events/useOAuthClientEventsWeb.ts b/apps/desktop/src/lib/backend/events/useOAuthClientEventsWeb.ts new file mode 100644 index 00000000..dc33fdd6 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useOAuthClientEventsWeb.ts @@ -0,0 +1,49 @@ +/** + * SSE OAuth client change events for web admin mode. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +import { isTauri } from '../data/transport'; + +import type { OAuthClientChangedPayload } from './useOAuthClientEvents'; + +/** + * Subscribe to `oauth-client-changed` over SSE in web admin mode. + */ +export function useOAuthClientEventsWeb() { + const handlersRef = useRef void>>(new Set()); + + useEffect(() => { + if (isTauri()) { + return; + } + const source = new EventSource('/api/v1/events'); + + source.addEventListener('oauth-client-changed', (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as OAuthClientChangedPayload; + handlersRef.current.forEach((handler) => handler(payload)); + } catch { + // ignore malformed frames + } + }); + + return () => source.close(); + }, []); + + /** + * Subscribe to OAuth client SSE events. + */ + const subscribe = useCallback( + (callback: (payload: OAuthClientChangedPayload) => void): (() => void) => { + handlersRef.current.add(callback); + return () => { + handlersRef.current.delete(callback); + }; + }, + [] + ); + + return { subscribe }; +} diff --git a/apps/desktop/src/lib/backend/events/useWorkspaceEvents.ts b/apps/desktop/src/lib/backend/events/useWorkspaceEvents.ts new file mode 100644 index 00000000..c3d163bd --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useWorkspaceEvents.ts @@ -0,0 +1,159 @@ +/** + * useWorkspaceEvents — workspace binding and session roots channels. + * + * Subscribes to Tauri events emitted by the gateway bridge: + * - `session-roots-changed` — EventBus bridge (reported roots updated) + * - `workspace-binding-changed` — EventBus bridge (binding + appearance writes) + * - `workspace-needs-binding` — EventBus bridge (unbound root prompt) + */ + +import { useCallback, useEffect, useRef } from 'react'; +import { listen, UnlistenFn, Event } from '@tauri-apps/api/event'; + +import { isTauri } from '../data/transport'; + +import { useWorkspaceEventsWeb } from './useWorkspaceEventsWeb'; + +/** Workspace-related Tauri event channels. */ +export type WorkspaceEventChannel = + | 'session-roots-changed' + | 'workspace-binding-changed' + | 'workspace-needs-binding'; + +/** Payload for `workspace-binding-changed` (binding or appearance update). */ +export interface WorkspaceBindingChangedPayload { + space_id?: string; + workspace_root: string; +} + +/** Payload for `workspace-needs-binding`. */ +export interface WorkspaceNeedsBindingPayload { + client_id: string; + session_id: string; + space_id: string; + workspace_root: string; + /** Set when another client's scoped binding blocked the global route. */ + collision_client_id?: string | null; +} + +/** Payload map for type-safe subscriptions. */ +export interface WorkspacePayloadTypeMap { + 'session-roots-changed': Record; + 'workspace-binding-changed': WorkspaceBindingChangedPayload; + 'workspace-needs-binding': WorkspaceNeedsBindingPayload; +} + +/** Callback for a specific workspace channel. */ +export type WorkspaceChannelCallback = ( + payload: WorkspacePayloadTypeMap[T] +) => void; + +/** Callback receiving channel name and payload. */ +export type WorkspaceEventsCallback = ( + channel: T, + payload: WorkspacePayloadTypeMap[T] +) => void; + +const ALL_WORKSPACE_CHANNELS: WorkspaceEventChannel[] = [ + 'session-roots-changed', + 'workspace-binding-changed', + 'workspace-needs-binding', +]; + +/** + * Hook for subscribing to workspace-related Tauri event channels. + */ +function useWorkspaceEventsTauri() { + const activeListeners = useRef([]); + + useEffect(() => { + return () => { + activeListeners.current.forEach((unlisten) => unlisten()); + activeListeners.current = []; + }; + }, []); + + /** + * Subscribe to a single workspace event channel. + * Returns an unsubscribe function. + */ + const subscribe = useCallback( + ( + channel: T, + callback: WorkspaceChannelCallback + ): (() => void) => { + if (!isTauri()) { + return () => {}; + } + let unlistenFn: UnlistenFn | null = null; + + listen(channel, (event: Event) => { + callback(event.payload); + }).then((unlisten) => { + unlistenFn = unlisten; + activeListeners.current.push(unlisten); + }); + + return () => { + if (unlistenFn) { + unlistenFn(); + activeListeners.current = activeListeners.current.filter((fn) => fn !== unlistenFn); + } + }; + }, + [] + ); + + /** + * Subscribe to multiple workspace channels with one callback. + * Returns an unsubscribe function. + */ + const subscribeMany = useCallback( + (channels: WorkspaceEventChannel[], callback: WorkspaceEventsCallback): (() => void) => { + const unlisteners: (() => void)[] = []; + + for (const channel of channels) { + const unsub = subscribe(channel, (payload) => { + callback(channel, payload); + }); + unlisteners.push(unsub); + } + + return () => { + unlisteners.forEach((unsub) => unsub()); + }; + }, + [subscribe] + ); + + return { + subscribe, + subscribeMany, + channels: ALL_WORKSPACE_CHANNELS, + }; +} + +/** + * Hook for workspace events — Tauri on desktop, SSE on web admin. + */ +export function useWorkspaceEvents() { + const tauri = useWorkspaceEventsTauri(); + const web = useWorkspaceEventsWeb(); + return isTauri() ? tauri : web; +} + +/** + * Convenience hook — invokes callback when any workspace channel fires. + */ +export function useWorkspaceEventListener( + callback: WorkspaceEventsCallback, + channels: WorkspaceEventChannel[] = ALL_WORKSPACE_CHANNELS +): void { + const { subscribeMany } = useWorkspaceEvents(); + + useEffect(() => { + return subscribeMany(channels, callback); + }, [subscribeMany, callback, channels]); +} + +export default useWorkspaceEvents; diff --git a/apps/desktop/src/lib/backend/events/useWorkspaceEventsWeb.ts b/apps/desktop/src/lib/backend/events/useWorkspaceEventsWeb.ts new file mode 100644 index 00000000..44f49413 --- /dev/null +++ b/apps/desktop/src/lib/backend/events/useWorkspaceEventsWeb.ts @@ -0,0 +1,94 @@ +/** + * SSE workspace event channels for web admin mode. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +import { isTauri } from '../data/transport'; + +import type { + WorkspaceChannelCallback, + WorkspaceEventChannel, + WorkspaceEventsCallback, + WorkspacePayloadTypeMap, +} from './useWorkspaceEvents'; + +const ALL_WORKSPACE_CHANNELS: WorkspaceEventChannel[] = [ + 'session-roots-changed', + 'workspace-binding-changed', + 'workspace-needs-binding', +]; + +/** + * Subscribe to workspace-related SSE channels in web admin mode. + */ +export function useWorkspaceEventsWeb() { + const handlersRef = useRef< + Map void>> + >(new Map()); + + useEffect(() => { + if (isTauri()) { + return; + } + const source = new EventSource('/api/v1/events'); + + for (const channel of ALL_WORKSPACE_CHANNELS) { + source.addEventListener(channel, (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as WorkspacePayloadTypeMap[typeof channel]; + handlersRef.current.get(channel)?.forEach((handler) => handler(payload)); + } catch { + // ignore malformed frames + } + }); + } + + return () => source.close(); + }, []); + + /** + * Subscribe to a single workspace SSE channel. + */ + const subscribe = useCallback( + ( + channel: T, + callback: WorkspaceChannelCallback + ): (() => void) => { + if (!handlersRef.current.has(channel)) { + handlersRef.current.set(channel, new Set()); + } + const wrapped = callback as ( + payload: WorkspacePayloadTypeMap[WorkspaceEventChannel] + ) => void; + handlersRef.current.get(channel)!.add(wrapped); + return () => { + handlersRef.current.get(channel)?.delete(wrapped); + }; + }, + [] + ); + + /** + * Subscribe to multiple workspace SSE channels. + */ + const subscribeMany = useCallback( + (channels: WorkspaceEventChannel[], callback: WorkspaceEventsCallback): (() => void) => { + const unsubs = channels.map((channel) => + subscribe(channel, (payload) => { + callback(channel, payload); + }) + ); + return () => { + unsubs.forEach((unsub) => unsub()); + }; + }, + [subscribe] + ); + + return { + subscribe, + subscribeMany, + channels: ALL_WORKSPACE_CHANNELS, + }; +} diff --git a/apps/desktop/src/lib/backend/index.ts b/apps/desktop/src/lib/backend/index.ts new file mode 100644 index 00000000..202093d8 --- /dev/null +++ b/apps/desktop/src/lib/backend/index.ts @@ -0,0 +1,10 @@ +/** + * Unified backend facade — three channels: data (commands), events (Phase 2), shell (desktop-only). + * @see AGENTS.md Frontend Notes (`@/lib/backend` facade) + */ + +export * from '../api'; +export * from './data/transport'; +export * from './data/fetch-api'; +export * from './events'; +export * as shell from './shell'; diff --git a/apps/desktop/src/lib/backend/shell/index.ts b/apps/desktop/src/lib/backend/shell/index.ts new file mode 100644 index 00000000..4dab947f --- /dev/null +++ b/apps/desktop/src/lib/backend/shell/index.ts @@ -0,0 +1,326 @@ +import { convertFileSrc, invoke } from '@tauri-apps/api/core'; +import { emit, listen, type Event, type UnlistenFn } from '@tauri-apps/api/event'; +import { open, type OpenDialogOptions } from '@tauri-apps/plugin-dialog'; +import { relaunch } from '@tauri-apps/plugin-process'; +import type { Update } from '@tauri-apps/plugin-updater'; + +import type { ExportConfigRequest } from '@/lib/api/configExport'; +import type { AdminWebSettings } from '@/lib/api/settings'; + +import { apiCall, isTauri } from '../data/transport'; + +export { isTauri }; +export type { Event, UnlistenFn, Update }; + +declare global { + interface Window { + __TAURI_TEST_API__?: { + invoke: typeof invoke; + emit: typeof emit; + }; + } +} + +/** Window chrome control actions for the custom title bar. */ +export type WindowControlAction = 'minimize' | 'maximize' | 'close'; + +/** + * Expose Tauri invoke/emit on window for E2E tests (desktop shell only). + */ +export function initTauriTestApi(): void { + if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) { + return; + } + window.__TAURI_TEST_API__ = { invoke, emit }; +} + +/** + * Return true when the URL targets a loopback HTTP(S) OAuth callback. + */ +function isLocalhostHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; + return isLocalhost && (parsed.protocol === 'http:' || parsed.protocol === 'https:'); + } catch { + return false; + } +} + +/** + * Open a URL using the system's default handler. + * + * In web admin mode the browser opens the URL directly. Desktop uses Tauri + * so custom protocol handlers (e.g. `cursor://`) reach the OS. + */ +export async function openUrl(url: string): Promise { + if (!isTauri()) { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + await apiCall('open_url', { url }); +} + +/** + * Open an external URL with opener-plugin and location fallbacks. + */ +export async function openExternal(url: string): Promise { + try { + await openUrl(url); + } catch (err) { + if (isLocalhostHttpUrl(url)) { + console.warn('[Shell] Loopback OAuth callback unavailable — not opening browser:', err); + return; + } + console.error('[Shell] openUrl failed:', err); + if (isTauri()) { + try { + const { openUrl: pluginOpenUrl } = await import('@tauri-apps/plugin-opener'); + await pluginOpenUrl(url); + } catch (pluginErr) { + console.error('[Shell] plugin-opener failed:', pluginErr); + } + return; + } + window.location.href = url; + } +} + +/** + * Perform a native window control action (desktop title bar only). + */ +export async function performWindowControl(action: WindowControlAction): Promise { + if (!isTauri()) { + return; + } + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const appWindow = getCurrentWindow(); + if (action === 'minimize') { + appWindow.minimize(); + } else if (action === 'maximize') { + appWindow.toggleMaximize(); + } else { + appWindow.close(); + } +} + +/** + * Subscribe to a Tauri event only in the desktop shell. + */ +export async function listenWhenTauri( + event: string, + handler: (event: Event) => void +): Promise { + if (!isTauri()) { + return undefined; + } + return listen(event, handler); +} + +/** + * Convert an absolute filesystem path to a webview-safe asset URL (desktop only). + */ +export function fileSrcFromAbsolutePath(absolutePath: string | null): string | null { + if (!absolutePath || !isTauri()) { + return null; + } + return convertFileSrc(absolutePath); +} + +/** + * Open the native file/directory picker (desktop only). + */ +export async function pickPath( + options: OpenDialogOptions +): Promise { + if (!isTauri()) { + return null; + } + const selected = await open(options); + if (selected === null) { + return null; + } + return selected; +} + +/** + * Flush a cold-start OAuth deep link after the consent listener is ready (desktop only). + */ +export async function flushPendingDeepLink(): Promise { + if (!isTauri()) { + return; + } + await invoke('flush_pending_deep_link'); +} + +/** Payload for OAuth consent deep links on desktop. */ +export interface OAuthConsentDeepLinkPayload { + requestId: string; +} + +/** + * Subscribe to OAuth consent deep-link events and flush any buffered URL (desktop only). + */ +export async function subscribeOAuthConsentRequest( + handler: (payload: OAuthConsentDeepLinkPayload) => void +): Promise { + if (!isTauri()) { + console.log('[OAuth] subscribeOAuthConsentRequest skipped — not Tauri'); + return undefined; + } + console.log('[OAuth] Subscribing to Tauri event:', 'oauth-consent-request'); + const unlisten = await listen( + 'oauth-consent-request', + (event) => { + console.log('[OAuth] Tauri consent event received:', event.payload); + handler(event.payload); + } + ); + void flushPendingDeepLink().catch((err) => { + console.warn('[OAuth] flush_pending_deep_link failed:', err); + }); + return unlisten; +} + +/** + * Subscribe to OAuth consent requests (Tauri events on desktop, SSE on web admin). + */ +export function subscribeOAuthConsentEvents( + handler: (payload: OAuthConsentDeepLinkPayload) => void +): () => void { + if (isTauri()) { + console.log('[OAuth] subscribeOAuthConsentEvents: using Tauri listener'); + let unlisten: UnlistenFn | undefined; + void subscribeOAuthConsentRequest(handler).then((fn) => { + unlisten = fn; + console.log('[OAuth] Tauri consent listener registered'); + }); + return () => { + console.log('[OAuth] Unsubscribing Tauri consent listener'); + unlisten?.(); + }; + } + + console.log('[OAuth] subscribeOAuthConsentEvents: using SSE /api/v1/events'); + const source = new EventSource('/api/v1/events'); + source.onopen = () => console.log('[OAuth] SSE connected'); + source.onerror = (err) => console.warn('[OAuth] SSE error:', err); + const onConsentRequest = (event: MessageEvent) => { + console.log('[OAuth] SSE consent event raw:', event.data); + try { + const payload = JSON.parse(event.data) as OAuthConsentDeepLinkPayload; + if (payload.requestId) { + console.log('[OAuth] SSE consent event parsed:', payload); + handler(payload); + } + } catch { + console.warn('[OAuth] SSE consent event: malformed payload'); + } + }; + source.addEventListener('oauth-consent-request', onConsentRequest); + return () => { + console.log('[OAuth] Closing SSE consent listener'); + source.removeEventListener('oauth-consent-request', onConsentRequest); + source.close(); + }; +} + +/** + * Open the application logs folder in the system file manager (desktop only). + */ +export async function openLogsFolder(): Promise { + if (!isTauri()) { + return; + } + await invoke('open_logs_folder'); +} + +/** + * Load web admin HTTP server settings (desktop control plane only). + */ +export async function getAdminWebSettings(): Promise { + return invoke('get_admin_web_settings'); +} + +/** + * Persist web admin settings and restart the admin HTTP server (desktop only). + */ +export async function updateAdminWebSettings(settings: AdminWebSettings): Promise { + await invoke('update_admin_web_settings', { settings }); +} + +/** + * Reveal a space config file in the system editor (desktop only). + */ +export async function openSpaceConfigFile(spaceId: string): Promise { + if (!isTauri()) { + return; + } + await invoke('open_space_config_file', { spaceId }); +} + +/** + * Add McpMux to VS Code via deep link (desktop only). + */ +export async function addToVscode(gatewayUrl: string): Promise { + if (!isTauri()) { + return; + } + await invoke('add_to_vscode', { gatewayUrl }); +} + +/** + * Add McpMux to Cursor via deep link (desktop only). + */ +export async function addToCursor(gatewayUrl: string): Promise { + if (!isTauri()) { + return; + } + await invoke('add_to_cursor', { gatewayUrl }); +} + +/** + * Write generated MCP client config JSON to a user-selected path (desktop only). + */ +export async function exportConfigToFile( + request: ExportConfigRequest, + path: string +): Promise { + return invoke('export_config_to_file', { request, path }); +} + +/** + * Check the Tauri updater for an available release (desktop only). + */ +export async function checkForAvailableUpdate(): Promise<{ version: string } | null> { + if (!isTauri()) { + return null; + } + const { check } = await import('@tauri-apps/plugin-updater'); + const update = await check(); + if (!update) { + return null; + } + return { version: update.version }; +} + +/** + * Run the Tauri updater check and return the full update handle (desktop only). + */ +export async function checkAppUpdate(): Promise { + if (!isTauri()) { + return null; + } + const { check } = await import('@tauri-apps/plugin-updater'); + return check(); +} + +/** + * Relaunch the desktop app after installing an update (desktop only). + */ +export async function relaunchApp(): Promise { + if (!isTauri()) { + return; + } + await relaunch(); +} diff --git a/apps/desktop/src/lib/build-info.helpers.ts b/apps/desktop/src/lib/build-info.helpers.ts new file mode 100644 index 00000000..2d3f4f4a --- /dev/null +++ b/apps/desktop/src/lib/build-info.helpers.ts @@ -0,0 +1,138 @@ +import { formatStampInstant } from '@/utils/build-date.helpers'; +import { getBuildInfo, getVersion, type BuildInfo } from '@/lib/api/app'; +import { isTauri } from '@/lib/backend/shell'; + +/** A labeled row for build stamp display in Settings UI. */ +export interface BuildStampRow { + label: string; + value: string; + mono?: boolean; + testId: string; +} + +/** Git/build metadata stamped into the web-admin SPA at Vite build time. */ +export interface BuildStamp { + gitSha: string; + gitBranch: string; + commitTime: string; + commitAt: string; + buildTime: string; + buildAt: string; +} + +const LABEL_STYLE = 'color: #888; font-weight: bold'; +const VALUE_STYLE = 'color: inherit'; + +/** + * Read SPA build metadata from Vite compile-time env vars. + */ +export function getSpaBuildStamp(): BuildStamp { + const commitTime = import.meta.env.VITE_BUILD_COMMIT_TIME ?? ''; + const buildTime = import.meta.env.VITE_BUILD_TIME ?? ''; + return { + gitSha: import.meta.env.VITE_BUILD_GIT_SHA ?? '', + gitBranch: import.meta.env.VITE_BUILD_GIT_BRANCH ?? '', + commitTime, + commitAt: import.meta.env.VITE_BUILD_COMMIT_AT || formatStampInstant(commitTime), + buildTime, + buildAt: import.meta.env.VITE_BUILD_AT || formatStampInstant(buildTime), + }; +} + +/** + * Map SPA compile-time stamp fields to labeled display rows. + */ +export function buildStampDisplayRows(stamp: BuildStamp): BuildStampRow[] { + return [ + { label: 'Branch', value: stamp.gitBranch || 'unknown', mono: true, testId: 'build-stamp-branch' }, + { label: 'Commit', value: stamp.gitSha || 'unknown', mono: true, testId: 'build-stamp-commit' }, + { label: 'Committed', value: stamp.commitAt || 'unknown', testId: 'build-stamp-committed' }, + { label: 'Built', value: stamp.buildAt || 'unknown', testId: 'build-stamp-built' }, + ]; +} + +/** + * Map backend compile-time build info to labeled display rows. + */ +export function backendBuildInfoRows(info: BuildInfo): BuildStampRow[] { + return [ + { label: 'Branch', value: info.git_branch || 'unknown', mono: true, testId: 'build-stamp-branch' }, + { label: 'Commit', value: info.git_sha || 'unknown', mono: true, testId: 'build-stamp-commit' }, + { + label: 'Committed', + value: formatStampInstant(info.commit_time), + testId: 'build-stamp-committed', + }, + { + label: 'Built', + value: formatStampInstant(info.build_time), + testId: 'build-stamp-built', + }, + ]; +} + +/** + * Format a gateway-style build line for Node/build logs. + */ +export function formatBuildStampLine(prefix: string, stamp: BuildStamp): string { + return `${prefix} | sha: ${stamp.gitSha} | branch: ${stamp.gitBranch} | committed: ${stamp.commitAt} | built: ${stamp.buildAt}`; +} + +/** + * Log a generAIt-style labeled row in the browser console. + */ +function logConsoleRow(label: string, value: string): void { + const pad = Math.max(1, 13 - label.length); + console.info(`%c${label}:%c${' '.repeat(pad)}${value}`, LABEL_STYLE, VALUE_STYLE); +} + +/** + * Log SPA and backend build metadata to the browser console on every boot + * (dev, production Tauri, and web-admin static bundle). + * Visual style matches generAIt Frontend startup banner (group + gray labels). + */ +export async function logWebAdminBuildInfo(): Promise { + const headerColor = import.meta.env.DEV ? '#70e000' : '#DA7756'; + const appLabel = import.meta.env.VITE_ADMIN_WEB ? 'McpMux Web Admin' : 'McpMux'; + console.group( + `%c ${appLabel} `, + `background: ${headerColor}; color: #000; font-weight: bold; border-radius: 4px; padding: 2px 6px;`, + ); + + logConsoleRow('Transport', isTauri() ? 'tauri' : 'admin-http'); + logConsoleRow('Host', window.location.hostname); + logConsoleRow('Mode', import.meta.env.DEV ? 'development' : 'production'); + + const spa = getSpaBuildStamp(); + + try { + const [backend, version] = await Promise.all([getBuildInfo(), getVersion()]); + logConsoleRow('Version', version); + + if (spa.gitSha) { + logConsoleRow('Branch', spa.gitBranch); + logConsoleRow('Commit', spa.gitSha); + logConsoleRow('Committed', spa.commitAt); + logConsoleRow('Built', spa.buildAt); + } + + if (backend.git_sha && backend.git_sha !== spa.gitSha) { + logConsoleRow('Backend', formatStampInstant(backend.build_time)); + logConsoleRow('Backend sha', backend.git_sha); + } else if (backend.build_time && backend.build_time !== spa.buildTime) { + logConsoleRow('Backend', formatStampInstant(backend.build_time)); + } + + if (spa.gitSha && backend.git_sha && spa.gitSha !== backend.git_sha) { + console.warn( + `%cStale:%c SPA (${spa.gitSha}) != backend (${backend.git_sha}) — run pnpm build:web:admin`, + LABEL_STYLE, + VALUE_STYLE, + ); + } + } catch { + console.warn('%cBackend:%c build info unavailable', LABEL_STYLE, VALUE_STYLE); + } + + console.groupEnd(); +} diff --git a/apps/desktop/src/lib/contribute.ts b/apps/desktop/src/lib/contribute.ts index 8d121ad7..f279e62f 100644 --- a/apps/desktop/src/lib/contribute.ts +++ b/apps/desktop/src/lib/contribute.ts @@ -5,12 +5,11 @@ * All URLs live here so we can update the target org / repo / site from one * place instead of grepping for hardcoded strings. * - * Open-in-browser goes through `openUrl` (our Tauri command wrapping - * `tauri-plugin-opener`) so the user's default browser handles the URL - * rather than loading it inside the webview. + * Open-in-browser goes through `backend.shell.openExternal` so the user's + * default browser handles the URL rather than loading it inside the webview. */ -import { openUrl } from '@/lib/api/gateway'; +import { openExternal as shellOpenExternal } from '@/lib/backend/shell'; export const CONTRIBUTE = { /** Main desktop + gateway repo. */ @@ -48,14 +47,8 @@ export const CONTRIBUTE = { } as const; /** - * Open an external URL via the Tauri opener plugin. Falls back to the plugin - * directly if our gateway wrapper fails (mirrors OAuthConsentModal's pattern). + * Open an external URL via the desktop shell opener (no-op fallback on web). */ export async function openExternal(url: string): Promise { - try { - await openUrl(url); - } catch { - const { openUrl: plugin } = await import('@tauri-apps/plugin-opener'); - await plugin(url); - } + await shellOpenExternal(url); } diff --git a/apps/desktop/src/lib/desktop-shell.ts b/apps/desktop/src/lib/desktop-shell.ts new file mode 100644 index 00000000..a868dca4 --- /dev/null +++ b/apps/desktop/src/lib/desktop-shell.ts @@ -0,0 +1,2 @@ +/** @deprecated Prefer `@/lib/backend/shell` — shim during facade migration. */ +export * from './backend/shell'; diff --git a/apps/desktop/src/lib/monaco-setup.ts b/apps/desktop/src/lib/monaco-setup.ts new file mode 100644 index 00000000..3b13d358 --- /dev/null +++ b/apps/desktop/src/lib/monaco-setup.ts @@ -0,0 +1,20 @@ +/** + * Self-host Monaco from the bundled npm package instead of the default CDN loader. + * Required for prod Tauri builds where CSP blocks cdn.jsdelivr.net. + */ +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; + +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; + +self.MonacoEnvironment = { + getWorker(_workerId: unknown, label: string) { + if (label === 'json') { + return new jsonWorker(); + } + return new editorWorker(); + }, +}; + +loader.config({ monaco }); diff --git a/apps/desktop/src/utils/build-date.helpers.ts b/apps/desktop/src/utils/build-date.helpers.ts new file mode 100644 index 00000000..423e8986 --- /dev/null +++ b/apps/desktop/src/utils/build-date.helpers.ts @@ -0,0 +1,77 @@ +/** Local timezone for build/commit display (matches generAIt frontend). */ +const BUILD_TIMEZONE = 'America/Denver'; + +const buildDateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: BUILD_TIMEZONE, + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +const buildTimeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: BUILD_TIMEZONE, + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short', +}); + +/** + * Format a date for build metadata (e.g. "Wed, May 29 2026"). + */ +export function formatBuildDate(date: Date): string { + return buildDateFormatter.format(date); +} + +/** + * Format a time for build metadata (e.g. "06:32:19 PM MDT"). + */ +export function formatBuildTime(date: Date): string { + return buildTimeFormatter.format(date); +} + +/** + * generAIt-style combined stamp: "Wed, May 29 2026 at 06:32:19 PM MDT". + */ +export function formatBuiltAt(date: Date): string { + return `${formatBuildDate(date)} at ${formatBuildTime(date)}`; +} + +/** + * Normalize git `%ci` (`YYYY-MM-DD HH:MM:SS ±HHMM`) to ISO 8601 for cross-engine parsing. + */ +function normalizeStampInstant(raw: string): string { + const gitCi = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(raw); + if (gitCi) { + const [, date, time, sign, hours, minutes] = gitCi; + return `${date}T${time}${sign}${hours}:${minutes}`; + } + return raw; +} + +/** + * Parse git `%ci` commit time or Rust UTC build strings. + */ +export function parseStampInstant(raw: string): Date | null { + const trimmed = raw.trim(); + if (!trimmed || trimmed === 'unknown') { + return null; + } + if (trimmed.endsWith(' UTC')) { + const iso = trimmed.replace(' UTC', 'Z').replace(' ', 'T'); + const parsed = Date.parse(iso); + return Number.isNaN(parsed) ? null : new Date(parsed); + } + const parsed = Date.parse(normalizeStampInstant(trimmed)); + return Number.isNaN(parsed) ? null : new Date(parsed); +} + +/** + * Format a raw git/Rust timestamp for display. + */ +export function formatStampInstant(raw: string, fallback = 'unknown'): string { + const date = parseStampInstant(raw); + return date ? formatBuiltAt(date) : fallback; +} diff --git a/docs/planning/dev-to-main-port.md b/docs/planning/dev-to-main-port.md new file mode 100644 index 00000000..be8a1944 --- /dev/null +++ b/docs/planning/dev-to-main-port.md @@ -0,0 +1,349 @@ +# Fork → Upstream Reconciliation (`dev` + `i18n` → `main`) + +**Last Updated:** Jun 23, 2026 +**Status:** Planning — not started +**Branch:** N/A — each phase is a separate feature branch off `main` +**Base branch:** `main` (upstream `mcpmux/mcp-mux`, synced to `4a69908` as of Jun 22, 2026) +**Depends on:** `main` kept in sync with upstream before each phase branch is cut +**Unblocks:** i18n landing on upstream; all fork-native features reaching production + +--- + +## Problem + +The fork (`crimsonsunset/mcp-mux`) and upstream diverged at the repo root — no shared ancestor commit. A direct `git merge main dev --allow-unrelated-histories` produces **211 `add/add` conflicts** with zero auto-resolutions. That path is not viable. + +Upstream shipped 24 commits while `dev` accumulated 387 (+ 13 more on `i18n`). Both independently assigned **migration numbers 016–019** to different content. A full-history merge would require manually resolving every conflicted file as two competing full-file versions with no diff baseline. + +The only tractable path is porting feature clusters as targeted PRs against `main`, one group at a time, with migrations renumbered to not collide with upstream's 016–019 sequence. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | Merge strategy | **Feature-branch PRs against `main`** — no full history merge | 211 `add/add` conflicts with no shared ancestor makes a direct merge a manual rewrite. Targeted PRs keep each delta reviewable, bisectable, and independently shippable. | +| 2 | Migration renumbering | **Fork migrations 016–027 → 020–031** | Upstream occupies 016–019 (`space_builtin_servers`, `purge_orphaned_feature_set_members`, `starter_is_default_fallback_copy`, `space_base_dirs`). Fork's 016–027 must start at 020 to avoid collision. | +| 3 | Phase ordering | **Storage first, then gateway, then UI features** | UI feature pages depend on Tauri commands that depend on domain entities that depend on storage. Inverting this order means each PR would be broken in isolation. | +| 4 | `i18n` branch | **Port last, as a rebase on top of all prior phases** | `i18n` sits 13 commits ahead of `dev`. Rebasing it on top of a fully reconciled `main` is cleaner than porting it mid-sequence and having to rebase again after each subsequent phase lands. | +| 5 | `HomePage` vs `Dashboard` | **Keep both; route `HomePage` as `/` and `Dashboard` as `/dashboard`** | Upstream's `HomePage` is a summary/stats widget. Fork's `Dashboard` is a separate richer surface. No code overlap — both can coexist under different nav entries. Revisit de-duplication after landing. | +| 6 | Meta-tools file structure | **Use dev's split layout (`invoke_tool.rs`, `search_tools.rs`, etc.)** | Upstream consolidated into fewer files; dev split them for maintainability. The split is better. Port the split layout; it supersedes upstream's consolidated version for these files. | +| 7 | `packages/ui` | **Additive only — do not remove upstream's `PageHeader`** | Upstream added `PageHeader`; dev added `ChipButton`, `ConfirmDialog`, `DropdownMenu`, `HoverTooltip`, `SearchField`, `AppShell`/`Sidebar`, `use-confirm.hook`, `useClickOutside`. No overlap; just merge the index. | + +--- + +## Scope + +**In:** + +- All fork-specific storage migrations (dev's 016–027), renumbered to 020–031 +- Web admin server stack (`crates/mcpmux-gateway/src/admin/`, Tauri admin services) +- Backend facade restructure (`apps/desktop/src/lib/backend/`) +- macOS-specific Tauri features (`macos_dock.rs`, `macos_permissions.rs`, `main_window.rs`) +- Meta-tools enhancements (invoke ergonomics, search, token budget, split module layout) +- Server account cloning (backend + UI) +- Server update policy feature (already fully implemented in `dev`) +- Tool embeddings + semantic search +- Dashboard feature +- Workspace appearances (icons, theme) +- `packages/ui` shared component additions +- `i18n` rebase + landing + +**Out:** + +| Item | Reason / Deferral | +| ---- | ----------------- | +| Full git history unification | No shared ancestor; the 387-commit history stays on `dev` as the canonical fork history. Only forward work goes to `main`. | +| Upstream feature back-porting to `dev` | Once reconciliation is done `dev` becomes a staging branch off `main`; no more parallel histories after Phase 9 lands. | +| Fork CI workflow changes (`ci.yml` on `dev` branch) | Upstream CI only runs on `main`; fork CI customizations stay in `dev`'s `.github/` — they don't need to land on `main`. | +| Update history log table (Phase 4 of server-update-policy) | Deferred as documented in [`server-update-policy.md`](./server-update-policy.md) — revisit after notify flow validated in prod. | + +--- + +## Migration Renumbering Map + +Upstream's 016–019 are occupied. Fork's sequence shifts: + +| Old (dev) | New (main) | Content | +| --------- | ---------- | ------- | +| `016_workspace_binding_label.sql` | `020_workspace_binding_label.sql` | Workspace binding display label column | +| `017_installed_server_cloned_from.sql` | `021_installed_server_cloned_from.sql` | Server account clone lineage | +| `018_installed_server_display_name_override.sql` | `022_installed_server_display_name_override.sql` | Per-install display name override | +| `019_feature_set_member_surfaced.sql` | `023_feature_set_member_surfaced.sql` | Tool surfacing flag on feature set members | +| `020_workspace_icons.sql` | `024_workspace_icons.sql` | Workspace appearance icon storage | +| `021_tool_embeddings.sql` | `025_tool_embeddings.sql` | Semantic embedding cache table | +| `022_installed_server_default_params.sql` | `026_installed_server_default_params.sql` | Per-install default params column | +| `023_workspace_binding_client_scope.sql` | `027_workspace_binding_client_scope.sql` | Per-client workspace binding scope | +| `024_server_update_policy.sql` | `028_server_update_policy.sql` | Update policy + pinned_version columns | +| `025_server_version_cache.sql` | `029_server_version_cache.sql` | Version probe cache columns | +| `026_default_params_strategy.sql` | `030_default_params_strategy.sql` | Default params strategy enum | +| `027_server_current_version.sql` | `031_server_current_version.sql` | Current probed version cache | + +--- + +## Phases + +### Phase 1 — Foundation: shared UI library + backend facade (~half day) + +Pure additive work. No conflicts with upstream — all new files. + +**`packages/ui` additions:** +- `ChipButton.tsx`, `ConfirmDialog.tsx`, `DropdownMenu.tsx`, `HoverTooltip.tsx`, `SearchField.tsx` +- `use-confirm.hook.tsx`, `useClickOutside.ts` +- `AppShell.tsx`, `Sidebar.tsx` (layout layer) +- Update `packages/ui/src/index.ts` to export new components alongside upstream's `PageHeader` + +**`lib/backend/` facade restructure:** +- `apps/desktop/src/lib/backend/data/fetch-api.ts` + types, helpers +- `apps/desktop/src/lib/backend/data/fetch-api.routes/` (app-settings, catalog, config-export, gateway, permissions, servers, spaces, workspaces routes) +- `apps/desktop/src/lib/backend/events/` (admin-sse-hub, tauri-adapter, use-backend-event-subscription, domain/meta/oauth/workspace event hooks + web variants) +- `apps/desktop/src/lib/backend/shell/index.ts` +- `apps/desktop/src/lib/backend/index.ts` +- Helper files: `build-info.helpers.ts`, `build-date.helpers.ts`, `desktop-shell.ts`, `monaco-setup.ts`, `analytics.ts`, `contribute.ts` + +**Scripts:** +- Port fork-specific dev scripts: `dev-admin.mjs`, `build-web-admin.mjs`, `dev-web-admin.mjs`, `build-stamp.mjs`, `cf-access-env.mjs`, `admin-e2e-fixture.mjs`, `remote-gateway-smoke.mjs`, `run-with-repo-env.mjs`, `count-meta-tool-tokens.py` +- Add corresponding `package.json` entries + +**Outcome:** `packages/ui` exports all shared components; any feature page can import `ChipButton`, `ConfirmDialog`, etc. without patching. The `lib/backend` façade is in place so subsequent feature PRs can import from `@/lib/backend` without wiring Tauri directly. No Rust changes; `pnpm validate` clean. + +--- + +### Phase 2 — Storage layer: migration reconciliation + new repositories (~1 day) + +The most load-bearing phase. Gets all fork-specific DB schema onto `main` without colliding with upstream's 016–019. + +**Migrations:** +- Copy + rename all 12 fork migrations per the renumbering map above (020–031) +- Verify `database.rs` migration array registers them in numeric order after upstream's 019 + +**New domain entities (additive fields on existing entities):** +- `InstalledServer`: `cloned_from`, `display_name_override`, `default_params`, `default_params_strategy`, `update_policy`, `pinned_version`, `latest_available_version`, `version_checked_at`, `current_version` +- `WorkspaceBinding`: `label`, `client_scope` +- `FeatureSetMember`: `surfaced` flag + +**New repositories:** +- `embedding_repository.rs` — tool embedding read/write +- `workspace_appearance_repository.rs` — workspace icons + theme +- Add to `repositories/mod.rs` and wire into `ApplicationServices` + +**Repository updates:** +- `installed_server_repository.rs` — read/write all new columns +- `feature_set_repository.rs` — `surfaced` field +- `workspace_binding_repository.rs` — `label` + `client_scope` + +**Outcome:** All fork-specific schema is on `main`, numbered sequentially after upstream's 019. A fresh DB build runs all 31 migrations in order cleanly. New repo traits compile; `pnpm test:rust:unit` passes. No UI changes in this phase — feature pages come later. + +--- + +### Phase 3 — Web admin server stack (~1.5 days) + +Ports the entire gateway-side admin server and the Tauri-side service wiring. This is the biggest Rust surface. + +**`crates/mcpmux-gateway/src/admin/` (new directory):** +- `mod.rs`, `router.rs`, `server.rs`, `runtime.rs`, `live_runtime.rs`, `write_runtime.rs` +- `config.rs`, `event_hub.rs`, `bridge_context.rs`, `ui_events.rs` +- `command_bridge/` — `mod.rs`, `read.rs`, `write.rs`, `oauth.rs`, `space.rs` +- `handlers/` — `mod.rs`, `read.rs`, `write.rs`, `oauth.rs`, `events.rs`, `health.rs`, `spa.rs`, `error.rs` +- `middleware/` — `mod.rs`, `cf_access.rs`, `csrf.rs` + +**Tauri-side services:** +- `apps/desktop/src-tauri/src/services/admin_server.rs` +- `apps/desktop/src-tauri/src/services/admin_write_runtime.rs` +- `apps/desktop/src-tauri/src/services/ui_events.rs` +- `apps/desktop/src-tauri/src/services/mod.rs` + +**Gateway wiring:** +- `crates/mcpmux-gateway/src/server/startup.rs` — boot admin server alongside MCP gateway +- `crates/mcpmux-gateway/src/server/service_container.rs` — include admin services +- `crates/mcpmux-gateway/src/server/dependencies.rs` — admin dependency injection +- `crates/mcpmux-gateway/src/lib.rs` — expose admin module + +**Public base URL:** +- `crates/mcpmux-gateway/src/public_base_url.rs` + +**Outcome:** `pnpm dev:web:admin` brings up the web admin on `:45819`. Fetch requests from the browser hit the admin API and return data. SSE events flow from the gateway's `AdminUiEventBus` to the browser. The admin build (`pnpm build:web:admin`) produces a working SPA. CF Access middleware compiles (not yet tested end-to-end without a tunnel). `pnpm validate` clean. + +--- + +### Phase 4 — macOS shell + Tauri features (~half day) + +Isolated macOS-specific additions that don't depend on Phase 3. + +- `apps/desktop/src-tauri/src/macos_dock.rs` — dock badge / bounce +- `apps/desktop/src-tauri/src/macos_permissions.rs` — `ensure_contacts_registered()` and other TCC calls +- `apps/desktop/src-tauri/src/main_window.rs` — window centering / focus helpers +- `apps/desktop/src-tauri/Info.plist` — `NSContactsUsageDescription`, `NSCalendarsUsageDescription`, `NSRemindersUsageDescription`, `NSAppleEventsUsageDescription` (merge with upstream plist additions) +- `apps/desktop/src-tauri/src/lib.rs` — call `ensure_contacts_registered()` from setup hook + +**Tauri commands:** +- `apps/desktop/src-tauri/src/commands/workspace_appearance.rs` — workspace icon + theme commands +- Register in `commands/mod.rs` + +**Outcome:** A macOS build shows the correct TCC prompts on first launch, dock badge updates on gateway events, and workspace appearance Tauri commands are callable. Linux/Windows builds are unaffected — all new code is `#[cfg(target_os = "macos")]` gated or additive. `pnpm validate` clean across platforms. + +--- + +### Phase 5 — Meta-tools enhancements (~1 day) + +Upstream shipped a base meta-tools implementation; dev has a significantly richer one. This phase supersedes the upstream files in the overlap zone and adds the new split-module structure. + +**Split layout (replaces upstream's consolidated files):** +- Upstream has `meta_tools/tools.rs`, `mod.rs`, `registry.rs`, `approval.rs` — dev has split these into 20+ focused modules +- Port: `invoke_tool.rs`, `invoke_backend.rs`, `invoke_payload_parse.rs`, `invoke_result_filter.rs`, `invoke_result_shaping.rs`, `invoke_alias.rs`, `invoke_tool_tests.rs`, `invoke_result_filter_tests.rs` +- Port: `search_tools.rs`, `search_tools_index.rs`, `list_servers.rs`, `meta_tool_common.rs` +- Port: `disclosure_read.rs`, `disclosure_search.rs`, `disclosure_backend.rs` +- Port: `feature_set_tools.rs`, `bind_workspace.rs`, `set_workspace_root.rs` +- Port: `token_budget.rs` +- Port: `approval_broker.rs`, `approval_types.rs`, `approval_broker_tests.rs` +- Port: `diagnose_server.rs`, `diagnose_view.rs`, `diagnose_tests.rs` + +**Gateway services:** +- `tool_discovery.rs`, `tool_discovery_index.rs`, `tool_discovery_search.rs`, `tool_discovery_tests.rs`, `tool_discovery_types.rs` +- `embedding.rs`, `embedding_warmer.rs` +- `package_version.rs`, `server_version_probe.rs` +- `discovery_rank.rs`, `prompt_discovery.rs`, `resource_discovery.rs` + +**Tauri command:** +- `apps/desktop/src-tauri/src/commands/meta_tool_approval.rs` (upstream has this too — reconcile, dev's version is more complete) + +**Outcome:** All meta-tool capabilities from `dev` work on `main`: invoke ergonomics (bare names, aliases, prefilled params, token budget), search with synonyms and inactive preview, `get_tool_schema` bare-name resolution, `diagnose_server`, approval flow. Token count target: ≤1,381 Claude-est tokens for the 4 advertised tools (matches dev's validated count). `pnpm test:rust` passes. + +--- + +### Phase 6 — Server features: cloning + update policy (~1.5 days) + +Two shipping features from `dev` that depend on Phase 2 migrations. + +**Server account cloning:** +- `apps/desktop/src-tauri/src/commands/server_clone.rs` + register in `mod.rs` +- `apps/desktop/src/features/servers/CloneAccountModal.tsx` +- `apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx` +- Update `ServersPage.tsx`, `ServerActionMenu.tsx` to wire clone actions +- Update `installed_server_repository.rs` for `cloned_from` reads (migration 021 from Phase 2) + +**Server update policy (migrations 028–031 already landed in Phase 2):** +- `crates/mcpmux-gateway/src/services/server_version_probe.rs` (background probe service) +- `crates/mcpmux-gateway/src/services/package_version.rs` +- Update `pool/transport/resolution.rs` — inject `@latest` / `@version` / `uv tool upgrade` +- `apps/desktop/src/features/servers/server-update-policy.helpers.ts` +- `apps/desktop/src/features/servers/server-pending-updates.helpers.ts` +- `apps/desktop/src/features/servers/ServerPendingUpdatesList.tsx` → `apps/desktop/src/features/settings/ServerPendingUpdatesList.tsx` +- `apps/desktop/src/features/settings/ServerUpdatesSection.tsx` +- `apps/desktop/src/features/settings/BuildStampPanel.tsx`, `use-build-stamp.hook.ts` +- `apps/desktop/src/components/StaleBuildBanner.tsx` +- Update `ServersPage.tsx`, `ServerActionMenu.tsx` for update badges + menu items +- Update `SettingsPage.tsx` for the Server Updates section + +**Outcome:** Server cloning works end-to-end (source server spawns a named clone with independent config). Update policy (Auto/Notify/Pinned) is operational: an Auto-mode npm server always spawns `@latest`; Notify-mode servers show amber badges when a newer version is available; Pinned servers lock to a specific version. Build stamp visible in Settings. Matches the shipped behaviour documented in [`server-update-policy.md`](./server-update-policy.md). + +--- + +### Phase 7 — Dashboard + workspace appearances (~1 day) + +**Dashboard (new feature, no upstream conflict):** +- `apps/desktop/src/features/dashboard/` — `DashboardPage.tsx`, `DashboardQuickLinks.tsx`, `DashboardRecentActivity.tsx`, `DashboardServerHealth.tsx`, `DashboardStatCards.tsx`, `dashboard.helpers.ts`, `useDashboardData.ts`, `index.ts` +- Add `/dashboard` route in `App.tsx` alongside upstream's `/` `HomePage` +- Add nav entry in `Sidebar` + +**Workspace appearances:** +- `crates/mcpmux-core/src/domain/workspace_appearance.rs` — new domain entity +- `crates/mcpmux-storage/src/repositories/workspace_appearance_repository.rs` (migration 024 from Phase 2) +- `apps/desktop/src-tauri/src/commands/workspace_appearance.rs` already landed in Phase 4 +- `apps/desktop/src/lib/api/workspaceAppearances.ts` +- Wire appearance data into `WorkspacesPage.tsx`, `SpaceSwitcher.tsx`, `SpacePanel.tsx` +- `apps/desktop/src/lib/spaceAccent.ts` — space color accent helpers (upstream also has this; reconcile) + +**Remaining UI components not yet ported:** +- `apps/desktop/src/components/SourceBadge.tsx` + `source-badge.helpers.ts` +- `apps/desktop/src/features/servers/AddServerMenu.tsx`, `ServerEnabledToggle.tsx`, `ServersCountSummary.tsx`, `ServersFiltersPopover.tsx` +- `apps/desktop/src/features/servers/server-display-name.helpers.ts`, `servers-page.helpers.ts` +- `apps/desktop/src/features/spaces/SpacePanel.tsx` +- `apps/desktop/src/features/settings/AboutSection.tsx` +- `apps/desktop/src/stores/registryStore.ts` +- Remaining hooks: `useMetaToolEvents.ts`, `useOAuthClientEvents.ts`, `useWorkspaceEvents.ts`, `useServerManager.ts` (reconcile with upstream) + +**Outcome:** Dashboard route resolves and shows live server health, recent activity, and stat cards against real gateway data. Workspace switcher shows custom icons and accent colours. Servers page filters, count summary, and enabled toggle work. `pnpm validate` clean; web admin Playwright smoke passes. + +--- + +### Phase 8 — i18n rebase + landing (~1 day) + +The `i18n` branch (13 commits ahead of `dev`) covers full UI string extraction across all feature pages and is the current active work. + +**Pre-requisite:** All of Phases 1–7 merged to `main`. + +**Work:** +- Rebase `i18n` onto the reconciled `main` (resolve any conflicts from the feature page rewrites in earlier phases — primarily `App.tsx`, `ServersPage.tsx`, `SettingsPage.tsx`, `SpacesPage.tsx`) +- Verify i18n translation keys still resolve across all ported components (Phase 7 UI additions may need keys added) +- `pnpm test:ts` — vitest i18n harness passes +- E2E: testid selectors added in Phase 4 of the i18n plan pass against the reconciled app +- `pnpm validate` clean + +**Outcome:** All user-visible strings are keyed through `react-i18next`. The `en` locale file covers all strings including Phase 5–7 additions. A second locale (if in progress) passes without missing keys. The app builds and tests clean from `main`. + +--- + +## Files to create / modify (summary) + +| Phase | File cluster | Action | +| ----- | ------------ | ------ | +| 1 | `packages/ui/src/components/common/*` (7 new components) | Create | +| 1 | `packages/ui/src/hooks/useClickOutside.ts` | Create | +| 1 | `packages/ui/src/components/layout/AppShell.tsx`, `Sidebar.tsx` | Create | +| 1 | `packages/ui/src/index.ts` | Modify — add exports | +| 1 | `apps/desktop/src/lib/backend/**` | Create (entire directory) | +| 1 | `apps/desktop/src/lib/build-info.helpers.ts`, `desktop-shell.ts`, etc. | Create | +| 1 | `scripts/dev-admin.mjs`, `build-web-admin.mjs`, etc. | Create | +| 2 | `crates/mcpmux-storage/src/migrations/020_*.sql` – `031_*.sql` | Create (renamed) | +| 2 | `crates/mcpmux-storage/src/database.rs` | Modify — register 020–031 | +| 2 | `crates/mcpmux-core/src/domain/installed_server.rs` | Modify — new fields | +| 2 | `crates/mcpmux-storage/src/repositories/embedding_repository.rs` | Create | +| 2 | `crates/mcpmux-storage/src/repositories/workspace_appearance_repository.rs` | Create | +| 3 | `crates/mcpmux-gateway/src/admin/**` | Create (~20 files) | +| 3 | `apps/desktop/src-tauri/src/services/admin_server.rs`, etc. | Create | +| 3 | `crates/mcpmux-gateway/src/server/startup.rs` | Create | +| 4 | `apps/desktop/src-tauri/src/macos_dock.rs`, `macos_permissions.rs`, `main_window.rs` | Create | +| 4 | `apps/desktop/src-tauri/Info.plist` | Modify — merge TCC keys | +| 4 | `apps/desktop/src-tauri/src/commands/workspace_appearance.rs` | Create | +| 5 | `crates/mcpmux-gateway/src/services/meta_tools/*` | Create/modify (~20 files) | +| 5 | `crates/mcpmux-gateway/src/services/tool_discovery*.rs`, `embedding*.rs` | Create | +| 6 | `apps/desktop/src-tauri/src/commands/server_clone.rs` | Create | +| 6 | `apps/desktop/src/features/servers/CloneAccountModal.tsx`, etc. | Create | +| 6 | `crates/mcpmux-gateway/src/services/server_version_probe.rs`, `package_version.rs` | Create | +| 6 | `crates/mcpmux-gateway/src/pool/transport/resolution.rs` | Modify — update policy injection | +| 6 | `apps/desktop/src/features/settings/ServerUpdatesSection.tsx`, etc. | Create | +| 7 | `apps/desktop/src/features/dashboard/**` | Create (8 files) | +| 7 | `crates/mcpmux-core/src/domain/workspace_appearance.rs` | Create | +| 7 | `apps/desktop/src/features/servers/AddServerMenu.tsx`, etc. | Create | +| 8 | `i18n` branch | Rebase onto `main` after phases 1–7 | + +--- + +## Key files referenced + +| File | Note | +| ---- | ---- | +| [`crates/mcpmux-storage/src/database.rs`](../../crates/mcpmux-storage/src/database.rs) | Migration registration array — Phase 2 primary target | +| [`crates/mcpmux-core/src/domain/installed_server.rs`](../../crates/mcpmux-core/src/domain/installed_server.rs) | Phase 2 entity extension | +| [`crates/mcpmux-gateway/src/services/meta_tools/mod.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/mod.rs) | Phase 5 meta-tools entrypoint | +| [`crates/mcpmux-gateway/src/pool/transport/resolution.rs`](../../crates/mcpmux-gateway/src/pool/transport/resolution.rs) | Phase 6 update policy injection site | +| [`apps/desktop/src/App.tsx`](../../apps/desktop/src/App.tsx) | Route wiring for Dashboard + all new pages | +| [`apps/desktop/src/stores/appStore.ts`](../../apps/desktop/src/stores/appStore.ts) | Global Zustand store — new slices per phase | +| [`packages/ui/src/index.ts`](../../packages/ui/src/index.ts) | Phase 1 export additions | +| [`apps/desktop/src-tauri/src/lib.rs`](../../apps/desktop/src-tauri/src/lib.rs) | Tauri app setup — macOS hook registration (Phase 4) | + +--- + +## Related documentation + +- [`docs/planning/server-update-policy.md`](./server-update-policy.md) — full spec for Phase 6 update policy feature +- [`docs/planning/server-update-policy-audit-and-fixes.md`](./server-update-policy-audit-and-fixes.md) — Phase 6 post-ship audit +- [`docs/planning/meta-surface-lean-core.md`](./meta-surface-lean-core.md) — Phase 5 lean core decisions +- [`docs/planning/meta-tool-invoke-ergonomics.md`](./meta-tool-invoke-ergonomics.md) — Phase 5 invoke ergonomics +- [`docs/planning/i18n-react-i18next.md`](./i18n-react-i18next.md) — Phase 8 i18n feature +- [`docs/planning/i18n-react-i18next-phase-2.md`](./i18n-react-i18next-phase-2.md) — Phase 8 i18n continuation +- [`docs/planning/fork-pr-ci.md`](./fork-pr-ci.md) — CI setup on fork; each phase PR will need fork CI clean before merge +- [`docs/frontend/technical/backend-facade.md`](../frontend/technical/backend-facade.md) — Phase 1 facade architecture doc +- [`docs/backend/technical/architecture.md`](../backend/technical/architecture.md) — full backend subsystem map diff --git a/package.json b/package.json index f585ac43..7e10937f 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,14 @@ "prepare": "node -e \"try{require('fs').chmodSync('.git/hooks/pre-commit',0o755)}catch(e){}\"", "dev": "pnpm --filter @mcpmux/desktop dev", "dev:web": "pnpm --filter @mcpmux/desktop dev:web", + "dev:web:admin": "node scripts/dev-web-admin.mjs", + "dev:admin": "node scripts/dev-admin.mjs", "build": "pnpm --filter @mcpmux/desktop build", "build:web": "pnpm --filter @mcpmux/desktop build:web", + "build:web:admin": "pnpm --filter @mcpmux/desktop build:web:admin", "release:key": "pnpm --filter @mcpmux/desktop tauri signer generate -w ~/.tauri/mcpmux.key", + "remote:smoke": "node scripts/remote-gateway-smoke.mjs", + "count-tokens": "cargo test -p mcpmux-gateway meta_tools_token_budget_report -- --nocapture && python3 scripts/count-meta-tool-tokens.py", "test": "pnpm test:rust && pnpm test:ts", "test:rust": "cargo nextest run --workspace", "test:rust:unit": "cargo nextest run --workspace --lib", diff --git a/packages/ui/src/components/common/ChipButton.tsx b/packages/ui/src/components/common/ChipButton.tsx new file mode 100644 index 00000000..93b40de4 --- /dev/null +++ b/packages/ui/src/components/common/ChipButton.tsx @@ -0,0 +1,44 @@ +import { type ButtonHTMLAttributes, forwardRef } from 'react'; +import { cn } from '../../lib/cn'; + +export type ChipButtonVariant = 'fill' | 'outline'; + +export interface ChipButtonProps extends ButtonHTMLAttributes { + active?: boolean; + variant?: ChipButtonVariant; +} + +/** + * Small pill toggle used for transport/status filter chips. + */ +export const ChipButton = forwardRef( + ({ className, active = false, variant = 'fill', children, type = 'button', ...props }, ref) => { + return ( + + ); + } +); + +ChipButton.displayName = 'ChipButton'; diff --git a/packages/ui/src/components/common/ConfirmDialog.tsx b/packages/ui/src/components/common/ConfirmDialog.tsx index 7a3e3f58..507788c4 100644 --- a/packages/ui/src/components/common/ConfirmDialog.tsx +++ b/packages/ui/src/components/common/ConfirmDialog.tsx @@ -1,4 +1,3 @@ -import { useCallback, useState, useRef } from 'react'; import { AlertCircle } from 'lucide-react'; export interface ConfirmDialogState { @@ -6,6 +5,7 @@ export interface ConfirmDialogState { title: string; message: string; confirmLabel?: string; + cancelLabel?: string; variant?: 'danger' | 'default'; } @@ -19,6 +19,7 @@ export function ConfirmDialog({ title, message, confirmLabel = 'Confirm', + cancelLabel = 'Cancel', variant = 'default', onConfirm, onCancel, @@ -45,7 +46,9 @@ export function ConfirmDialog({ )}
-

{title}

+

+ {title} +

{message}

@@ -55,7 +58,7 @@ export function ConfirmDialog({ className="px-4 py-2 text-sm font-medium rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-active))] text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors" data-testid="confirm-dialog-cancel" > - Cancel + {cancelLabel} + ); +} + +/** + * Simple compact menu row (icon + label) for action menus. + */ +export function DropdownMenuAction({ + icon: Icon, + label, + onSelect, + variant = 'default', + className, + 'data-testid': testId, +}: Omit) { + const { setOpen } = useDropdownMenu(); + + const labelClass = + variant === 'danger' + ? 'text-[rgb(var(--error))]' + : variant === 'warning' + ? 'text-[rgb(var(--warning))]' + : 'text-[rgb(var(--foreground))]'; + + return ( + + ); +} + +export function DropdownMenuSeparator() { + return
; +} diff --git a/packages/ui/src/components/common/HoverTooltip.tsx b/packages/ui/src/components/common/HoverTooltip.tsx new file mode 100644 index 00000000..b98870bd --- /dev/null +++ b/packages/ui/src/components/common/HoverTooltip.tsx @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { cn } from '../../lib/cn'; + +export type HoverTooltipSide = 'top' | 'bottom' | 'auto'; + +const VIEWPORT_PADDING = 8; +const GAP = 8; + +export interface HoverTooltipProps { + children: ReactNode; + title: string; + lines?: string[]; + /** Preferred placement; `auto` flips based on available viewport space. */ + side?: HoverTooltipSide; + className?: string; + hidden?: boolean; + 'data-testid'?: string; +} + +/** + * Pick top or bottom placement from viewport space around the trigger. + */ +function resolveTooltipSide( + preferred: HoverTooltipSide, + triggerRect: DOMRect, + tooltipHeight: number +): 'top' | 'bottom' { + const spaceAbove = triggerRect.top; + const spaceBelow = window.innerHeight - triggerRect.bottom; + const needed = tooltipHeight + GAP; + + if (preferred === 'top') { + if (spaceAbove >= needed) { + return 'top'; + } + if (spaceBelow >= needed) { + return 'bottom'; + } + return spaceBelow > spaceAbove ? 'bottom' : 'top'; + } + + if (preferred === 'bottom') { + if (spaceBelow >= needed) { + return 'bottom'; + } + if (spaceAbove >= needed) { + return 'top'; + } + return spaceAbove > spaceBelow ? 'top' : 'bottom'; + } + + if (spaceAbove >= needed && spaceBelow >= needed) { + return spaceAbove >= spaceBelow ? 'top' : 'bottom'; + } + if (spaceBelow >= needed) { + return 'bottom'; + } + if (spaceAbove >= needed) { + return 'top'; + } + return spaceBelow > spaceAbove ? 'bottom' : 'top'; +} + +/** + * Compute fixed viewport coordinates for the tooltip panel. + */ +function computeTooltipCoords( + triggerRect: DOMRect, + tooltipWidth: number, + tooltipHeight: number, + placement: 'top' | 'bottom' +): { top: number; left: number } { + let top = + placement === 'top' + ? triggerRect.top - tooltipHeight - GAP + : triggerRect.bottom + GAP; + + top = Math.max( + VIEWPORT_PADDING, + Math.min(top, window.innerHeight - tooltipHeight - VIEWPORT_PADDING) + ); + + let left = triggerRect.right - tooltipWidth; + left = Math.max( + VIEWPORT_PADDING, + Math.min(left, window.innerWidth - tooltipWidth - VIEWPORT_PADDING) + ); + + return { top, left }; +} + +/** + * Wraps a control and shows a tooltip panel on hover (hidden while `hidden` is true). + * Placement flips above/below based on viewport space when `side` is `auto`. + */ +export function HoverTooltip({ + children, + title, + lines = [], + side = 'auto', + className, + hidden = false, + 'data-testid': testId, +}: HoverTooltipProps) { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [active, setActive] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); + + const updateCoords = useCallback(() => { + const container = containerRef.current; + const tooltip = tooltipRef.current; + if (!container || !tooltip) { + return; + } + + const triggerRect = container.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : tooltip.scrollWidth; + const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : tooltip.scrollHeight; + + const placement = resolveTooltipSide(side, triggerRect, tooltipHeight); + setCoords(computeTooltipCoords(triggerRect, tooltipWidth, tooltipHeight, placement)); + }, [side]); + + useLayoutEffect(() => { + if (!active || hidden) { + return; + } + updateCoords(); + }, [active, hidden, updateCoords, title, lines]); + + useEffect(() => { + if (!active || hidden) { + return; + } + + const handleReposition = () => updateCoords(); + window.addEventListener('resize', handleReposition); + window.addEventListener('scroll', handleReposition, true); + return () => { + window.removeEventListener('resize', handleReposition); + window.removeEventListener('scroll', handleReposition, true); + }; + }, [active, hidden, updateCoords]); + + const showCoords = active && !hidden ? coords : null; + const showTooltip = showCoords !== null; + + return ( +
setActive(true)} + onMouseLeave={() => setActive(false)} + onFocusCapture={() => setActive(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setActive(false); + } + }} + > +
+

{title}

+ {lines.map((line) => ( +

+ {line} +

+ ))} +
+ {children} +
+ ); +} diff --git a/packages/ui/src/components/common/SearchField.tsx b/packages/ui/src/components/common/SearchField.tsx new file mode 100644 index 00000000..97066783 --- /dev/null +++ b/packages/ui/src/components/common/SearchField.tsx @@ -0,0 +1,50 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { Search, X, type LucideIcon } from 'lucide-react'; +import { cn } from '../../lib/cn'; + +export interface SearchFieldProps extends Omit, 'type'> { + onClear?: () => void; + icon?: LucideIcon; + 'data-testid'?: string; +} + +/** + * Search input with leading icon and optional clear control. + */ +export const SearchField = forwardRef( + ({ className, value, onClear, icon: Icon = Search, 'data-testid': testId, ...props }, ref) => { + const hasValue = String(value ?? '').length > 0; + + return ( +
+ + + {hasValue && onClear && ( + + )} +
+ ); + } +); + +SearchField.displayName = 'SearchField'; diff --git a/packages/ui/src/components/common/use-confirm.hook.tsx b/packages/ui/src/components/common/use-confirm.hook.tsx new file mode 100644 index 00000000..b03fc0a0 --- /dev/null +++ b/packages/ui/src/components/common/use-confirm.hook.tsx @@ -0,0 +1,62 @@ +import { useCallback, useRef, useState } from 'react'; +import { ConfirmDialog } from './ConfirmDialog'; +import type { ConfirmDialogState } from './ConfirmDialog'; + +/** + * Hook that provides a promise-based confirm dialog. + * + * Usage: + * ```tsx + * const { confirm, ConfirmDialogElement } = useConfirm(); + * + * const handleDelete = async () => { + * if (!await confirm({ title: 'Delete?', message: 'This cannot be undone.' })) return; + * // proceed with delete + * }; + * + * return <>{ConfirmDialogElement}; + * ``` + */ +export function useConfirm() { + const [state, setState] = useState({ + open: false, + title: '', + message: '', + key: 0, + }); + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + const confirm = useCallback( + (options: Omit) => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setState((prev) => ({ ...options, open: true, key: prev.key + 1 })); + }); + }, + [] + ); + + const handleConfirm = useCallback(() => { + setState((prev) => ({ ...prev, open: false })); + resolveRef.current?.(true); + resolveRef.current = null; + }, []); + + const handleCancel = useCallback(() => { + setState((prev) => ({ ...prev, open: false })); + resolveRef.current?.(false); + resolveRef.current = null; + }, []); + + const { key: dialogKey, ...dialogState } = state; + const ConfirmDialogElement = ( + + ); + + return { confirm, ConfirmDialogElement }; +} diff --git a/packages/ui/src/hooks/useClickOutside.ts b/packages/ui/src/hooks/useClickOutside.ts new file mode 100644 index 00000000..7af00eeb --- /dev/null +++ b/packages/ui/src/hooks/useClickOutside.ts @@ -0,0 +1,27 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * Invoke a callback when the user clicks outside all provided element refs. + */ +export function useClickOutside( + refs: RefObject[], + onClickOutside: () => void, + enabled: boolean +): void { + useEffect(() => { + if (!enabled) { + return; + } + + function handlePointerDown(event: MouseEvent) { + const target = event.target as Node; + const isInside = refs.some((ref) => ref.current?.contains(target)); + if (!isInside) { + onClickOutside(); + } + } + + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [refs, onClickOutside, enabled]); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c54cc5a3..88ca65ee 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,6 +12,26 @@ export { StatusBar, StatusBarItem } from './components/layout/StatusBar'; // Common components export { Button } from './components/common/Button'; export { Input } from './components/common/Input'; +export { SearchField } from './components/common/SearchField'; +export type { SearchFieldProps } from './components/common/SearchField'; +export { ChipButton } from './components/common/ChipButton'; +export type { ChipButtonProps, ChipButtonVariant } from './components/common/ChipButton'; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuAction, + DropdownMenuSeparator, +} from './components/common/DropdownMenu'; +export type { + DropdownMenuProps, + DropdownMenuTriggerProps, + DropdownMenuContentProps, + DropdownMenuItemProps, +} from './components/common/DropdownMenu'; +export { HoverTooltip } from './components/common/HoverTooltip'; +export type { HoverTooltipProps, HoverTooltipSide } from './components/common/HoverTooltip'; export { Card, CardHeader, @@ -22,13 +42,15 @@ export { export { Switch } from './components/common/Switch'; export { Toast, ToastContainer } from './components/common/Toast'; export type { ToastProps, ToastType, ToastAction } from './components/common/Toast'; -export { ConfirmDialog, useConfirm } from './components/common/ConfirmDialog'; +export { ConfirmDialog } from './components/common/ConfirmDialog'; +export { useConfirm } from './components/common/use-confirm.hook'; export type { ConfirmDialogState, ConfirmDialogProps } from './components/common/ConfirmDialog'; export { PageHeader } from './components/common/PageHeader'; // Hooks export { useToast } from './hooks/useToast'; export type { ToastOptions } from './hooks/useToast'; +export { useClickOutside } from './hooks/useClickOutside'; // Utilities export { cn } from './lib/cn'; diff --git a/scripts/admin-e2e-fixture.mjs b/scripts/admin-e2e-fixture.mjs new file mode 100644 index 00000000..e2c9cea0 --- /dev/null +++ b/scripts/admin-e2e-fixture.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * Playwright webServer fixture: McpMux dev with web admin on :45819. + * + * Tunnel parity: loads repo `.env`, passes `MCPMUX_CF_ACCESS_*` into `pnpm dev`, and + * probes admin with the same CF headers Playwright uses (service token or JWT). + * + * Env: MCPMUX_DEV_ADMIN=1, MCPMUX_ADMIN_TEST=1 (SSE/oauth publish helpers for admin specs). + * Linux CI: dbus + gnome-keyring unlock (same pattern as e2e-desktop). + */ + +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + adminCfProbeHeaders, + hasAdminCfProbeAuth, + loadRepoDotEnv, +} from './cf-access-env.mjs'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ADMIN_PORT = Number.parseInt(process.env.MCPMUX_ADMIN_PORT ?? '45819', 10); +const HEALTH_URL = `http://127.0.0.1:${ADMIN_PORT}/api/v1/health`; +const READY_URL = `http://127.0.0.1:${ADMIN_PORT}/`; +const WAIT_MS = Number.parseInt(process.env.MCPMUX_ADMIN_E2E_WAIT_MS ?? '300000', 10); +const POLL_MS = 500; +const DIST_INDEX = path.join(REPO_ROOT, 'apps', 'desktop', 'dist', 'index.html'); + +loadRepoDotEnv(REPO_ROOT); + +/** + * @param {number} ms + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * True when the admin HTTP server is listening without CF credentials (trust off). + * @param {number} status + * @returns {boolean} + */ +function isAdminHttpUpWithoutCf(status) { + return status === 200 || status === 503; +} + +/** + * Probe admin; with CF auth env expects 200, without CF accepts trust-off signals. + * @returns {Promise} + */ +async function adminReady() { + const headers = adminCfProbeHeaders(); + const useCfAuth = Object.keys(headers).length > 0; + + for (const url of [HEALTH_URL, READY_URL]) { + try { + const response = await fetch(url, { + method: 'GET', + headers, + redirect: 'follow', + }); + if (useCfAuth) { + if (response.status === 200) { + return true; + } + } else if (isAdminHttpUpWithoutCf(response.status)) { + return true; + } + } catch { + // try next probe + } + } + return false; +} + +/** + * Ensure production admin SPA exists for :45819 static serving. + */ +function ensureAdminDistBuilt() { + if (existsSync(DIST_INDEX)) { + return; + } + console.log('[admin-e2e-fixture] Building web admin SPA (pnpm build:web:admin)…'); + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const result = spawnSync(pnpm, ['build:web:admin'], { + cwd: REPO_ROOT, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +/** + * @param {import('node:child_process').ChildProcess | null} child + */ +function attachShutdown(child) { + const stop = () => { + if (child?.pid && !child.killed) { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else { + child.kill('SIGTERM'); + } + } + process.exit(0); + }; + process.on('SIGTERM', stop); + process.on('SIGINT', stop); +} + +/** + * Start Tauri dev with admin enabled (and test helpers). Inherits `.env` CF vars. + * @returns {import('node:child_process').ChildProcess} + */ +function startDevBackend() { + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const env = { + ...process.env, + MCPMUX_DEV_PREP_DONE: '1', + MCPMUX_DEV_ADMIN: '1', + MCPMUX_ADMIN_TEST: '1', + }; + + if (process.env.CI === 'true' && process.platform === 'linux') { + // tauri dev opens a webkit2gtk window — needs a virtual display under headless CI + // (same xvfb-run pattern as e2e-desktop.yml). Without it `pnpm dev` exits immediately. + const inner = `echo "test" | gnome-keyring-daemon --unlock --components=secrets 2>/dev/null; eval "$(gnome-keyring-daemon --start --components=secrets 2>/dev/null)"; exec xvfb-run --auto-servernum ${pnpm} dev`; + return spawn('dbus-run-session', ['--', 'bash', '-lc', inner], { + cwd: REPO_ROOT, + env, + stdio: 'inherit', + }); + } + + return spawn(pnpm, ['dev'], { + cwd: REPO_ROOT, + env, + stdio: 'inherit', + shell: process.platform === 'win32', + }); +} + +/** + * Block until admin responds or timeout. + * @returns {Promise} + */ +async function waitForAdmin() { + const deadline = Date.now() + WAIT_MS; + while (Date.now() < deadline) { + if (await adminReady()) { + return true; + } + await sleep(POLL_MS); + } + return adminReady(); +} + +/** + * Log actionable hints when CF trust is on but probes never return 200. + */ +function logCfTrustHints() { + if (hasAdminCfProbeAuth()) { + console.error( + '[admin-e2e-fixture] CF credentials are set but admin did not return 200.', + ); + console.error( + ' Restart McpMux after saving .env so the process has MCPMUX_CF_ACCESS_* set.', + ); + return; + } + console.error( + '[admin-e2e-fixture] Admin returned 401 (CF Access trust likely on).', + ); + console.error( + ' Add MCPMUX_CF_ACCESS_CLIENT_ID and MCPMUX_CF_ACCESS_CLIENT_SECRET to repo .env', + ); + console.error(' (or MCPMUX_ADMIN_CF_JWT), then restart McpMux and re-run tests.'); +} + +async function main() { + if (!existsSync(path.join(REPO_ROOT, 'package.json'))) { + console.error('[admin-e2e-fixture] Could not locate repo root.'); + process.exit(1); + } + + ensureAdminDistBuilt(); + + if (await adminReady()) { + console.log(`[admin-e2e-fixture] Reusing admin API at :${ADMIN_PORT}`); + attachShutdown(null); + await new Promise(() => {}); + return; + } + + console.log(`[admin-e2e-fixture] Starting McpMux dev (admin :${ADMIN_PORT})…`); + const child = startDevBackend(); + attachShutdown(child); + + child.on('exit', (code, signal) => { + if (signal) { + process.exit(0); + } + console.error(`[admin-e2e-fixture] pnpm dev exited (code=${code ?? 'null'})`); + process.exit(code ?? 1); + }); + + const ready = await waitForAdmin(); + if (!ready) { + console.error(`[admin-e2e-fixture] Admin API did not become ready on :${ADMIN_PORT}`); + logCfTrustHints(); + process.exit(1); + } + + console.log(`[admin-e2e-fixture] Ready on :${ADMIN_PORT}`); + await new Promise(() => {}); +} + +main(); diff --git a/scripts/build-date.helpers.mjs b/scripts/build-date.helpers.mjs new file mode 100644 index 00000000..358bca49 --- /dev/null +++ b/scripts/build-date.helpers.mjs @@ -0,0 +1,90 @@ +/** Local timezone for build/commit display (matches generAIt frontend). */ +const BUILD_TIMEZONE = 'America/Denver'; + +const buildDateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: BUILD_TIMEZONE, + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +const buildTimeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: BUILD_TIMEZONE, + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short', +}); + +/** + * Format a date for build metadata (e.g. "Wed, May 29 2026"). + * @param {Date} date + * @returns {string} + */ +export function formatBuildDate(date) { + return buildDateFormatter.format(date); +} + +/** + * Format a time for build metadata (e.g. "06:32:19 PM MDT"). + * @param {Date} date + * @returns {string} + */ +export function formatBuildTime(date) { + return buildTimeFormatter.format(date); +} + +/** + * generAIt-style combined stamp: "Wed, May 29 2026 at 06:32:19 PM MDT". + * @param {Date} date + * @returns {string} + */ +export function formatBuiltAt(date) { + return `${formatBuildDate(date)} at ${formatBuildTime(date)}`; +} + +/** + * Normalize git `%ci` (`YYYY-MM-DD HH:MM:SS ±HHMM`) to ISO 8601 for cross-engine parsing. + * @param {string} raw + * @returns {string} + */ +function normalizeStampInstant(raw) { + const gitCi = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(raw); + if (gitCi) { + const [, date, time, sign, hours, minutes] = gitCi; + return `${date}T${time}${sign}${hours}:${minutes}`; + } + return raw; +} + +/** + * Parse git `%ci` commit time or Rust UTC build strings. + * @param {string} raw + * @returns {Date | null} + */ +export function parseStampInstant(raw) { + const trimmed = raw.trim(); + if (!trimmed || trimmed === 'unknown') { + return null; + } + if (trimmed.endsWith(' UTC')) { + const iso = trimmed.replace(' UTC', 'Z').replace(' ', 'T'); + const parsed = Date.parse(iso); + return Number.isNaN(parsed) ? null : new Date(parsed); + } + const parsed = Date.parse(normalizeStampInstant(trimmed)); + return Number.isNaN(parsed) ? null : new Date(parsed); +} + +/** + * Format a raw git/Rust timestamp for display. + * @param {string} raw + * @param {string} [fallback] + * @returns {string} + */ +export function formatStampInstant(raw, fallback = 'unknown') { + const date = parseStampInstant(raw); + return date ? formatBuiltAt(date) : fallback; +} diff --git a/scripts/build-stamp.mjs b/scripts/build-stamp.mjs new file mode 100644 index 00000000..21efdac3 --- /dev/null +++ b/scripts/build-stamp.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Git + build metadata for web-admin SPA builds (mirrors gateway `build.rs` fields). + */ + +import { execSync } from 'node:child_process'; + +import { formatBuiltAt, formatStampInstant } from './build-date.helpers.mjs'; + +/** + * Run a git command and return trimmed stdout, or `fallback` on failure. + * @param {string[]} args + * @param {string} [fallback] + * @returns {string} + */ +function git(args, fallback = '') { + try { + return execSync(['git', ...args].join(' '), { encoding: 'utf8' }).trim(); + } catch { + return fallback; + } +} + +/** + * Resolve build instant honoring SOURCE_DATE_EPOCH when set. + * @returns {Date} + */ +function getBuildInstant() { + const raw = process.env.SOURCE_DATE_EPOCH?.trim(); + if (raw) { + const parsed = Number.parseInt(raw, 10); + if (!Number.isNaN(parsed)) { + return new Date(parsed * 1000); + } + } + return new Date(); +} + +/** + * Collect git/build metadata for stamping Vite bundles and build-stamp.json. + * @returns {{ + * gitSha: string, + * gitBranch: string, + * commitTime: string, + * commitAt: string, + * buildTime: string, + * buildAt: string, + * }} + */ +export function getBuildStamp() { + const commitTime = git(['log', '-1', '--format=%ci'], 'unknown'); + const buildInstant = getBuildInstant(); + const buildTime = `${buildInstant.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')}`; + + return { + gitSha: git(['rev-parse', '--short', 'HEAD'], 'unknown'), + gitBranch: git(['rev-parse', '--abbrev-ref', 'HEAD'], 'unknown'), + commitTime, + commitAt: formatStampInstant(commitTime), + buildTime, + buildAt: formatBuiltAt(buildInstant), + }; +} + +/** + * JSON shape written to `apps/desktop/dist/build-stamp.json`. + * @param {ReturnType} stamp + * @returns {Record} + */ +export function buildStampJson(stamp) { + return { + git_sha: stamp.gitSha, + git_branch: stamp.gitBranch, + commit_time: stamp.commitTime, + commit_at: stamp.commitAt, + build_time: stamp.buildTime, + build_at: stamp.buildAt, + }; +} + +/** + * Format a gateway-style build line for console output. + * @param {string} prefix + * @param {ReturnType} stamp + * @returns {string} + */ +export function formatBuildStampLine(prefix, stamp) { + return `${prefix} | sha: ${stamp.gitSha} | branch: ${stamp.gitBranch} | committed: ${stamp.commitAt} | built: ${stamp.buildAt}`; +} diff --git a/scripts/build-web-admin.mjs b/scripts/build-web-admin.mjs new file mode 100644 index 00000000..61e832a5 --- /dev/null +++ b/scripts/build-web-admin.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Production web-admin SPA build with VITE_ADMIN_WEB set for the full pipeline. + */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DESKTOP_DIR = path.join(REPO_ROOT, 'apps', 'desktop'); +const env = { ...process.env, VITE_ADMIN_WEB: 'true' }; +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +/** + * Run a pnpm command in the desktop package and exit on failure. + * @param {string[]} args + */ +function runDesktop(args) { + const result = spawnSync(pnpm, args, { + cwd: DESKTOP_DIR, + stdio: 'inherit', + env, + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +runDesktop(['exec', 'tsc']); +runDesktop(['exec', 'vite', 'build']); diff --git a/scripts/cf-access-env.mjs b/scripts/cf-access-env.mjs new file mode 100644 index 00000000..0a051f73 --- /dev/null +++ b/scripts/cf-access-env.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Build Cloudflare Access HTTP headers from environment variables. + * + * Reads `MCPMUX_CF_ACCESS_CLIENT_ID` and `MCPMUX_CF_ACCESS_CLIENT_SECRET`. + * Returns an empty object when either is unset. + */ + +/** + * @returns {Record} + */ +export function cfAccessHeadersFromEnv() { + const clientId = process.env.MCPMUX_CF_ACCESS_CLIENT_ID?.trim(); + const clientSecret = process.env.MCPMUX_CF_ACCESS_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) { + return {}; + } + return { + 'CF-Access-Client-Id': clientId, + 'CF-Access-Client-Secret': clientSecret, + }; +} + +/** + * @returns {string[]} + */ +export function cfAccessCurlFlagsFromEnv() { + const headers = cfAccessHeadersFromEnv(); + return Object.entries(headers).flatMap(([name, value]) => ['-H', `${name}: ${value}`]); +} + +/** + * Headers for loopback admin probes when CF Access trust is on (JWT or service token). + * @returns {Record} + */ +export function adminCfProbeHeaders() { + const jwt = process.env.MCPMUX_ADMIN_CF_JWT?.trim(); + if (jwt) { + return { 'CF-Access-Jwt-Assertion': jwt }; + } + return cfAccessHeadersFromEnv(); +} + +/** + * @returns {boolean} True when admin HTTP probes should send CF Access credentials. + */ +export function hasAdminCfProbeAuth() { + return Object.keys(adminCfProbeHeaders()).length > 0; +} + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Load `.env` from the repo root when present (optional; no dependency on dotenv). + * @param {string} repoRoot + */ +export function loadRepoDotEnv(repoRoot) { + try { + const dotenvPath = join(repoRoot, '.env'); + if (!existsSync(dotenvPath)) { + return; + } + const text = readFileSync(dotenvPath, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + } catch { + // optional convenience only + } +} diff --git a/scripts/count-meta-tool-tokens.py b/scripts/count-meta-tool-tokens.py new file mode 100644 index 00000000..272b510a --- /dev/null +++ b/scripts/count-meta-tool-tokens.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Measure mcpmux_* tools/list token budget (tiktoken cl100k_base when available).""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def tiktoken_count(text: str) -> int: + try: + import tiktoken # type: ignore + + enc = tiktoken.get_encoding("cl100k_base") + return len(enc.encode(text)) + except ImportError: + return (len(text.encode("utf-8")) * 11 + 43) // 44 + + +def run_rust_byte_report() -> dict[str, int]: + proc = subprocess.run( + [ + "cargo", + "test", + "-p", + "mcpmux-gateway", + "meta_tools_token_budget_report", + "--", + "--nocapture", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + combined = proc.stdout + proc.stderr + match = re.search( + r"META_TOOL_TOKEN_REPORT " + r"core_bytes=(\d+) full_bytes=(\d+) " + r"core_tiktoken=(\d+) full_tiktoken=(\d+) " + r"core_claude_est=(\d+) full_claude_est=(\d+) " + r"saved_tiktoken=(\d+) saved_claude_est=(\d+) " + r"core_rmcp_bytes=(\d+) full_rmcp_bytes=(\d+)", + combined, + ) + if not match: + print(combined, file=sys.stderr) + raise RuntimeError("cargo test did not emit META_TOOL_TOKEN_REPORT (test failed?)") + keys = [ + "core_bytes", + "full_bytes", + "core_tiktoken", + "full_tiktoken", + "core_claude_est", + "full_claude_est", + "saved_tiktoken", + "saved_claude_est", + "core_rmcp_bytes", + "full_rmcp_bytes", + ] + return {k: int(v) for k, v in zip(keys, match.groups(), strict=True)} + + +def main() -> int: + rust = run_rust_byte_report() + print("Meta-tool tools/list token budget") + print("--------------------------------") + print(f" Core (4 advertised): {rust['core_tiktoken']} tiktoken (~{rust['core_claude_est']} Claude est.)") + print(f" Full (11 registered): {rust['full_tiktoken']} tiktoken (~{rust['full_claude_est']} Claude est.)") + print(f" Saved: {rust['saved_tiktoken']} tiktoken (~{rust['saved_claude_est']} Claude est.)") + print() + print(f" Serialized bytes — core: {rust['core_bytes']}, full: {rust['full_bytes']}") + print() + print(json.dumps(rust, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev-admin.mjs b/scripts/dev-admin.mjs new file mode 100644 index 00000000..fdbfe5b6 --- /dev/null +++ b/scripts/dev-admin.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Tauri dev with web admin enabled for the session (MCPMUX_DEV_ADMIN=1). + * Opens the HMR URL in the default browser after the admin health check passes. + * + * Usage (repo root): pnpm dev:admin + */ + +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadRepoDotEnv } from './cf-access-env.mjs'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ADMIN_PORT = Number.parseInt(process.env.MCPMUX_ADMIN_PORT ?? '45819', 10); +const GATEWAY_PORT = Number.parseInt(process.env.MCPMUX_GATEWAY_PORT ?? '45818', 10); +const HEALTH_URL = `http://127.0.0.1:${ADMIN_PORT}/api/v1/health`; +const GATEWAY_HEALTH_URL = `http://127.0.0.1:${GATEWAY_PORT}/health`; +const VITE_URL = 'http://127.0.0.1:1420'; +const OPEN_WAIT_MS = 90_000; +const POLL_MS = 500; +/** Number of consecutive healthy responses required before gateway is considered stable. */ +const GATEWAY_STABLE_TICKS = 3; +/** Minimum ms between stability ticks — ensures we aren't measuring the same in-flight response twice. */ +const GATEWAY_STABLE_INTERVAL_MS = 1_000; + +/** + * @param {number} ms + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * @returns {Promise} + */ +async function adminHealthOk() { + try { + const response = await fetch(HEALTH_URL, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + return response.ok; + } catch { + return false; + } +} + +/** + * Poll :45818/health until the gateway has responded consistently for + * GATEWAY_STABLE_TICKS consecutive checks, indicating it is not mid-restart. + * Logs progress so the dev sees what's happening. + * + * @param {number} deadlineMs - absolute timestamp after which we give up + * @returns {Promise} true if stable, false if timed out + */ +async function waitForStableGateway(deadlineMs) { + let ticks = 0; + while (Date.now() < deadlineMs) { + try { + const res = await fetch(GATEWAY_HEALTH_URL, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(2_000), + }); + if (res.ok) { + const body = await res.json().catch(() => ({})); + ticks++; + const connected = body.servers_connected ?? '?'; + console.log( + `[dev-admin] Gateway health tick ${ticks}/${GATEWAY_STABLE_TICKS} — servers_connected=${connected}`, + ); + if (ticks >= GATEWAY_STABLE_TICKS) return true; + await sleep(GATEWAY_STABLE_INTERVAL_MS); + continue; + } + } catch { + // not up yet + } + ticks = 0; + await sleep(POLL_MS); + } + return false; +} + +/** + * Open a URL in the system browser (macOS/Linux/Windows best-effort). + * @param {string} url + */ +function openBrowser(url) { + if (process.platform === 'darwin') { + spawnSync('open', [url], { stdio: 'ignore' }); + return; + } + if (process.platform === 'win32') { + spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore', shell: true }); + return; + } + spawnSync('xdg-open', [url], { stdio: 'ignore' }); +} + +async function waitThenOpenBrowser() { + const deadline = Date.now() + OPEN_WAIT_MS; + + // Step 1: wait for admin API + while (Date.now() < deadline) { + if (await adminHealthOk()) break; + await sleep(POLL_MS); + } + if (Date.now() >= deadline) { + console.warn(`[dev-admin] Timed out waiting for ${HEALTH_URL}; open ${VITE_URL} manually when ready.`); + return; + } + + // Step 2: wait for gateway to be stable (not mid-restart) + console.log(`[dev-admin] Admin API ready — waiting for stable gateway on :${GATEWAY_PORT}…`); + const gatewayStable = await waitForStableGateway(deadline); + if (!gatewayStable) { + console.warn( + `[dev-admin] Gateway did not stabilise before timeout; open ${VITE_URL} manually. Reload MCP in Cursor once :${GATEWAY_PORT} is up.`, + ); + return; + } + + console.log(`[dev-admin] Gateway stable — opening ${VITE_URL} (HMR + /api proxy).`); + console.log(`[dev-admin] Production-parity UI: http://127.0.0.1:${ADMIN_PORT}/ after pnpm build:web:admin`); + console.log(`[dev-admin] Reminder: reload MCP in Cursor (Settings → MCP) if tools are stale.`); + openBrowser(VITE_URL); +} + +async function main() { + if (!existsSync(path.join(REPO_ROOT, 'package.json'))) { + console.error('[dev-admin] Could not locate repo root.'); + process.exit(1); + } + + loadRepoDotEnv(REPO_ROOT); + + void waitThenOpenBrowser(); + + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const result = spawnSync(pnpm, ['dev'], { + cwd: REPO_ROOT, + stdio: 'inherit', + env: { + ...process.env, + MCPMUX_DEV_ADMIN: '1', + MCPMUX_DEV_PREP_DONE: '1', + }, + shell: process.platform === 'win32', + }); + process.exit(result.status ?? 0); +} + +main(); diff --git a/scripts/dev-web-admin.mjs b/scripts/dev-web-admin.mjs new file mode 100644 index 00000000..7b8a532c --- /dev/null +++ b/scripts/dev-web-admin.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * Web admin UI dev entry — prep ports, ensure Tauri backend is up, run Vite with + * VITE_ADMIN_WEB so the browser uses the admin HTTP transport (proxied /api → :45819). + * + * Usage (repo root): pnpm dev:web:admin + * Optional: MCPMUX_DEV_ADMIN=1 is set when spawning the backend (see pnpm dev:admin). + */ + +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ADMIN_PORT = Number.parseInt(process.env.MCPMUX_ADMIN_PORT ?? '45819', 10); +const HEALTH_URL = `http://127.0.0.1:${ADMIN_PORT}/api/v1/health`; +const BACKEND_WAIT_MS = 120_000; +const POLL_MS = 500; + +/** + * @param {number} ms + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Run dev-env prep (free ports, stop orphans). + */ +function runPrep() { + const node = process.execPath; + const result = spawnSync(node, [path.join(REPO_ROOT, 'scripts/dev-env.mjs'), 'prep'], { + cwd: REPO_ROOT, + stdio: 'inherit', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +/** + * @returns {Promise} + */ +async function adminHealthOk() { + try { + const response = await fetch(HEALTH_URL, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + return response.ok; + } catch { + return false; + } +} + +/** + * Start `pnpm dev` in the background when the admin API is not up yet. + */ +function startBackendDetached() { + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const env = { + ...process.env, + MCPMUX_DEV_PREP_DONE: '1', + MCPMUX_DEV_ADMIN: '1', + }; + const child = spawn(pnpm, ['dev'], { + cwd: REPO_ROOT, + detached: true, + stdio: 'ignore', + env, + shell: process.platform === 'win32', + }); + child.unref(); + console.log('[dev-web-admin] Started `pnpm dev` in the background (Tauri + gateway + admin when enabled).'); +} + +/** + * Block until admin /health responds or timeout. + * @returns {Promise} + */ +async function waitForAdmin() { + const deadline = Date.now() + BACKEND_WAIT_MS; + while (Date.now() < deadline) { + if (await adminHealthOk()) { + return true; + } + await sleep(POLL_MS); + } + return adminHealthOk(); +} + +/** + * Run Vite with admin web build flags (HMR on :1420, /api proxy → admin port). + */ +function runVite() { + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const result = spawnSync( + pnpm, + ['--filter', '@mcpmux/desktop', 'dev:web:admin'], + { + cwd: REPO_ROOT, + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + }, + ); + process.exit(result.status ?? 0); +} + +async function main() { + if (!existsSync(path.join(REPO_ROOT, 'package.json'))) { + console.error('[dev-web-admin] Could not locate repo root.'); + process.exit(1); + } + + runPrep(); + + if (!(await adminHealthOk())) { + startBackendDetached(); + console.log(`[dev-web-admin] Waiting for admin API at ${HEALTH_URL} …`); + const ready = await waitForAdmin(); + if (!ready) { + console.error('[dev-web-admin] Admin API did not become ready in time.'); + console.error( + ' Enable **Web admin** in McpMux Settings → Gateway, or run `pnpm dev:admin` (auto-enables admin in dev).', + ); + console.error(` Then open http://127.0.0.1:1420 for HMR, or http://127.0.0.1:${ADMIN_PORT} after pnpm build:web:admin.`); + process.exit(1); + } + } + + console.log('[dev-web-admin] Admin API ready. Starting Vite (http://127.0.0.1:1420, /api → admin).'); + runVite(); +} + +main(); diff --git a/scripts/remote-gateway-smoke.mjs b/scripts/remote-gateway-smoke.mjs new file mode 100644 index 00000000..0d580f13 --- /dev/null +++ b/scripts/remote-gateway-smoke.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Remote gateway smoke — health + OAuth metadata over a Cloudflare Tunnel hostname. + * + * Prereqs: + * cp .env.example .env # fill MCPMUX_CF_ACCESS_* and MCPMUX_REMOTE_GATEWAY_URL + * cloudflared tunnel running → localhost:45818 + * + * Usage: + * pnpm remote:smoke + * node scripts/remote-gateway-smoke.mjs + */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cfAccessCurlFlagsFromEnv, loadRepoDotEnv } from './cf-access-env.mjs'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * Run curl and return stdout. + * @param {string[]} args + */ +function curl(args) { + const result = spawnSync('curl', args, { encoding: 'utf8' }); + if (result.status !== 0) { + console.error(result.stderr || result.stdout); + process.exit(result.status ?? 1); + } + return result.stdout.trim(); +} + +/** + * @param {string} url + */ +function checkHealth(url) { + const code = curl([ + ...cfAccessCurlFlagsFromEnv(), + '-s', + '-o', + '/dev/null', + '-w', + '%{http_code}', + `${url}/health`, + ]); + console.log(`GET ${url}/health => HTTP ${code}`); + if (code !== '200') { + process.exit(1); + } +} + +/** + * @param {string} url + */ +function checkOAuthMetadata(url) { + const body = curl([ + ...cfAccessCurlFlagsFromEnv(), + '-s', + `${url}/.well-known/oauth-protected-resource`, + ]); + const parsed = JSON.parse(body); + console.log('Protected resource metadata:', parsed.resource); +} + +function main() { + loadRepoDotEnv(REPO_ROOT); + + const gatewayUrl = process.env.MCPMUX_REMOTE_GATEWAY_URL?.replace(/\/$/, ''); + if (!gatewayUrl) { + console.error('Set MCPMUX_REMOTE_GATEWAY_URL in .env (see .env.example)'); + process.exit(1); + } + + const cfHeaders = cfAccessCurlFlagsFromEnv(); + if (cfHeaders.length === 0) { + console.error('Set MCPMUX_CF_ACCESS_CLIENT_ID and MCPMUX_CF_ACCESS_CLIENT_SECRET in .env'); + process.exit(1); + } + + checkHealth(gatewayUrl); + checkOAuthMetadata(gatewayUrl); + + const adminUrl = process.env.MCPMUX_REMOTE_ADMIN_URL?.replace(/\/$/, ''); + if (adminUrl) { + const code = curl([ + ...cfAccessCurlFlagsFromEnv(), + '-s', + '-o', + '/dev/null', + '-w', + '%{http_code}', + `${adminUrl}/api/v1/health`, + ]); + console.log(`GET ${adminUrl}/api/v1/health => HTTP ${code}`); + if (code === '302') { + console.error( + 'Admin returned 302 — add the service token to the mux Access application policy in Cloudflare Zero Trust.', + ); + process.exit(1); + } + if (code !== '200') { + process.exit(1); + } + } +} + +main(); diff --git a/scripts/run-with-repo-env.mjs b/scripts/run-with-repo-env.mjs new file mode 100644 index 00000000..171196b8 --- /dev/null +++ b/scripts/run-with-repo-env.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Run a command with repo-root `.env` merged into the environment (when present). + * + * Usage: node scripts/run-with-repo-env.mjs tauri dev + */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadRepoDotEnv } from './cf-access-env.mjs'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +loadRepoDotEnv(REPO_ROOT); + +const [, , ...cmd] = process.argv; +if (cmd.length === 0) { + console.error('Usage: node scripts/run-with-repo-env.mjs [args...]'); + process.exit(1); +} + +const result = spawnSync(cmd[0], cmd.slice(1), { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', +}); + +process.exit(result.status ?? 1); From abf5ae6bde060c87ecd85bc8cd0b64d584b983f7 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 18:53:30 -0600 Subject: [PATCH 002/148] =?UTF-8?q?feat(port):=20Phase=202=20=E2=80=94=20S?= =?UTF-8?q?torage=20layer:=20migration=20reconciliation=20+=20new=20reposi?= =?UTF-8?q?tories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the fork's storage schema onto main, renumbered 020-031 to sit after upstream's 016-019. Extends InstalledServer (cloned_from, display_name_override, default_params(+strategy), update_policy, pinned_version, latest_available_version, version_checked_at, current_version), WorkspaceBinding (client_id, label), and FeatureSetMember (surfaced) additively, with the SQLite repos round-tripping the new columns. Adds embedding_repository + workspace_appearance_repository and their core traits/entity. Autonomous decisions: - Kept main's exact-match WorkspaceBinding resolution (find_exact_for_roots); did NOT port i18n's longest-prefix + client-scope resolver — per orchestrator Choice A. client_id/label are persisted additively but stay global (None) today; per-client routing is a later gateway phase. - Did not wire the new repos into ApplicationServices — i18n itself doesn't wire them there, and their consumers (gateway embedding/discovery services, workspace-appearance commands) land in later phases. Repos are crate-exported and unit-tested. - Kept main's stronger InstalledServerRepository semantics (build_server -> Result, careful decrypt error distinction) rather than porting i18n's signatures; only the 9 new columns were added to the existing install/update CRUD. Did not add i18n's set_display_name_override/update_version_cache trait methods (later-phase consumers). - Renamed migration 027's internal "-- Migration 023:" comment to 027 to match the renumbered filename. - New feature-set members default surfaced=false at every construction site, matching migration 023's DEFAULT 0 and the entity constructors. Signed-off-by: crimsonsunset --- .../src-tauri/src/commands/feature_set.rs | 2 + .../src/commands/workspace_binding.rs | 2 + crates/mcpmux-core/src/domain/feature_set.rs | 6 + .../src/domain/installed_server.rs | 212 +++++++++++++++- crates/mcpmux-core/src/domain/mod.rs | 6 +- .../src/domain/workspace_appearance.rs | 23 ++ .../src/domain/workspace_binding.rs | 11 + crates/mcpmux-core/src/repository/mod.rs | 42 +++- crates/mcpmux-storage/src/database.rs | 60 +++++ .../020_workspace_binding_label.sql | 2 + .../021_installed_server_cloned_from.sql | 2 + ...installed_server_display_name_override.sql | 2 + .../023_feature_set_member_surfaced.sql | 2 + .../src/migrations/024_workspace_icons.sql | 8 + .../src/migrations/025_tool_embeddings.sql | 8 + .../026_installed_server_default_params.sql | 1 + .../027_workspace_binding_client_scope.sql | 65 +++++ .../migrations/028_server_update_policy.sql | 4 + .../migrations/029_server_version_cache.sql | 4 + .../030_default_params_strategy.sql | 2 + .../migrations/031_server_current_version.sql | 2 + .../src/repositories/embedding_repository.rs | 232 ++++++++++++++++++ .../repositories/feature_set_repository.rs | 23 +- .../installed_server_repository.rs | 81 +++++- crates/mcpmux-storage/src/repositories/mod.rs | 4 + .../workspace_appearance_repository.rs | 137 +++++++++++ .../workspace_binding_repository.rs | 17 +- tests/rust/src/mocks.rs | 1 + .../tests/integration/effective_features.rs | 1 + tests/rust/tests/integration/mcp_flows.rs | 4 + 30 files changed, 945 insertions(+), 21 deletions(-) create mode 100644 crates/mcpmux-core/src/domain/workspace_appearance.rs create mode 100644 crates/mcpmux-storage/src/migrations/020_workspace_binding_label.sql create mode 100644 crates/mcpmux-storage/src/migrations/021_installed_server_cloned_from.sql create mode 100644 crates/mcpmux-storage/src/migrations/022_installed_server_display_name_override.sql create mode 100644 crates/mcpmux-storage/src/migrations/023_feature_set_member_surfaced.sql create mode 100644 crates/mcpmux-storage/src/migrations/024_workspace_icons.sql create mode 100644 crates/mcpmux-storage/src/migrations/025_tool_embeddings.sql create mode 100644 crates/mcpmux-storage/src/migrations/026_installed_server_default_params.sql create mode 100644 crates/mcpmux-storage/src/migrations/027_workspace_binding_client_scope.sql create mode 100644 crates/mcpmux-storage/src/migrations/028_server_update_policy.sql create mode 100644 crates/mcpmux-storage/src/migrations/029_server_version_cache.sql create mode 100644 crates/mcpmux-storage/src/migrations/030_default_params_strategy.sql create mode 100644 crates/mcpmux-storage/src/migrations/031_server_current_version.sql create mode 100644 crates/mcpmux-storage/src/repositories/embedding_repository.rs create mode 100644 crates/mcpmux-storage/src/repositories/workspace_appearance_repository.rs diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 297b941d..806825e3 100644 --- a/apps/desktop/src-tauri/src/commands/feature_set.rs +++ b/apps/desktop/src-tauri/src/commands/feature_set.rs @@ -391,6 +391,7 @@ pub async fn add_feature_set_member( member_type, member_id: input.member_id, mode, + surfaced: false, }; feature_set.members.push(member); @@ -515,6 +516,7 @@ pub async fn set_feature_set_members( member_type, member_id: input.member_id, mode, + surfaced: false, } }) .collect(); diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index f62cc07e..b8763a54 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -322,6 +322,8 @@ pub async fn update_workspace_binding( let updated = WorkspaceBinding { id: existing.id, workspace_root: normalized, + client_id: existing.client_id, + label: existing.label, space_id, feature_set_ids, created_at: existing.created_at, diff --git a/crates/mcpmux-core/src/domain/feature_set.rs b/crates/mcpmux-core/src/domain/feature_set.rs index 1a7659aa..1dcb7d92 100644 --- a/crates/mcpmux-core/src/domain/feature_set.rs +++ b/crates/mcpmux-core/src/domain/feature_set.rs @@ -125,6 +125,9 @@ pub struct FeatureSetMember { pub member_id: String, /// Include or exclude pub mode: MemberMode, + /// When true on an included tool member, promote into client `tools/list`. + #[serde(default)] + pub surfaced: bool, } impl FeatureSetMember { @@ -136,6 +139,7 @@ impl FeatureSetMember { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode: MemberMode::Include, + surfaced: false, } } @@ -147,6 +151,7 @@ impl FeatureSetMember { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode: MemberMode::Exclude, + surfaced: false, } } @@ -158,6 +163,7 @@ impl FeatureSetMember { member_type: MemberType::FeatureSet, member_id: included_featureset_id.to_string(), mode: MemberMode::Include, + surfaced: false, } } } diff --git a/crates/mcpmux-core/src/domain/installed_server.rs b/crates/mcpmux-core/src/domain/installed_server.rs index 8c5422f7..af07d78a 100644 --- a/crates/mcpmux-core/src/domain/installed_server.rs +++ b/crates/mcpmux-core/src/domain/installed_server.rs @@ -2,12 +2,75 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; use uuid::Uuid; use super::ServerDefinition; +/// Per-server package update policy for npx/uvx stdio transports. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum UpdatePolicy { + /// Inject `@latest` (npx) or run `uv tool upgrade` (uvx) at spawn time. + Auto, + /// Default — surface available updates without auto-upgrading (Phase 2 probe). + #[default] + Notify, + /// Lock to `pinned_version` at spawn time (enforced in Phase 3). + Pinned, +} + +impl UpdatePolicy { + /// Parse a database-stored policy string (`auto` / `notify` / `pinned`). + pub fn from_db_str(value: &str) -> Self { + match value { + "auto" => Self::Auto, + "pinned" => Self::Pinned, + _ => Self::Notify, + } + } + + /// Serialize to the `installed_servers.update_policy` column value. + pub fn as_db_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Notify => "notify", + Self::Pinned => "pinned", + } + } +} + +/// Merge strategy when `default_params` and caller-supplied args share a key. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum DefaultParamsStrategy { + /// Default — caller-supplied args win on collision. + #[default] + Fill, + /// Pre-configured defaults win on collision; caller args only fill missing keys. + Override, +} + +impl DefaultParamsStrategy { + /// Parse a database-stored strategy string (`fill` / `override`). + pub fn from_db_str(value: &str) -> Self { + match value { + "override" => Self::Override, + _ => Self::Fill, + } + } + + /// Serialize to the `installed_servers.default_params_strategy` column value. + pub fn as_db_str(self) -> &'static str { + match self { + Self::Fill => "fill", + Self::Override => "override", + } + } +} + /// Tracks how a server was installed (for sync/cleanup decisions) #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -74,6 +137,20 @@ pub struct InstalledServer { #[serde(default)] pub extra_headers: HashMap, + /// Default tool-call arguments merged into every call routed to this server. + /// + /// Non-secret values only (e.g. cloudId, projectKey). + /// Merge behaviour is controlled by `default_params_strategy`. + #[serde(default)] + pub default_params: HashMap, + + /// How `default_params` are merged with caller-supplied args. + /// + /// `fill` (default) — caller wins on collision. + /// `override` — defaults win; caller args only fill missing keys. + #[serde(default)] + pub default_params_strategy: DefaultParamsStrategy, + /// Whether OAuth authentication has been completed pub oauth_connected: bool, @@ -81,6 +158,40 @@ pub struct InstalledServer { #[serde(default)] pub source: InstallationSource, + /// Source server ID when this install was cloned from another server in the same space + #[serde(default)] + pub cloned_from: Option, + + /// User-supplied display label that survives user-config sync. + /// + /// When set, the UI and meta tools prefer this over `server_name` / + /// `cached_definition.name`. The `server_id`, alias, and tool prefixes are + /// unaffected. + #[serde(default)] + pub display_name_override: Option, + + /// Package update policy for npx/uvx stdio transports. + #[serde(default)] + pub update_policy: UpdatePolicy, + + /// Exact semver pin when `update_policy` is `Pinned` (enforced in Phase 3). + #[serde(default)] + pub pinned_version: Option, + + /// Latest registry version from the most recent notify-mode probe. + #[serde(default)] + pub latest_available_version: Option, + + /// Resolved installed version from the most recent probe (npx cache / + /// `uv tool list`). Lets the UI badge bare `npx -y pkg` / `uvx pkg` + /// installs that carry no `@semver` in their args. + #[serde(default)] + pub current_version: Option, + + /// When `latest_available_version` was last probed (RFC3339 in DB). + #[serde(default)] + pub version_checked_at: Option>, + /// Creation timestamp pub created_at: DateTime, @@ -105,8 +216,17 @@ impl InstalledServer { env_overrides: HashMap::new(), args_append: Vec::new(), extra_headers: HashMap::new(), + default_params: HashMap::new(), + default_params_strategy: DefaultParamsStrategy::default(), oauth_connected: false, source: InstallationSource::default(), + cloned_from: None, + display_name_override: None, + update_policy: UpdatePolicy::default(), + pinned_version: None, + latest_available_version: None, + current_version: None, + version_checked_at: None, created_at: now, updated_at: now, } @@ -126,8 +246,14 @@ impl InstalledServer { .and_then(|json| serde_json::from_str(json).ok()) } - /// Get display name (from cached definition or server_id fallback) + /// Get effective display name. + /// + /// Precedence: `display_name_override` (user-supplied) → `server_name` + /// (cached at install time) → final segment of `server_id`. pub fn display_name(&self) -> &str { + if let Some(override_name) = self.display_name_override.as_deref() { + return override_name; + } self.server_name.as_deref().unwrap_or_else(|| { self.server_id .split('/') @@ -136,6 +262,15 @@ impl InstalledServer { }) } + /// Set the user-supplied display override (None or empty/whitespace clears it). + pub fn with_display_name_override(mut self, value: Option>) -> Self { + self.display_name_override = value + .map(Into::into) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + self + } + /// Set input values pub fn with_inputs(mut self, inputs: HashMap) -> Self { self.input_values = inputs; @@ -154,12 +289,33 @@ impl InstalledServer { self } + /// Set the package update policy for this installation. + pub fn with_update_policy(mut self, policy: UpdatePolicy) -> Self { + self.update_policy = policy; + self + } + + /// Set the pinned package version (used when policy is `Pinned`). + pub fn with_pinned_version(mut self, version: Option>) -> Self { + self.pinned_version = version + .map(Into::into) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + self + } + /// Set installation source pub fn with_source(mut self, source: InstallationSource) -> Self { self.source = source; self } + /// Set the source server ID when this install is a clone + pub fn with_cloned_from(mut self, source_server_id: impl Into) -> Self { + self.cloned_from = Some(source_server_id.into()); + self + } + /// Update OAuth connected state pub fn set_oauth_connected(&mut self, connected: bool) { self.oauth_connected = connected; @@ -230,6 +386,10 @@ mod tests { server.extra_headers.is_empty(), "New server should have empty extra_headers" ); + assert!( + server.default_params.is_empty(), + "New server should have empty default_params" + ); } #[test] @@ -450,4 +610,54 @@ mod tests { assert_eq!(deserialized.args_append.len(), 100); assert_eq!(deserialized.args_append[99], "--arg-99"); } + + #[test] + fn test_display_name_override_takes_precedence() { + let mut server = InstalledServer::new("space_default", "google.com/calendar"); + server.server_name = Some("Google Calendar".to_string()); + + assert_eq!(server.display_name(), "Google Calendar"); + + server.display_name_override = Some("Joe Calendar".to_string()); + assert_eq!(server.display_name(), "Joe Calendar"); + } + + #[test] + fn test_with_display_name_override_trims_and_clears() { + let server = InstalledServer::new("space_default", "test-server") + .with_display_name_override(Some(" Work Account ")); + assert_eq!( + server.display_name_override.as_deref(), + Some("Work Account") + ); + + let cleared = server.with_display_name_override(Some(" ")); + assert!(cleared.display_name_override.is_none()); + + let none_clears = InstalledServer::new("space_default", "test-server") + .with_display_name_override(Some("Name")) + .with_display_name_override(Option::::None); + assert!(none_clears.display_name_override.is_none()); + } + + #[test] + fn test_display_name_override_default_on_deserialize() { + let json = r#"{ + "id": "00000000-0000-0000-0000-000000000001", + "space_id": "space_default", + "server_id": "test-server", + "server_name": "Catalog Name", + "cached_definition": null, + "input_values": {}, + "enabled": false, + "oauth_connected": false, + "source": {"type": "registry"}, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }"#; + + let server: InstalledServer = serde_json::from_str(json).expect("Failed to deserialize"); + assert!(server.display_name_override.is_none()); + assert_eq!(server.display_name(), "Catalog Name"); + } } diff --git a/crates/mcpmux-core/src/domain/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index 83d97def..cdaa5fb4 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -17,6 +17,7 @@ mod server; mod server_feature; mod server_log; mod space; +mod workspace_appearance; mod workspace_binding; // Export event types first (ConnectionStatus is defined here) @@ -31,12 +32,15 @@ pub use client::*; pub use config::*; pub use credential::*; pub use feature_set::*; -pub use installed_server::{InstallationSource, InstalledServer}; +pub use installed_server::{ + DefaultParamsStrategy, InstallationSource, InstalledServer, UpdatePolicy, +}; pub use outbound_oauth_registration::*; pub use server::*; pub use server_feature::*; pub use server_log::*; pub use space::*; +pub use workspace_appearance::WorkspaceAppearance; pub use workspace_binding::{ longest_matching_base, normalize_workspace_root, path_is_within, validate_workspace_root, WorkspaceBinding, WorkspaceRootValidation, diff --git a/crates/mcpmux-core/src/domain/workspace_appearance.rs b/crates/mcpmux-core/src/domain/workspace_appearance.rs new file mode 100644 index 00000000..eca907d1 --- /dev/null +++ b/crates/mcpmux-core/src/domain/workspace_appearance.rs @@ -0,0 +1,23 @@ +//! WorkspaceAppearance entity for unmapped workspace roots. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Appearance metadata keyed by normalized workspace root. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceAppearance { + pub workspace_root: String, + pub icon: String, + pub updated_at: DateTime, +} + +impl WorkspaceAppearance { + /// Create a new workspace appearance record. + pub fn new(workspace_root: impl Into, icon: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + icon: icon.into(), + updated_at: Utc::now(), + } + } +} diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 8260dfc8..393b20ac 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -33,6 +33,15 @@ use uuid::Uuid; pub struct WorkspaceBinding { pub id: Uuid, pub workspace_root: String, + /// Optional OAuth client scope. `None` is a global binding (the only kind + /// produced today — resolution stays exact-match-global). The column + /// exists for a later per-client routing phase; it is persisted but does + /// not affect resolution yet. + #[serde(default)] + pub client_id: Option, + /// Optional friendly display label shown in the UI instead of the path. + #[serde(default)] + pub label: Option, pub space_id: Uuid, /// Order matters for UI rendering only — the resolver treats them as /// a set. Stored in the `workspace_binding_feature_sets` junction @@ -63,6 +72,8 @@ impl WorkspaceBinding { Self { id: Uuid::new_v4(), workspace_root: workspace_root.into(), + client_id: None, + label: None, space_id, feature_set_ids, created_at: now, diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 5d73e2de..e5d22884 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -8,7 +8,8 @@ use uuid::Uuid; use crate::domain::{ Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode, - OutboundOAuthRegistration, ServerFeature, Space, SpaceBaseDir, WorkspaceBinding, + OutboundOAuthRegistration, ServerFeature, Space, SpaceBaseDir, WorkspaceAppearance, + WorkspaceBinding, }; /// Result type for repository operations @@ -406,3 +407,42 @@ pub trait SpaceBuiltinConfigRepository: Send + Sync { enabled: bool, ) -> RepoResult<()>; } + +/// A persisted embedding keyed by content hash + model version. +#[derive(Debug, Clone, PartialEq)] +pub struct EmbeddingRecord { + pub content_hash: String, + pub model_version: String, + pub vector: Vec, +} + +/// Embedding repository trait — caches tool embedding vectors for semantic search. +#[async_trait] +pub trait EmbeddingRepository: Send + Sync { + /// Load vectors for a set of content hashes and a model version. + async fn get_many( + &self, + content_hashes: &[String], + model_version: &str, + ) -> RepoResult>; + + /// Insert or replace vectors by `(content_hash, model_version)`. + async fn upsert_many(&self, records: &[EmbeddingRecord]) -> RepoResult<()>; +} + +/// Workspace appearance repository trait — icon overrides keyed by normalized +/// workspace root (covers unmapped roots that have no binding). +#[async_trait] +pub trait WorkspaceAppearanceRepository: Send + Sync { + /// List all stored workspace appearance overrides. + async fn list(&self) -> RepoResult>; + + /// Get a stored appearance by normalized workspace root. + async fn get(&self, workspace_root: &str) -> RepoResult>; + + /// Insert or update an appearance for a normalized workspace root. + async fn upsert(&self, appearance: &WorkspaceAppearance) -> RepoResult<()>; + + /// Delete a stored appearance by normalized workspace root. + async fn delete(&self, workspace_root: &str) -> RepoResult<()>; +} diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 14a32c2a..e2257183 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -128,6 +128,66 @@ const MIGRATIONS: &[Migration] = &[ name: "space_base_dirs", sql: include_str!("migrations/019_space_base_dirs.sql"), }, + Migration { + version: 20, + name: "workspace_binding_label", + sql: include_str!("migrations/020_workspace_binding_label.sql"), + }, + Migration { + version: 21, + name: "installed_server_cloned_from", + sql: include_str!("migrations/021_installed_server_cloned_from.sql"), + }, + Migration { + version: 22, + name: "installed_server_display_name_override", + sql: include_str!("migrations/022_installed_server_display_name_override.sql"), + }, + Migration { + version: 23, + name: "feature_set_member_surfaced", + sql: include_str!("migrations/023_feature_set_member_surfaced.sql"), + }, + Migration { + version: 24, + name: "workspace_icons", + sql: include_str!("migrations/024_workspace_icons.sql"), + }, + Migration { + version: 25, + name: "tool_embeddings", + sql: include_str!("migrations/025_tool_embeddings.sql"), + }, + Migration { + version: 26, + name: "installed_server_default_params", + sql: include_str!("migrations/026_installed_server_default_params.sql"), + }, + Migration { + version: 27, + name: "workspace_binding_client_scope", + sql: include_str!("migrations/027_workspace_binding_client_scope.sql"), + }, + Migration { + version: 28, + name: "server_update_policy", + sql: include_str!("migrations/028_server_update_policy.sql"), + }, + Migration { + version: 29, + name: "server_version_cache", + sql: include_str!("migrations/029_server_version_cache.sql"), + }, + Migration { + version: 30, + name: "default_params_strategy", + sql: include_str!("migrations/030_default_params_strategy.sql"), + }, + Migration { + version: 31, + name: "server_current_version", + sql: include_str!("migrations/031_server_current_version.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/020_workspace_binding_label.sql b/crates/mcpmux-storage/src/migrations/020_workspace_binding_label.sql new file mode 100644 index 00000000..8e68ad92 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/020_workspace_binding_label.sql @@ -0,0 +1,2 @@ +-- Optional friendly display name for workspace bindings (separate from workspace_root). +ALTER TABLE workspace_bindings ADD COLUMN label TEXT; diff --git a/crates/mcpmux-storage/src/migrations/021_installed_server_cloned_from.sql b/crates/mcpmux-storage/src/migrations/021_installed_server_cloned_from.sql new file mode 100644 index 00000000..a1616bb9 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/021_installed_server_cloned_from.sql @@ -0,0 +1,2 @@ +-- Track clone lineage on installed servers (display-only in v1). +ALTER TABLE installed_servers ADD COLUMN cloned_from TEXT; diff --git a/crates/mcpmux-storage/src/migrations/022_installed_server_display_name_override.sql b/crates/mcpmux-storage/src/migrations/022_installed_server_display_name_override.sql new file mode 100644 index 00000000..868380a8 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/022_installed_server_display_name_override.sql @@ -0,0 +1,2 @@ +-- User-supplied display name that survives user-config sync (UI-preferred label). +ALTER TABLE installed_servers ADD COLUMN display_name_override TEXT; diff --git a/crates/mcpmux-storage/src/migrations/023_feature_set_member_surfaced.sql b/crates/mcpmux-storage/src/migrations/023_feature_set_member_surfaced.sql new file mode 100644 index 00000000..aa114a59 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/023_feature_set_member_surfaced.sql @@ -0,0 +1,2 @@ +-- Per-member flag: when set on an included tool, promote into client tools/list. +ALTER TABLE feature_set_members ADD COLUMN surfaced INTEGER NOT NULL DEFAULT 0; diff --git a/crates/mcpmux-storage/src/migrations/024_workspace_icons.sql b/crates/mcpmux-storage/src/migrations/024_workspace_icons.sql new file mode 100644 index 00000000..e295690f --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/024_workspace_icons.sql @@ -0,0 +1,8 @@ +-- Workspace icon metadata for mapped and unmapped workspace roots. +ALTER TABLE workspace_bindings ADD COLUMN icon TEXT; + +CREATE TABLE IF NOT EXISTS workspace_appearances ( + workspace_root TEXT PRIMARY KEY, + icon TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/crates/mcpmux-storage/src/migrations/025_tool_embeddings.sql b/crates/mcpmux-storage/src/migrations/025_tool_embeddings.sql new file mode 100644 index 00000000..f8372122 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/025_tool_embeddings.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS tool_embeddings ( + content_hash TEXT NOT NULL, + model_version TEXT NOT NULL, + vector BLOB NOT NULL, + dims INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (content_hash, model_version) +); diff --git a/crates/mcpmux-storage/src/migrations/026_installed_server_default_params.sql b/crates/mcpmux-storage/src/migrations/026_installed_server_default_params.sql new file mode 100644 index 00000000..e3236bd0 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/026_installed_server_default_params.sql @@ -0,0 +1 @@ +ALTER TABLE installed_servers ADD COLUMN default_params TEXT NOT NULL DEFAULT '{}'; diff --git a/crates/mcpmux-storage/src/migrations/027_workspace_binding_client_scope.sql b/crates/mcpmux-storage/src/migrations/027_workspace_binding_client_scope.sql new file mode 100644 index 00000000..2c71578b --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/027_workspace_binding_client_scope.sql @@ -0,0 +1,65 @@ +-- Migration 027: Optional client_id scope on workspace_bindings. +-- +-- Global bindings (client_id IS NULL) remain the default — one per path, +-- shared by any client without a scoped override. Scoped bindings +-- (client_id set) let distinct OAuth clients route the same filesystem +-- path to different FeatureSets. +-- +-- Partial unique indexes replace the old global UNIQUE(workspace_root). + +CREATE TABLE workspace_binding_feature_sets_backup AS +SELECT * FROM workspace_binding_feature_sets; + +DROP TABLE workspace_binding_feature_sets; + +CREATE TABLE workspace_bindings_v3 ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL, + client_id TEXT REFERENCES inbound_clients(client_id) ON DELETE SET NULL, + label TEXT, + icon TEXT, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_v3 + (id, workspace_root, client_id, label, icon, space_id, created_at, updated_at) +SELECT id, workspace_root, NULL, label, icon, space_id, created_at, updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_v3 RENAME TO workspace_bindings; + +CREATE TABLE workspace_binding_feature_sets ( + binding_id TEXT NOT NULL REFERENCES workspace_bindings(id) ON DELETE CASCADE, + feature_set_id TEXT NOT NULL REFERENCES feature_sets(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (binding_id, feature_set_id) +); + +INSERT INTO workspace_binding_feature_sets +SELECT binding_id, feature_set_id, sort_order +FROM workspace_binding_feature_sets_backup; + +DROP TABLE workspace_binding_feature_sets_backup; + +CREATE INDEX IF NOT EXISTS idx_wbfs_binding + ON workspace_binding_feature_sets(binding_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_wb_root_global + ON workspace_bindings(workspace_root) + WHERE client_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_wb_root_scoped + ON workspace_bindings(client_id, workspace_root) + WHERE client_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root + ON workspace_bindings(workspace_root); + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space + ON workspace_bindings(space_id); + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_client + ON workspace_bindings(client_id); diff --git a/crates/mcpmux-storage/src/migrations/028_server_update_policy.sql b/crates/mcpmux-storage/src/migrations/028_server_update_policy.sql new file mode 100644 index 00000000..3d6c8efb --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/028_server_update_policy.sql @@ -0,0 +1,4 @@ +ALTER TABLE installed_servers + ADD COLUMN update_policy TEXT NOT NULL DEFAULT 'notify'; +ALTER TABLE installed_servers + ADD COLUMN pinned_version TEXT; diff --git a/crates/mcpmux-storage/src/migrations/029_server_version_cache.sql b/crates/mcpmux-storage/src/migrations/029_server_version_cache.sql new file mode 100644 index 00000000..a7986324 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/029_server_version_cache.sql @@ -0,0 +1,4 @@ +ALTER TABLE installed_servers + ADD COLUMN latest_available_version TEXT; +ALTER TABLE installed_servers + ADD COLUMN version_checked_at TEXT; diff --git a/crates/mcpmux-storage/src/migrations/030_default_params_strategy.sql b/crates/mcpmux-storage/src/migrations/030_default_params_strategy.sql new file mode 100644 index 00000000..b8b26b19 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/030_default_params_strategy.sql @@ -0,0 +1,2 @@ +ALTER TABLE installed_servers + ADD COLUMN default_params_strategy TEXT NOT NULL DEFAULT 'fill'; diff --git a/crates/mcpmux-storage/src/migrations/031_server_current_version.sql b/crates/mcpmux-storage/src/migrations/031_server_current_version.sql new file mode 100644 index 00000000..b3438cf7 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/031_server_current_version.sql @@ -0,0 +1,2 @@ +ALTER TABLE installed_servers + ADD COLUMN current_version TEXT; diff --git a/crates/mcpmux-storage/src/repositories/embedding_repository.rs b/crates/mcpmux-storage/src/repositories/embedding_repository.rs new file mode 100644 index 00000000..fe4524f2 --- /dev/null +++ b/crates/mcpmux-storage/src/repositories/embedding_repository.rs @@ -0,0 +1,232 @@ +//! SQLite implementation of EmbeddingRepository. + +use std::sync::Arc; + +use anyhow::{bail, Result}; +use async_trait::async_trait; +use ring::digest::{digest, SHA256}; +use rusqlite::params; +use tokio::sync::Mutex; + +use crate::Database; + +/// SQLite-backed implementation of EmbeddingRepository. +pub struct SqliteEmbeddingRepository { + db: Arc>, +} + +impl SqliteEmbeddingRepository { + /// Create a new SQLite embedding repository. + pub fn new(db: Arc>) -> Self { + Self { db } + } +} + +/// Compute the stable SHA-256 content hash for embedding text. +pub fn hash_embedding_content(content: &str) -> String { + let hash = digest(&SHA256, content.as_bytes()); + hex::encode(hash.as_ref()) +} + +/// Encode an embedding vector as little-endian f32 bytes. +fn encode_vector(vector: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(std::mem::size_of_val(vector)); + for value in vector { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +/// Decode a little-endian f32 byte buffer into an embedding vector. +fn decode_vector(blob: &[u8]) -> Result> { + if !blob.len().is_multiple_of(std::mem::size_of::()) { + bail!( + "Embedding vector blob length {} is not divisible by {}", + blob.len(), + std::mem::size_of::() + ); + } + + Ok(blob + .chunks_exact(std::mem::size_of::()) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect()) +} + +#[async_trait] +impl mcpmux_core::EmbeddingRepository for SqliteEmbeddingRepository { + async fn get_many( + &self, + content_hashes: &[String], + model_version: &str, + ) -> mcpmux_core::RepoResult> { + if content_hashes.is_empty() { + return Ok(Vec::new()); + } + + let db = self.db.lock().await; + let conn = db.connection(); + + let mut records = Vec::new(); + // Fetch in chunks with a single `IN (...)` query per chunk instead of + // one round-trip per hash. SQLite caps bound variables (~999 on older + // builds); 800 + the shared model_version stays clear of that limit. + const CHUNK: usize = 800; + for chunk in content_hashes.chunks(CHUNK) { + let placeholders = vec!["?"; chunk.len()].join(", "); + let sql = format!( + "SELECT content_hash, vector + FROM tool_embeddings + WHERE model_version = ? AND content_hash IN ({placeholders})" + ); + let mut stmt = conn.prepare(&sql)?; + + let mut bound: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1); + bound.push(&model_version); + for content_hash in chunk { + bound.push(content_hash); + } + + let rows = stmt.query_map(bound.as_slice(), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) + })?; + for row in rows { + let (content_hash, vector_blob) = row?; + records.push(mcpmux_core::EmbeddingRecord { + content_hash, + model_version: model_version.to_string(), + vector: decode_vector(&vector_blob)?, + }); + } + } + + Ok(records) + } + + async fn upsert_many( + &self, + records: &[mcpmux_core::EmbeddingRecord], + ) -> mcpmux_core::RepoResult<()> { + if records.is_empty() { + return Ok(()); + } + + let db = self.db.lock().await; + let conn = db.connection(); + + for record in records { + conn.execute( + "INSERT INTO tool_embeddings (content_hash, model_version, vector, dims, created_at) + VALUES (?1, ?2, ?3, ?4, CAST(strftime('%s', 'now') AS INTEGER)) + ON CONFLICT(content_hash, model_version) DO UPDATE SET + vector = excluded.vector, + dims = excluded.dims", + params![ + record.content_hash, + record.model_version, + encode_vector(&record.vector), + record.vector.len() as i64, + ], + )?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{hash_embedding_content, SqliteEmbeddingRepository}; + use crate::Database; + use mcpmux_core::{EmbeddingRecord, EmbeddingRepository}; + use std::sync::Arc; + use tokio::sync::Mutex; + + /// Create an in-memory database for repository tests. + async fn setup_test_db() -> Arc> { + let db = Database::open_in_memory().expect("Failed to create in-memory database"); + Arc::new(Mutex::new(db)) + } + + #[tokio::test] + async fn upsert_and_get_round_trip() { + let db = setup_test_db().await; + let repository = SqliteEmbeddingRepository::new(db); + + let content_hash = hash_embedding_content("tool: read_file\nReads files from disk."); + let model_version = "bge-small-en-v1.5"; + let expected_vector = vec![0.125, -2.5, 7.75, 0.0]; + + repository + .upsert_many(&[EmbeddingRecord { + content_hash: content_hash.clone(), + model_version: model_version.to_string(), + vector: expected_vector.clone(), + }]) + .await + .expect("Upsert should succeed"); + + let records = repository + .get_many(&[content_hash], model_version) + .await + .expect("Get should succeed"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].vector, expected_vector); + } + + #[tokio::test] + async fn upsert_overwrites_on_primary_key_conflict() { + let db = setup_test_db().await; + let repository = SqliteEmbeddingRepository::new(db); + + let content_hash = hash_embedding_content("tool: write_file\nWrites files to disk."); + let model_version = "bge-small-en-v1.5"; + + repository + .upsert_many(&[EmbeddingRecord { + content_hash: content_hash.clone(), + model_version: model_version.to_string(), + vector: vec![1.0, 2.0], + }]) + .await + .expect("Initial upsert should succeed"); + + repository + .upsert_many(&[EmbeddingRecord { + content_hash: content_hash.clone(), + model_version: model_version.to_string(), + vector: vec![3.5, 4.5, 5.5], + }]) + .await + .expect("Conflict upsert should succeed"); + + let records = repository + .get_many(&[content_hash], model_version) + .await + .expect("Get should succeed"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].vector, vec![3.5, 4.5, 5.5]); + } + + #[tokio::test] + async fn get_many_returns_empty_for_missing_hashes() { + let db = setup_test_db().await; + let repository = SqliteEmbeddingRepository::new(db); + let model_version = "bge-small-en-v1.5"; + + let records = repository + .get_many( + &[ + hash_embedding_content("missing one"), + hash_embedding_content("missing two"), + ], + model_version, + ) + .await + .expect("Get should succeed"); + + assert!(records.is_empty()); + } +} diff --git a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs index fed4cb93..805fd8dc 100644 --- a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs +++ b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs @@ -67,6 +67,7 @@ impl SqliteFeatureSetRepository { .unwrap_or(MemberType::Feature), member_id: row.get(3)?, mode: MemberMode::parse(&row.get::<_, String>(4)?).unwrap_or(MemberMode::Include), + surfaced: row.get::<_, i32>(5).unwrap_or(0) == 1, }) } @@ -76,7 +77,7 @@ impl SqliteFeatureSetRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT id, feature_set_id, member_type, member_id, mode + "SELECT id, feature_set_id, member_type, member_id, mode, surfaced FROM feature_set_members WHERE feature_set_id = ? ORDER BY id", @@ -95,7 +96,7 @@ impl SqliteFeatureSetRepository { feature_set_id: &str, ) -> Result> { let mut stmt = conn.prepare( - "SELECT id, feature_set_id, member_type, member_id, mode + "SELECT id, feature_set_id, member_type, member_id, mode, surfaced FROM feature_set_members WHERE feature_set_id = ? ORDER BY id", @@ -210,14 +211,15 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { let now = chrono::Utc::now().to_rfc3339(); for member in &feature_set.members { conn.execute( - "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, surfaced, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ member.id, member.feature_set_id, member.member_type.as_str(), member.member_id, member.mode.as_str(), + if member.surfaced { 1 } else { 0 }, now, ], )?; @@ -267,14 +269,15 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { let now = chrono::Utc::now().to_rfc3339(); for member in &feature_set.members { conn.execute( - "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, surfaced, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ member.id, member.feature_set_id, member.member_type.as_str(), member.member_id, member.mode.as_str(), + if member.surfaced { 1 } else { 0 }, now, ], )?; @@ -372,17 +375,19 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode, + surfaced: false, }; conn.execute( - "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, surfaced, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ member.id, member.feature_set_id, member.member_type.as_str(), member.member_id, member.mode.as_str(), + if member.surfaced { 1 } else { 0 }, chrono::Utc::now().to_rfc3339(), ], )?; @@ -410,7 +415,7 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT id, feature_set_id, member_type, member_id, mode + "SELECT id, feature_set_id, member_type, member_id, mode, surfaced FROM feature_set_members WHERE feature_set_id = ?1 AND member_type = 'feature' ORDER BY id", diff --git a/crates/mcpmux-storage/src/repositories/installed_server_repository.rs b/crates/mcpmux-storage/src/repositories/installed_server_repository.rs index 010b68d4..8827b809 100644 --- a/crates/mcpmux-storage/src/repositories/installed_server_repository.rs +++ b/crates/mcpmux-storage/src/repositories/installed_server_repository.rs @@ -7,8 +7,12 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mcpmux_core::{InstallationSource, InstalledServer, InstalledServerRepository}; +use mcpmux_core::{ + DefaultParamsStrategy, InstallationSource, InstalledServer, InstalledServerRepository, + UpdatePolicy, +}; use rusqlite::{params, OptionalExtension}; +use serde_json::Value; use tokio::sync::Mutex; use uuid::Uuid; @@ -30,6 +34,15 @@ struct RawServerRow { created_at: String, updated_at: String, source: Option, + cloned_from: Option, + display_name_override: Option, + default_params: Option, + update_policy: String, + pinned_version: Option, + latest_available_version: Option, + version_checked_at: Option, + default_params_strategy: Option, + current_version: Option, } /// SQLite-backed implementation of InstalledServerRepository. @@ -121,6 +134,17 @@ impl SqliteInstalledServerRepository { serde_json::to_string(vec).unwrap_or_else(|_| "[]".to_string()) } + /// Parse JSON string to a `HashMap` (for `default_params`). + fn parse_json_value_map(s: Option) -> HashMap { + s.and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() + } + + /// Serialize a `HashMap` to JSON string (for `default_params`). + fn serialize_json_value_map(map: &HashMap) -> String { + serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string()) + } + /// Serialize InstallationSource to database string format. /// Format: "registry" | "user_config:/path/to/file.json" | "manual_entry" fn serialize_source(source: &InstallationSource) -> String { @@ -151,7 +175,9 @@ impl SqliteInstalledServerRepository { /// Standard column list for SELECT queries const SELECT_COLUMNS: &'static str = "id, space_id, server_id, server_name, cached_definition, input_values, enabled, env_overrides, - args_append, extra_headers, oauth_connected, created_at, updated_at, source"; + args_append, extra_headers, oauth_connected, created_at, updated_at, source, cloned_from, + display_name_override, default_params, update_policy, pinned_version, + latest_available_version, version_checked_at, default_params_strategy, current_version"; /// Extract raw row data (used in the closure passed to rusqlite). fn extract_row(row: &rusqlite::Row) -> rusqlite::Result { @@ -170,6 +196,15 @@ impl SqliteInstalledServerRepository { created_at: row.get(11)?, updated_at: row.get(12)?, source: row.get(13)?, + cloned_from: row.get(14)?, + display_name_override: row.get(15)?, + default_params: row.get(16)?, + update_policy: row.get(17)?, + pinned_version: row.get(18)?, + latest_available_version: row.get(19)?, + version_checked_at: row.get(20)?, + default_params_strategy: row.get(21)?, + current_version: row.get(22)?, }) } @@ -189,8 +224,21 @@ impl SqliteInstalledServerRepository { env_overrides: Self::parse_json_map(row.env_overrides), args_append: Self::parse_json_vec(row.args_append), extra_headers: Self::parse_json_map(row.extra_headers), + default_params: Self::parse_json_value_map(row.default_params), + default_params_strategy: row + .default_params_strategy + .as_deref() + .map(DefaultParamsStrategy::from_db_str) + .unwrap_or_default(), oauth_connected: row.oauth_connected, source: Self::parse_source(row.source), + cloned_from: row.cloned_from, + display_name_override: row.display_name_override, + update_policy: UpdatePolicy::from_db_str(&row.update_policy), + pinned_version: row.pinned_version, + latest_available_version: row.latest_available_version, + current_version: row.current_version, + version_checked_at: row.version_checked_at.as_deref().map(Self::parse_datetime), created_at: Self::parse_datetime(&row.created_at), updated_at: Self::parse_datetime(&row.updated_at), }) @@ -298,8 +346,10 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { conn.execute( "INSERT INTO installed_servers (id, space_id, server_id, server_name, cached_definition, input_values, enabled, env_overrides, - args_append, extra_headers, oauth_connected, created_at, updated_at, source) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + args_append, extra_headers, oauth_connected, created_at, updated_at, source, cloned_from, + display_name_override, default_params, update_policy, pinned_version, + latest_available_version, version_checked_at, default_params_strategy, current_version) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)", params![ server.id.to_string(), server.space_id, @@ -315,6 +365,15 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { server.created_at.to_rfc3339(), server.updated_at.to_rfc3339(), Self::serialize_source(&server.source), + server.cloned_from, + server.display_name_override, + Self::serialize_json_value_map(&server.default_params), + server.update_policy.as_db_str(), + server.pinned_version, + server.latest_available_version, + server.version_checked_at.map(|dt| dt.to_rfc3339()), + server.default_params_strategy.as_db_str(), + server.current_version, ], )?; Ok(()) @@ -330,7 +389,10 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { "UPDATE installed_servers SET server_name = ?2, cached_definition = ?3, input_values = ?4, enabled = ?5, env_overrides = ?6, args_append = ?7, extra_headers = ?8, oauth_connected = ?9, - updated_at = ?10, source = ?11 + updated_at = ?10, source = ?11, cloned_from = ?12, display_name_override = ?13, + default_params = ?14, update_policy = ?15, pinned_version = ?16, + latest_available_version = ?17, version_checked_at = ?18, + default_params_strategy = ?19, current_version = ?20 WHERE id = ?1", params![ server.id.to_string(), @@ -344,6 +406,15 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { server.oauth_connected, Utc::now().to_rfc3339(), Self::serialize_source(&server.source), + server.cloned_from, + server.display_name_override, + Self::serialize_json_value_map(&server.default_params), + server.update_policy.as_db_str(), + server.pinned_version, + server.latest_available_version, + server.version_checked_at.map(|dt| dt.to_rfc3339()), + server.default_params_strategy.as_db_str(), + server.current_version, ], )?; Ok(()) diff --git a/crates/mcpmux-storage/src/repositories/mod.rs b/crates/mcpmux-storage/src/repositories/mod.rs index 9b1947db..ee621408 100644 --- a/crates/mcpmux-storage/src/repositories/mod.rs +++ b/crates/mcpmux-storage/src/repositories/mod.rs @@ -2,6 +2,7 @@ mod app_settings_repository; mod credential_repository; +mod embedding_repository; mod feature_set_repository; mod inbound_client_repository; mod inbound_mcp_client_repository; @@ -11,10 +12,12 @@ mod server_feature_repository; mod space_base_dir_repository; mod space_builtin_config_repository; mod space_repository; +mod workspace_appearance_repository; mod workspace_binding_repository; pub use app_settings_repository::SqliteAppSettingsRepository; pub use credential_repository::SqliteCredentialRepository; +pub use embedding_repository::{hash_embedding_content, SqliteEmbeddingRepository}; pub use feature_set_repository::SqliteFeatureSetRepository; pub use inbound_client_repository::{ AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, TokenRecord, @@ -29,4 +32,5 @@ pub use server_feature_repository::{ pub use space_base_dir_repository::SqliteSpaceBaseDirRepository; pub use space_builtin_config_repository::SqliteSpaceBuiltinConfigRepository; pub use space_repository::SqliteSpaceRepository; +pub use workspace_appearance_repository::SqliteWorkspaceAppearanceRepository; pub use workspace_binding_repository::SqliteWorkspaceBindingRepository; diff --git a/crates/mcpmux-storage/src/repositories/workspace_appearance_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_appearance_repository.rs new file mode 100644 index 00000000..10cc2c95 --- /dev/null +++ b/crates/mcpmux-storage/src/repositories/workspace_appearance_repository.rs @@ -0,0 +1,137 @@ +//! SQLite implementation of [`WorkspaceAppearanceRepository`]. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mcpmux_core::{WorkspaceAppearance, WorkspaceAppearanceRepository}; +use rusqlite::params; +use tokio::sync::Mutex; + +use crate::Database; + +#[allow(dead_code)] +pub struct SqliteWorkspaceAppearanceRepository { + db: Arc>, +} + +#[allow(dead_code)] +impl SqliteWorkspaceAppearanceRepository { + pub fn new(db: Arc>) -> Self { + Self { db } + } + + fn parse_datetime(s: &str) -> DateTime { + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return dt.and_utc(); + } + Utc::now() + } +} + +#[async_trait] +impl WorkspaceAppearanceRepository for SqliteWorkspaceAppearanceRepository { + async fn list(&self) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let mut stmt = conn.prepare( + "SELECT workspace_root, icon, updated_at + FROM workspace_appearances + ORDER BY workspace_root", + )?; + let rows = stmt.query_map([], |row| { + let updated_at: String = row.get(2)?; + Ok(WorkspaceAppearance { + workspace_root: row.get(0)?, + icon: row.get(1)?, + updated_at: Self::parse_datetime(&updated_at), + }) + })?; + Ok(rows.collect::, _>>()?) + } + + async fn get(&self, workspace_root: &str) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let mut stmt = conn.prepare( + "SELECT workspace_root, icon, updated_at + FROM workspace_appearances + WHERE workspace_root = ?1", + )?; + let mut rows = stmt.query(params![workspace_root])?; + if let Some(row) = rows.next()? { + let updated_at: String = row.get(2)?; + return Ok(Some(WorkspaceAppearance { + workspace_root: row.get(0)?, + icon: row.get(1)?, + updated_at: Self::parse_datetime(&updated_at), + })); + } + Ok(None) + } + + async fn upsert(&self, appearance: &WorkspaceAppearance) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "INSERT INTO workspace_appearances (workspace_root, icon, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(workspace_root) DO UPDATE SET + icon = excluded.icon, + updated_at = excluded.updated_at", + params![ + appearance.workspace_root, + appearance.icon, + appearance.updated_at.to_rfc3339(), + ], + )?; + Ok(()) + } + + async fn delete(&self, workspace_root: &str) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "DELETE FROM workspace_appearances WHERE workspace_root = ?1", + params![workspace_root], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcpmux_core::normalize_workspace_root; + + #[tokio::test] + async fn test_upsert_get_delete_round_trip() { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let repo = SqliteWorkspaceAppearanceRepository::new(db); + let normalized_root = normalize_workspace_root("file:///home/user/my%20project"); + + let mut created = WorkspaceAppearance::new(normalized_root.clone(), "📁"); + repo.upsert(&created).await.unwrap(); + + let fetched = repo.get(&normalized_root).await.unwrap().unwrap(); + assert_eq!(fetched.workspace_root, normalized_root); + assert_eq!(fetched.icon, "📁"); + + created.icon = "🧪".to_string(); + created.updated_at = Utc::now(); + repo.upsert(&created).await.unwrap(); + let updated = repo.get(&created.workspace_root).await.unwrap().unwrap(); + assert_eq!(updated.icon, "🧪"); + + let listed = repo.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].workspace_root, created.workspace_root); + + repo.delete(&created.workspace_root).await.unwrap(); + assert!(repo.get(&created.workspace_root).await.unwrap().is_none()); + } +} diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index 25f07268..7c627f98 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -67,10 +67,14 @@ impl SqliteWorkspaceBindingRepository { let space_id_str: String = row.get(2)?; let created_at: String = row.get(3)?; let updated_at: String = row.get(4)?; + let client_id: Option = row.get(5)?; + let label: Option = row.get(6)?; Ok(WorkspaceBinding { id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), workspace_root, + client_id, + label, space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::nil()), feature_set_ids: Vec::new(), // filled in by caller created_at: Self::parse_datetime(&created_at), @@ -149,7 +153,8 @@ impl SqliteWorkspaceBindingRepository { Ok(()) } - const SELECT_COLS: &'static str = "id, workspace_root, space_id, created_at, updated_at"; + const SELECT_COLS: &'static str = + "id, workspace_root, space_id, created_at, updated_at, client_id, label"; /// Fetch bindings + their FeatureSet lists in two queries. /// `where_clause` is appended to the binding SELECT (use `""` for none); @@ -221,14 +226,16 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let tx = conn.unchecked_transaction()?; tx.execute( "INSERT INTO workspace_bindings - (id, workspace_root, space_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + (id, workspace_root, space_id, created_at, updated_at, client_id, label) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), binding.created_at.to_rfc3339(), binding.updated_at.to_rfc3339(), + binding.client_id, + binding.label, ], )?; Self::rewrite_fs_for_binding(&tx, &binding.id.to_string(), &binding.feature_set_ids)?; @@ -248,13 +255,15 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let tx = conn.unchecked_transaction()?; let rows_affected = tx.execute( "UPDATE workspace_bindings - SET workspace_root = ?2, space_id = ?3, updated_at = ?4 + SET workspace_root = ?2, space_id = ?3, updated_at = ?4, client_id = ?5, label = ?6 WHERE id = ?1", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), binding.updated_at.to_rfc3339(), + binding.client_id, + binding.label, ], )?; diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index 57ab83b8..d5c8f1e5 100644 --- a/tests/rust/src/mocks.rs +++ b/tests/rust/src/mocks.rs @@ -432,6 +432,7 @@ impl FeatureSetRepository for MockFeatureSetRepository { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode, + surfaced: false, }; self.members .write() diff --git a/tests/rust/tests/integration/effective_features.rs b/tests/rust/tests/integration/effective_features.rs index 7a2e3468..217235ee 100644 --- a/tests/rust/tests/integration/effective_features.rs +++ b/tests/rust/tests/integration/effective_features.rs @@ -367,6 +367,7 @@ async fn composition_cycle_terminates_and_returns_union() { member_type: mtype, member_id: mid, mode: MemberMode::Include, + surfaced: false, }; // X ⊇ {gh_issue (feature), Y (featureset)} diff --git a/tests/rust/tests/integration/mcp_flows.rs b/tests/rust/tests/integration/mcp_flows.rs index f6e041d3..b7df26ed 100644 --- a/tests/rust/tests/integration/mcp_flows.rs +++ b/tests/rust/tests/integration/mcp_flows.rs @@ -104,6 +104,7 @@ impl TestContext { member_type: MemberType::Feature, member_id: feature.id.to_string(), mode: MemberMode::Include, + surfaced: false, }); } fs @@ -128,6 +129,7 @@ impl TestContext { member_type: MemberType::Feature, member_id: feature.id.to_string(), mode: MemberMode::Include, + surfaced: false, }); } fs @@ -187,6 +189,7 @@ async fn test_list_tools_with_restricted_grant() { member_id: tool_a_id.to_string(), member_type: MemberType::Feature, mode: MemberMode::Include, + surfaced: false, }); let custom_fs_id = ctx.add_feature_set(custom_fs).await; @@ -566,6 +569,7 @@ async fn test_features_dont_leak_between_spaces() { member_type: MemberType::Feature, member_id: work_tool.id.to_string(), mode: MemberMode::Include, + surfaced: false, }); let work_all_id = work_all.id.clone(); feature_set_repo.create(&work_all).await.unwrap(); From e6fc66fd55b450050b5c467f38d345390a3e8fc1 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 19:27:43 -0600 Subject: [PATCH 003/148] =?UTF-8?q?feat(port):=20Phase=203=20=E2=80=94=20W?= =?UTF-8?q?eb=20admin=20server=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the web admin HTTP server stack from the i18n branch to main, reconciled with Phase 1–2 divergences. Autonomous decisions: - Stubbed Phase 5/6/7 features (version probing, clone_server, display name override, public URL persistence, workspace icon upload via the `image` crate) with descriptive errors and ponytail comments — avoids pulling in unported dependencies while preserving the API surface. - Replaced `WorkspaceBinding::new_scoped_multi` (Phase 6) with `new_multi` + manual client_id assignment using the existing API. - Replaced `find_longest_prefix_match` (not yet added to the repo trait) with an inline prefix-scan over `list_for_space`. - Used `option_env!` for MCPMUX_BUILD_* env vars so `cargo check` works outside CI without those variables set. - Added admin settings keys and methods to `AppSettingsService` in mcpmux-core (get/set admin_enabled, admin_port, trust_cf_access, cf_team_domain) — minimal extension, no breaking changes. - Added `space_repository()` accessor to `SpaceService` to avoid exposing the private `repository` field. - Added `test-utils` feature flag to mcpmux-gateway Cargo.toml, used by the ported `#[cfg(feature = "test-utils")]` test helpers. - Wired admin server startup into lib.rs setup closure; registered `reload_admin_server` Tauri command; integrated `emit_ui_channel` into the gateway domain-event bridge for SSE fan-in. Signed-off-by: crimsonsunset --- Cargo.lock | 127 ++- .../desktop/src-tauri/src/commands/gateway.rs | 75 +- apps/desktop/src-tauri/src/lib.rs | 28 +- .../src-tauri/src/services/admin_server.rs | 622 ++++++++++++ .../src/services/admin_write_runtime.rs | 310 ++++++ apps/desktop/src-tauri/src/services/mod.rs | 4 + .../src-tauri/src/services/ui_events.rs | 20 + apps/desktop/src-tauri/src/state/mod.rs | 37 +- .../src/service/app_settings_service.rs | 79 ++ .../mcpmux-core/src/service/space_service.rs | 5 + crates/mcpmux-gateway/Cargo.toml | 7 +- .../src/admin/bridge_context.rs | 53 + .../src/admin/command_bridge/mod.rs | 9 + .../src/admin/command_bridge/oauth.rs | 40 + .../src/admin/command_bridge/read.rs | 789 +++++++++++++++ .../src/admin/command_bridge/space.rs | 171 ++++ .../src/admin/command_bridge/write.rs | 957 ++++++++++++++++++ crates/mcpmux-gateway/src/admin/config.rs | 72 ++ crates/mcpmux-gateway/src/admin/event_hub.rs | 152 +++ .../src/admin/handlers/error.rs | 53 + .../src/admin/handlers/events.rs | 83 ++ .../src/admin/handlers/health.rs | 24 + .../mcpmux-gateway/src/admin/handlers/mod.rs | 11 + .../src/admin/handlers/oauth.rs | 68 ++ .../mcpmux-gateway/src/admin/handlers/read.rs | 609 +++++++++++ .../mcpmux-gateway/src/admin/handlers/spa.rs | 34 + .../src/admin/handlers/write.rs | 580 +++++++++++ .../mcpmux-gateway/src/admin/live_runtime.rs | 215 ++++ .../src/admin/middleware/cf_access.rs | 388 +++++++ .../src/admin/middleware/csrf.rs | 85 ++ .../src/admin/middleware/mod.rs | 11 + crates/mcpmux-gateway/src/admin/mod.rs | 40 + crates/mcpmux-gateway/src/admin/router.rs | 379 +++++++ crates/mcpmux-gateway/src/admin/runtime.rs | 102 ++ crates/mcpmux-gateway/src/admin/server.rs | 117 +++ crates/mcpmux-gateway/src/admin/ui_events.rs | 414 ++++++++ .../mcpmux-gateway/src/admin/write_runtime.rs | 444 ++++++++ crates/mcpmux-gateway/src/lib.rs | 4 + crates/mcpmux-gateway/src/public_base_url.rs | 217 ++++ .../mcpmux-gateway/src/server/dependencies.rs | 20 +- tests/fixtures/README.md | 6 + 41 files changed, 7423 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src-tauri/src/services/admin_server.rs create mode 100644 apps/desktop/src-tauri/src/services/admin_write_runtime.rs create mode 100644 apps/desktop/src-tauri/src/services/ui_events.rs create mode 100644 crates/mcpmux-gateway/src/admin/bridge_context.rs create mode 100644 crates/mcpmux-gateway/src/admin/command_bridge/mod.rs create mode 100644 crates/mcpmux-gateway/src/admin/command_bridge/oauth.rs create mode 100644 crates/mcpmux-gateway/src/admin/command_bridge/read.rs create mode 100644 crates/mcpmux-gateway/src/admin/command_bridge/space.rs create mode 100644 crates/mcpmux-gateway/src/admin/command_bridge/write.rs create mode 100644 crates/mcpmux-gateway/src/admin/config.rs create mode 100644 crates/mcpmux-gateway/src/admin/event_hub.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/error.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/events.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/health.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/mod.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/oauth.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/read.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/spa.rs create mode 100644 crates/mcpmux-gateway/src/admin/handlers/write.rs create mode 100644 crates/mcpmux-gateway/src/admin/live_runtime.rs create mode 100644 crates/mcpmux-gateway/src/admin/middleware/cf_access.rs create mode 100644 crates/mcpmux-gateway/src/admin/middleware/csrf.rs create mode 100644 crates/mcpmux-gateway/src/admin/middleware/mod.rs create mode 100644 crates/mcpmux-gateway/src/admin/mod.rs create mode 100644 crates/mcpmux-gateway/src/admin/router.rs create mode 100644 crates/mcpmux-gateway/src/admin/runtime.rs create mode 100644 crates/mcpmux-gateway/src/admin/server.rs create mode 100644 crates/mcpmux-gateway/src/admin/ui_events.rs create mode 100644 crates/mcpmux-gateway/src/admin/write_runtime.rs create mode 100644 crates/mcpmux-gateway/src/public_base_url.rs create mode 100644 tests/fixtures/README.md diff --git a/Cargo.lock b/Cargo.lock index 4e8bf697..8151338d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -951,11 +951,10 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1059,7 +1058,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1257,7 +1256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1974,6 +1973,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2401,6 +2406,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2707,6 +2727,7 @@ dependencies = [ "hmac", "http", "http-body-util", + "jsonwebtoken", "mcpmux-core", "mcpmux-storage", "oauth2", @@ -2718,6 +2739,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 1.0.69", "tokio", "tokio-util", @@ -2796,6 +2818,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minisign-verify" version = "0.2.4" @@ -2955,14 +2987,33 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] name = "num-traits" @@ -3306,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3389,6 +3440,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4271,7 +4332,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4329,7 +4390,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4745,6 +4806,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5436,7 +5509,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5536,12 +5609,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -5551,15 +5623,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -5782,11 +5854,20 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -5971,6 +6052,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -6401,7 +6488,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 0f66f09b..bd432924 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -12,7 +12,7 @@ use mcpmux_gateway::{ }; use serde::Serialize; use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::RwLock; use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; @@ -442,9 +442,21 @@ pub fn start_domain_event_bridge( "[Gateway] Forwarding domain event to UI" ); - if let Err(e) = app_handle_clone.emit(channel, payload) { - error!("[Gateway] Failed to emit {} event: {}", channel, e); - } + // Emit to Tauri webview and admin SSE subscribers. + let ui_event_bus = { + let admin_state: tauri::State< + '_, + Arc>, + > = app_handle_clone.state(); + let guard = admin_state.read().await; + guard.ui_event_bus.clone() + }; + crate::services::ui_events::emit_ui_channel( + &app_handle_clone, + Some(&ui_event_bus), + channel, + payload, + ); } info!("[Gateway] Domain event bridge stopped"); @@ -1120,6 +1132,24 @@ pub async fn start_gateway( warn!("[Gateway] Failed to emit gateway-changed(started): {}", e); } + // Sync admin server health endpoint and register SSE stream. + { + use crate::services::admin_server::{register_gateway_sse, set_gateway_running}; + let admin_state: tauri::State< + '_, + Arc>, + > = app_handle.state(); + let guard = admin_state.read().await; + set_gateway_running(&guard, true); + if let Some(gw_state) = state.gateway_state.clone() { + let admin_guard_clone = admin_state.clone(); + let gw_state_clone = gw_state; + drop(guard); + let guard2 = admin_guard_clone.read().await; + register_gateway_sse(&guard2, &gw_state_clone).await; + } + } + Ok(url) } @@ -1153,6 +1183,18 @@ pub async fn stop_gateway( warn!("[Gateway] Failed to emit gateway-changed(stopped): {}", e); } + // Sync admin server health endpoint and clear SSE stream. + { + use crate::services::admin_server::{clear_gateway_sse, set_gateway_running}; + let admin_state: tauri::State< + '_, + Arc>, + > = app_handle.state(); + let guard = admin_state.read().await; + set_gateway_running(&guard, false); + clear_gateway_sse(&guard).await; + } + Ok(()) } @@ -1989,6 +2031,31 @@ pub struct PoolStatsResponse { pub total_space_server_mappings: usize, } +/// Reload (or stop-then-start) the web admin server based on current settings. +/// +/// Call this after the user toggles `gateway.admin_enabled`, changes the admin +/// port, or modifies Cloudflare Access settings so the admin server picks up the +/// new configuration without requiring a full app restart. +#[tauri::command] +pub async fn reload_admin_server( + app_handle: tauri::AppHandle, + gateway_state: State<'_, Arc>>, + server_manager_state: State<'_, Arc>>, +) -> Result<(), String> { + let admin_state: tauri::State<'_, Arc>> = + app_handle.state(); + let event_bus = mcpmux_core::create_shared_event_bus(); + crate::services::admin_server::reload_admin_server( + app_handle.clone(), + admin_state.inner().clone(), + gateway_state.inner().clone(), + server_manager_state.inner().clone(), + event_bus, + ) + .await; + Ok(()) +} + #[cfg(test)] mod public_base_url_tests { use super::{advertised_base_url, normalize_public_base_url}; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cde1a72..88088b90 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -563,8 +563,31 @@ pub fn run() { } }); - app.manage(gateway_state); - app.manage(server_manager_state); + app.manage(gateway_state.clone()); + app.manage(server_manager_state.clone()); + + // Create and manage admin server state, then start admin server + { + let admin_state = Arc::new(tokio::sync::RwLock::new( + services::admin_server::AdminServerState::new(), + )); + let app_handle_for_admin = app.handle().clone(); + let gw_state_for_admin = gateway_state.clone(); + let sm_state_for_admin = server_manager_state.clone(); + let event_bus_for_admin = event_bus.clone(); + let admin_state_clone = admin_state.clone(); + app.manage(admin_state); + tauri::async_runtime::spawn(async move { + services::admin_server::start_admin_server_if_enabled( + app_handle_for_admin, + admin_state_clone, + gw_state_for_admin, + sm_state_for_admin, + event_bus_for_admin, + ) + .await; + }); + } // Start file watcher for user space config files (hot-reload) { @@ -969,6 +992,7 @@ pub fn run() { commands::start_gateway, commands::stop_gateway, commands::restart_gateway, + commands::reload_admin_server, commands::generate_gateway_config, commands::connect_server, commands::disconnect_server, diff --git a/apps/desktop/src-tauri/src/services/admin_server.rs b/apps/desktop/src-tauri/src/services/admin_server.rs new file mode 100644 index 00000000..9e45ee8d --- /dev/null +++ b/apps/desktop/src-tauri/src/services/admin_server.rs @@ -0,0 +1,622 @@ +//! Web admin HTTP server startup (loopback :45819 by default). + +use super::admin_write_runtime::DesktopGatewayWriteRuntime; +use crate::state::AppState; +use crate::{ + commands::{gateway::GatewayAppState, server_manager::ServerManagerState}, + get_bundle_version, +}; +use async_trait::async_trait; +#[cfg(debug_assertions)] +use mcpmux_core::service::app_settings_service::keys; +use mcpmux_core::service::is_port_available; +use mcpmux_core::{AppSettingsService, ApplicationServices, EventBus}; +use mcpmux_gateway::admin::event_hub::AdminEventHub; +use mcpmux_gateway::admin::runtime::GatewayRuntime; +use mcpmux_gateway::admin::ui_events::AdminUiEventBus; +use mcpmux_gateway::admin::{AdminBridgeCtx, BackendBuildStamp}; +use mcpmux_gateway::pool::ConnectionStatus as GatewayConnectionStatus; +use mcpmux_gateway::{AdminConfig, AdminServer, AdminServerHandle}; +use serde::Deserialize; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +/// Tracks the running admin server and shared gateway liveness flag. +pub struct AdminServerState { + /// Background task handle for graceful shutdown. + pub handle: Option, + /// Updated when the MCP gateway starts or stops (admin `/api/v1/health`). + pub gateway_running: Arc, + /// Direct UI events from Tauri `app.emit` paths (oauth, session overrides). + pub ui_event_bus: Arc, + /// Merged SSE fan-in hub (EventBus + gateway domain + direct emits). + pub event_hub: Arc, +} + +impl AdminServerState { + /// Create admin server state with shared event buses for SSE fan-in. + pub fn new() -> Self { + let ui_event_bus = Arc::new(AdminUiEventBus::new()); + let event_hub = Arc::new(AdminEventHub::new(ui_event_bus.clone())); + Self { + handle: None, + gateway_running: Arc::new(AtomicBool::new(false)), + ui_event_bus, + event_hub, + } + } +} + +impl Default for AdminServerState { + fn default() -> Self { + Self::new() + } +} + +/// Whether the admin HTTP server should start (settings + dev overrides). +async fn resolve_admin_enabled_for_startup(app_state: &AppState) -> bool { + if std::env::var("MCPMUX_DEV_DISABLE_ADMIN").as_deref() == Ok("1") { + info!("[Admin] Skipped (MCPMUX_DEV_DISABLE_ADMIN=1)"); + return false; + } + + let settings = AppSettingsService::new(app_state.settings_repository.clone()); + + #[cfg(debug_assertions)] + { + if std::env::var("MCPMUX_DEV_ADMIN").as_deref() == Ok("1") { + info!("[Admin] Enabled for this dev session (MCPMUX_DEV_ADMIN=1)"); + return true; + } + + match mcpmux_core::AppSettingsRepository::get( + app_state.settings_repository.as_ref(), + keys::gateway::ADMIN_ENABLED, + ) + .await + { + Ok(None) => { + if let Err(e) = settings.set_admin_enabled(true).await { + warn!("[Admin] Failed to persist dev default admin_enabled: {}", e); + } else { + info!("[Admin] Dev default: enabled web admin (setting was unset)"); + } + return true; + } + Ok(Some(_)) => {} + Err(e) => warn!("[Admin] Could not read admin_enabled: {}", e), + } + } + + settings.get_admin_enabled().await +} + +/// Resolve the built frontend directory for static SPA serving. +pub fn resolve_frontend_dist(app: &AppHandle) -> PathBuf { + if let Ok(resource) = app.path().resource_dir() { + let dist = resource.join("dist"); + if dist.join("index.html").is_file() { + return dist; + } + } + + let dev_dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist"); + if dev_dist.join("index.html").is_file() { + return dev_dist; + } + + dev_dist +} + +/// Build `ApplicationServices` for admin handlers from desktop `AppState`. +fn build_application_services( + app_state: &AppState, + event_bus: Arc, +) -> anyhow::Result> { + Ok(Arc::new(app_state.build_application_services(event_bus)?)) +} + +struct DesktopGatewayRuntime { + gateway_port_service: Arc, + gateway_state: Arc>, + server_manager_state: Arc>, +} + +impl DesktopGatewayRuntime { + fn new( + gateway_port_service: Arc, + gateway_state: Arc>, + server_manager_state: Arc>, + ) -> Self { + Self { + gateway_port_service, + gateway_state, + server_manager_state, + } + } +} + +#[async_trait] +impl GatewayRuntime for DesktopGatewayRuntime { + async fn get_gateway_status( + &self, + space_id: Option, + ) -> anyhow::Result { + let state = self.gateway_state.read().await; + let active_sessions = if let Some(ref gateway_state) = state.gateway_state { + gateway_state.read().await.sessions.len() + } else { + 0 + }; + + let connected_backends = { + let manager_state = self.server_manager_state.read().await; + if let Some(ref manager) = manager_state.manager { + if let Some(space_id) = space_id { + let space_id = uuid::Uuid::parse_str(&space_id)?; + manager.connected_count_for_space(&space_id).await + } else { + manager.connected_count().await + } + } else { + 0 + } + }; + + Ok(json!({ + "running": state.running, + "url": state.url, + "active_sessions": active_sessions, + "connected_backends": connected_backends, + })) + } + + async fn probe_gateway_start(&self, port: Option) -> anyhow::Result { + let (preferred_port, source) = if let Some(port) = port { + (port, "override") + } else if let Some(port) = self.gateway_port_service.load_persisted_port().await { + (port, "configured") + } else { + (mcpmux_core::DEFAULT_GATEWAY_PORT, "default") + }; + Ok(json!({ + "preferredPort": preferred_port, + "preferredAvailable": is_port_available(preferred_port), + "source": source, + })) + } + + async fn take_pending_port_conflict(&self) -> anyhow::Result { + let mut state = self.gateway_state.write().await; + Ok(state + .pending_port_conflict + .take() + .map(|conflict| { + json!({ + "preferredPort": conflict.preferred_port, + "source": conflict.source, + }) + }) + .unwrap_or(serde_json::Value::Null)) + } + + async fn get_gateway_port_settings(&self) -> anyhow::Result { + let configured_port = self.gateway_port_service.load_persisted_port().await; + let active_port = { + let state = self.gateway_state.read().await; + state + .url + .as_deref() + .and_then(|url| url.split("://").nth(1)) + .and_then(|host_port| host_port.split('/').next()) + .and_then(|host_port| host_port.rsplit(':').next()) + .and_then(|port| port.parse::().ok()) + }; + Ok(json!({ + "configuredPort": configured_port, + "defaultPort": mcpmux_core::DEFAULT_GATEWAY_PORT, + "activePort": active_port, + })) + } + + async fn reset_gateway_port(&self) -> anyhow::Result { + self.gateway_port_service.clear_persisted_port().await?; + Ok(json!({ "ok": true })) + } + + async fn list_connected_servers(&self) -> anyhow::Result { + Ok(json!([])) + } + + async fn get_pool_stats(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + let stats = match &state.pool_service { + Some(pool) => pool.stats(), + None => mcpmux_gateway::PoolStats::default(), + }; + Ok(json!({ + "total_instances": stats.total_instances, + "connected_instances": stats.connected_instances, + "total_space_server_mappings": stats.connecting_instances + stats.failed_instances + stats.oauth_pending_instances, + })) + } + + async fn list_reported_workspace_roots(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + Ok(json!(state + .session_roots + .as_ref() + .map(|registry| registry.list_all_roots()) + .unwrap_or_default())) + } + + async fn list_meta_tool_grants(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + let Some(ref broker) = state.approval_broker else { + return Ok(json!([])); + }; + Ok(json!(broker + .list_always_allow() + .into_iter() + .map(|(client_id, tool_name)| json!({ + "client_id": client_id, + "tool_name": tool_name, + })) + .collect::>())) + } + + async fn get_oauth_clients(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + let Some(ref gateway_state) = state.gateway_state else { + return Err(anyhow::anyhow!("Gateway not running")); + }; + let gateway_state = gateway_state.read().await; + let Some(repository) = gateway_state.inbound_client_repository() else { + return Err(anyhow::anyhow!("Database not available")); + }; + let clients = repository.list_clients().await?; + let approved = clients + .into_iter() + .filter(|client| client.approved) + .map(|client| { + json!({ + "client_id": client.client_id, + "registration_type": client.registration_type.as_str(), + "client_name": client.client_name, + "client_alias": client.client_alias, + "redirect_uris": client.redirect_uris, + "scope": client.scope, + "approved": client.approved, + "logo_uri": client.logo_uri, + "client_uri": client.client_uri, + "software_id": client.software_id, + "software_version": client.software_version, + "metadata_url": client.metadata_url, + "metadata_cached_at": client.metadata_cached_at, + "metadata_cache_ttl": client.metadata_cache_ttl, + "last_seen": client.last_seen, + "created_at": client.created_at, + "reports_roots": client.reports_roots, + "roots_capability_known": client.roots_capability_known, + }) + }) + .collect::>(); + Ok(json!(approved)) + } + + async fn get_oauth_client_grants( + &self, + client_id: String, + space_id: String, + ) -> anyhow::Result { + let state = self.gateway_state.read().await; + let Some(ref grant_service) = state.grant_service else { + return Err(anyhow::anyhow!("Gateway not running")); + }; + Ok(json!( + grant_service + .get_grants_for_space(&client_id, &space_id) + .await? + )) + } + + async fn get_server_statuses(&self, space_id: String) -> anyhow::Result { + let space_uuid = uuid::Uuid::parse_str(&space_id) + .map_err(|e| anyhow::anyhow!("Invalid space_id: {e}"))?; + + let manager_state = self.server_manager_state.read().await; + let Some(ref manager) = manager_state.manager else { + return Err(anyhow::anyhow!("ServerManager not initialized")); + }; + + let statuses = manager.get_all_statuses(space_uuid).await; + let mut result = serde_json::Map::new(); + for (server_id, (status, flow_id, has_connected_before, message)) in statuses { + result.insert( + server_id.clone(), + json!({ + "server_id": server_id, + "status": gateway_status_to_ui(status), + "flow_id": flow_id, + "has_connected_before": has_connected_before, + "message": message, + }), + ); + } + Ok(json!(result)) + } +} + +/// Map gateway pool status to the UI-facing string (`oauth_required`, not `auth_required`). +fn gateway_status_to_ui(status: GatewayConnectionStatus) -> &'static str { + match status { + GatewayConnectionStatus::Disconnected => "disconnected", + GatewayConnectionStatus::Connecting => "connecting", + GatewayConnectionStatus::Connected => "connected", + GatewayConnectionStatus::Refreshing => "refreshing", + GatewayConnectionStatus::AuthRequired => "oauth_required", + GatewayConnectionStatus::Authenticating => "authenticating", + GatewayConnectionStatus::Error => "error", + } +} + +/// Stop the web admin server if it is running. +pub async fn stop_admin_server(admin_state: &Arc>) { + let handle = { + let mut guard = admin_state.write().await; + guard.handle.take() + }; + if let Some(handle) = handle { + handle.shutdown(); + if let Err(e) = handle.task.await { + warn!("[Admin] Admin server task join error: {:?}", e); + } + info!("[Admin] Stopped"); + } +} + +/// Apply current settings: stop any running admin server, then start if enabled. +pub async fn reload_admin_server( + app: AppHandle, + admin_state: Arc>, + gateway_state: Arc>, + server_manager_state: Arc>, + event_bus: Arc, +) { + stop_admin_server(&admin_state).await; + start_admin_server_if_enabled( + app, + admin_state, + gateway_state, + server_manager_state, + event_bus, + ) + .await; +} + +/// Start the admin server when `gateway.admin_enabled` is true. +pub async fn start_admin_server_if_enabled( + app: AppHandle, + admin_state: Arc>, + gateway_state: Arc>, + server_manager_state: Arc>, + event_bus: Arc, +) { + let app_state: tauri::State<'_, AppState> = app.state(); + let settings = AppSettingsService::new(app_state.settings_repository.clone()); + if !resolve_admin_enabled_for_startup(app_state.inner()).await { + info!("[Admin] Web admin disabled (gateway.admin_enabled=false)"); + return; + } + + let port = settings + .get_admin_port() + .await + .unwrap_or(mcpmux_gateway::DEFAULT_ADMIN_PORT); + let trust_cf_access = settings.get_admin_trust_cf_access().await; + let cf_team_domain = settings.get_admin_cf_team_domain().await; + + let config = AdminConfig { + host: "127.0.0.1".to_string(), + port, + trust_cf_access, + cf_team_domain, + cf_access_audience: None, + cf_validator_override: None, + }; + + let gateway_running = { + let guard = admin_state.read().await; + guard.gateway_running.clone() + }; + let event_hub = { + let guard = admin_state.read().await; + guard.event_hub.clone() + }; + + let services = match build_application_services(&app_state, event_bus) { + Ok(s) => s, + Err(e) => { + warn!("[Admin] Failed to build ApplicationServices: {}", e); + return; + } + }; + + let cf_validator = match AdminServer::build_cf_validator(&config).await { + Ok(v) => v, + Err(e) => { + warn!("[Admin] CF Access validator init failed: {}", e); + return; + } + }; + + let frontend_dist = resolve_frontend_dist(&app); + let dist_ready = frontend_dist.join("index.html").is_file(); + let auto_launch_enabled = app + .try_state::() + .and_then(|manager| manager.is_enabled().ok()); + let gateway_runtime = Arc::new(DesktopGatewayRuntime::new( + app_state.gateway_port_service.clone(), + gateway_state.clone(), + server_manager_state.clone(), + )); + let gateway_writes = Arc::new(DesktopGatewayWriteRuntime::new( + app.clone(), + gateway_state.clone(), + )); + let bridge = Arc::new(AdminBridgeCtx { + services: services.clone(), + spaces_dir: app_state.spaces_dir().to_path_buf(), + data_dir: app_state.data_dir().to_path_buf(), + gateway_port_service: app_state.gateway_port_service.clone(), + server_discovery: app_state.server_discovery.clone(), + settings_repository: app_state.settings_repository.clone(), + workspace_binding_repository: app_state.workspace_binding_repository.clone(), + workspace_appearance_repository: app_state.workspace_appearance_repository.clone(), + server_feature_repository: app_state.server_feature_repository_core.clone(), + server_log_manager: app_state.server_log_manager.clone(), + space_service: Arc::new(mcpmux_core::SpaceService::new( + app_state.space_service.space_repository(), + )), + gateway_runtime, + gateway_writes, + feature_set_repository: app_state.feature_set_repository.clone(), + auto_launch_enabled, + app_version: env!("CARGO_PKG_VERSION").to_string(), + bundle_version: get_bundle_version(), + backend_build: BackendBuildStamp { + git_sha: option_env!("MCPMUX_BUILD_GIT_SHA") + .unwrap_or("dev") + .to_string(), + git_branch: option_env!("MCPMUX_BUILD_GIT_BRANCH") + .unwrap_or("dev") + .to_string(), + commit_time: option_env!("MCPMUX_BUILD_COMMIT_TIME") + .unwrap_or("") + .to_string(), + build_time: option_env!("MCPMUX_BUILD_TIME").unwrap_or("").to_string(), + }, + }); + let backend_git_sha = option_env!("MCPMUX_BUILD_GIT_SHA") + .unwrap_or("dev") + .to_string(); + let frontend_dist_log = frontend_dist.clone(); + let server = match AdminServer::new( + config.clone(), + services, + bridge, + event_hub, + gateway_running, + frontend_dist, + cf_validator, + ) + .await + { + Ok(s) => s, + Err(e) => { + warn!("[Admin] Failed to build admin server: {}", e); + return; + } + }; + + let handle = server.spawn(); + info!( + "[Admin] Started on http://{}:{} (cf_access={}, static_spa={})", + config.host, config.port, config.trust_cf_access, dist_ready + ); + info!( + "[Admin] Backend | sha: {} | branch: {} | committed: {} | built: {}", + backend_git_sha, + option_env!("MCPMUX_BUILD_GIT_BRANCH").unwrap_or("dev"), + option_env!("MCPMUX_BUILD_COMMIT_TIME").unwrap_or(""), + option_env!("MCPMUX_BUILD_TIME").unwrap_or(""), + ); + if dist_ready { + log_spa_build_stamp(&frontend_dist_log, &backend_git_sha); + } + info!( + "[Admin] Dev HMR UI: http://127.0.0.1:1420 (Vite proxies /api → :{}) — run pnpm dev:web:admin or pnpm dev:admin", + config.port + ); + if !dist_ready { + info!( + "[Admin] Production-parity UI: run `pnpm build:web:admin` then open http://127.0.0.1:{}/", + config.port + ); + } + + let mut guard = admin_state.write().await; + guard.handle = Some(handle); +} + +/// SPA build metadata written by `pnpm build:web:admin` into `dist/build-stamp.json`. +#[derive(Debug, Deserialize)] +struct SpaBuildStamp { + git_sha: String, + git_branch: String, + commit_time: String, + #[serde(default)] + commit_at: String, + build_time: String, + #[serde(default)] + build_at: String, +} + +/// Log SPA bundle stamp and warn when it diverges from the running backend binary. +fn log_spa_build_stamp(dist: &Path, backend_sha: &str) { + let path = dist.join("build-stamp.json"); + let Ok(contents) = fs::read_to_string(&path) else { + return; + }; + let Ok(stamp) = serde_json::from_str::(&contents) else { + warn!("[Admin] SPA bundle build-stamp.json is invalid or unreadable"); + return; + }; + let committed = if stamp.commit_at.is_empty() { + stamp.commit_time.as_str() + } else { + stamp.commit_at.as_str() + }; + let built = if stamp.build_at.is_empty() { + stamp.build_time.as_str() + } else { + stamp.build_at.as_str() + }; + info!( + "[Admin] SPA bundle | sha: {} | branch: {} | committed: {} | built: {}", + stamp.git_sha, stamp.git_branch, committed, built + ); + if !backend_sha.is_empty() && stamp.git_sha != backend_sha { + warn!( + "[Admin] SPA bundle sha {} != backend sha {} — run `pnpm build:web:admin`", + stamp.git_sha, backend_sha + ); + } +} + +/// Sync gateway liveness into the admin health endpoint. +pub fn set_gateway_running(admin_state: &AdminServerState, running: bool) { + admin_state + .gateway_running + .store(running, Ordering::Relaxed); +} + +/// Register gateway domain events with the admin SSE hub. +pub async fn register_gateway_sse( + admin_state: &AdminServerState, + gateway_state: &Arc>, +) { + let tx = gateway_state.read().await.domain_event_sender(); + admin_state.event_hub.register_gateway_events(tx).await; +} + +/// Clear gateway domain event fan-in when the MCP gateway stops. +pub async fn clear_gateway_sse(admin_state: &AdminServerState) { + admin_state.event_hub.clear_gateway_events().await; +} diff --git a/apps/desktop/src-tauri/src/services/admin_write_runtime.rs b/apps/desktop/src-tauri/src/services/admin_write_runtime.rs new file mode 100644 index 00000000..4fd205bd --- /dev/null +++ b/apps/desktop/src-tauri/src/services/admin_write_runtime.rs @@ -0,0 +1,310 @@ +//! Desktop implementation of admin gateway write runtime — delegates to Tauri commands. + +use async_trait::async_trait; +use mcpmux_gateway::admin::write_runtime::GatewayWriteRuntime; +use serde_json::{json, Value}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::RwLock; + +use crate::commands::gateway::{ + connect_all_enabled_servers, disconnect_server, refresh_oauth_tokens_on_startup, + restart_gateway, set_gateway_port, start_gateway, stop_gateway, GatewayAppState, +}; +use crate::commands::meta_tool_approval::{respond_to_meta_tool_approval, revoke_meta_tool_grant}; +use crate::commands::oauth::{ + delete_oauth_client, grant_oauth_client_feature_set, revoke_oauth_client_feature_set, + update_oauth_client, UpdateClientSettingsRequest, +}; +use crate::commands::server_manager::{ + cancel_auth_v2, disable_server_v2, enable_server_v2, logout_server, retry_connection, + start_auth_v2, +}; + +/// Delegates admin write operations to existing Tauri command handlers. +pub struct DesktopGatewayWriteRuntime { + app_handle: AppHandle, + app_gateway_state: Arc>, +} + +impl DesktopGatewayWriteRuntime { + /// Create a write runtime bound to the running Tauri app handle. + pub fn new(app_handle: AppHandle, app_gateway_state: Arc>) -> Self { + Self { + app_handle, + app_gateway_state, + } + } +} + +#[async_trait] +impl GatewayWriteRuntime for DesktopGatewayWriteRuntime { + async fn start_gateway( + &self, + port: Option, + allow_dynamic_fallback: Option, + ) -> anyhow::Result { + let url = start_gateway( + port, + allow_dynamic_fallback, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "url": url })) + } + + async fn stop_gateway(&self) -> anyhow::Result { + stop_gateway( + self.app_handle + .state::>>(), + self.app_handle.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn restart_gateway( + &self, + port: Option, + allow_dynamic_fallback: Option, + ) -> anyhow::Result { + let url = restart_gateway( + port, + allow_dynamic_fallback, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "url": url })) + } + + async fn disconnect_server( + &self, + server_id: String, + space_id: String, + logout: Option, + ) -> anyhow::Result { + disconnect_server( + server_id, + space_id, + logout, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn connect_all_enabled_servers(&self) -> anyhow::Result { + let result = connect_all_enabled_servers(self.app_handle.state(), self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!(result)) + } + + async fn refresh_oauth_tokens_on_startup(&self) -> anyhow::Result { + let result = refresh_oauth_tokens_on_startup(self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!(result)) + } + + async fn set_gateway_port(&self, port: u16) -> anyhow::Result { + set_gateway_port(port, self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn enable_server_v2(&self, space_id: String, server_id: String) -> anyhow::Result { + enable_server_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn disable_server_v2( + &self, + space_id: String, + server_id: String, + ) -> anyhow::Result { + disable_server_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn start_auth_v2(&self, space_id: String, server_id: String) -> anyhow::Result { + start_auth_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn cancel_auth_v2(&self, space_id: String, server_id: String) -> anyhow::Result { + cancel_auth_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn retry_connection(&self, space_id: String, server_id: String) -> anyhow::Result { + retry_connection( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn update_server_package( + &self, + _space_id: String, + _server_id: String, + ) -> anyhow::Result { + // ponytail: Tauri update_server_package command lands in Phase 5 + Err(anyhow::anyhow!("Server package update not yet available")) + } + + async fn logout_server(&self, space_id: String, server_id: String) -> anyhow::Result { + logout_server( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn respond_to_meta_tool_approval( + &self, + request_id: String, + client_id: String, + tool_name: String, + decision: String, + ) -> anyhow::Result { + let approved = respond_to_meta_tool_approval( + request_id, + client_id, + tool_name, + decision, + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "approved": approved })) + } + + async fn revoke_meta_tool_grant( + &self, + client_id: String, + tool_name: String, + ) -> anyhow::Result { + let revoked = revoke_meta_tool_grant(client_id, tool_name, self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "revoked": revoked })) + } + + async fn update_oauth_client( + &self, + client_id: String, + client_alias: Option, + ) -> anyhow::Result { + let client = update_oauth_client( + self.app_handle.state(), + client_id, + UpdateClientSettingsRequest { client_alias }, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!(client)) + } + + async fn delete_oauth_client(&self, client_id: String) -> anyhow::Result { + delete_oauth_client(self.app_handle.state(), client_id) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn grant_oauth_client_feature_set( + &self, + client_id: String, + space_id: String, + feature_set_id: String, + ) -> anyhow::Result { + grant_oauth_client_feature_set( + self.app_handle.clone(), + self.app_handle.state(), + client_id, + space_id, + feature_set_id, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn revoke_oauth_client_feature_set( + &self, + client_id: String, + space_id: String, + feature_set_id: String, + ) -> anyhow::Result { + revoke_oauth_client_feature_set( + self.app_handle.clone(), + self.app_handle.state(), + client_id, + space_id, + feature_set_id, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn gateway_state(&self) -> Option>> { + let app_state = self.app_gateway_state.read().await; + app_state.gateway_state.clone() + } +} diff --git a/apps/desktop/src-tauri/src/services/mod.rs b/apps/desktop/src-tauri/src/services/mod.rs index 7d3bb92d..a45dcaed 100644 --- a/apps/desktop/src-tauri/src/services/mod.rs +++ b/apps/desktop/src-tauri/src/services/mod.rs @@ -2,6 +2,10 @@ //! //! Background services for the desktop application. +pub mod admin_server; +pub mod admin_write_runtime; pub mod file_watcher; +pub mod ui_events; +pub use admin_server::AdminServerState; pub use file_watcher::SpaceFileWatcher; diff --git a/apps/desktop/src-tauri/src/services/ui_events.rs b/apps/desktop/src-tauri/src/services/ui_events.rs new file mode 100644 index 00000000..f93a63a6 --- /dev/null +++ b/apps/desktop/src-tauri/src/services/ui_events.rs @@ -0,0 +1,20 @@ +//! Shared desktop UI event emission (Tauri + admin SSE fan-in). + +use mcpmux_gateway::admin::ui_events::AdminUiEventBus; +use serde_json::Value; +use tauri::{AppHandle, Emitter}; + +/// Emit a UI channel event to the Tauri webview and admin SSE subscribers. +pub fn emit_ui_channel( + app: &AppHandle, + ui_event_bus: Option<&AdminUiEventBus>, + channel: &str, + payload: Value, +) { + if let Err(e) = app.emit(channel, payload.clone()) { + tracing::warn!("[UI] Failed to emit {channel}: {e}"); + } + if let Some(bus) = ui_event_bus { + bus.publish(channel, payload); + } +} diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index 78050b78..99916469 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -4,18 +4,19 @@ //! between Tauri commands. use mcpmux_core::{ - AppSettingsRepository, AppSettingsService, CredentialRepository, FeatureSetRepository, - GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, - OutboundOAuthRepository, ServerDiscoveryService, - ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, - SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, - WorkspaceBindingRepository, + AppSettingsRepository, AppSettingsService, ApplicationServices, ApplicationServicesBuilder, + CredentialRepository, EventBus, FeatureSetRepository, GatewayPortService, + InboundMcpClientRepository, InstalledServerRepository, LogConfig, OutboundOAuthRepository, + ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, + ServerLogManager, SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, + SpaceService, WorkspaceAppearanceRepository, WorkspaceBindingRepository, }; use mcpmux_storage::{ Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, - SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceAppearanceRepository, + SqliteWorkspaceBindingRepository, }; use std::path::PathBuf; use std::sync::Arc; @@ -54,6 +55,8 @@ pub struct AppState { pub space_base_dir_repository: Arc, /// Per-Space built-in server config (Tool Optimization enablement + tool toggles) pub space_builtin_config_repository: Arc, + /// Workspace appearances (icons, theme accent colours) + pub workspace_appearance_repository: Arc, /// Server feature repository for discovered MCP features (implements core trait) pub server_feature_repository: Arc, /// Server feature repository cast to core trait (for gateway services) @@ -118,6 +121,9 @@ impl AppState { let space_builtin_config_repository: Arc = Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone())); + let workspace_appearance_repository: Arc = + Arc::new(SqliteWorkspaceAppearanceRepository::new(db.clone())); + let server_feature_repository = Arc::new(SqliteServerFeatureRepository::new(db.clone())); let server_feature_repository_core: Arc = server_feature_repository.clone(); @@ -177,6 +183,7 @@ impl AppState { workspace_binding_repository, space_base_dir_repository, space_builtin_config_repository, + workspace_appearance_repository, server_feature_repository, server_feature_repository_core, encryptor, @@ -208,4 +215,20 @@ impl AppState { mcpmux_core::get_space_config_path(&self.spaces_dir, space_id) .map_err(|e| format!("Invalid space id '{space_id}': {e}")) } + + /// Build shared `ApplicationServices` for command bridge and admin HTTP. + pub fn build_application_services( + &self, + event_bus: Arc, + ) -> anyhow::Result { + ApplicationServicesBuilder::new() + .with_event_bus(event_bus) + .with_space_repo(self.space_service.space_repository()) + .with_installed_server_repo(self.installed_server_repository.clone()) + .with_feature_set_repo(self.feature_set_repository.clone()) + .with_server_feature_repo(self.server_feature_repository_core.clone()) + .with_client_repo(self.client_repository.clone()) + .with_credential_repo(self.credential_repository.clone()) + .build() + } } diff --git a/crates/mcpmux-core/src/service/app_settings_service.rs b/crates/mcpmux-core/src/service/app_settings_service.rs index 555ddfa7..bcb85ac2 100644 --- a/crates/mcpmux-core/src/service/app_settings_service.rs +++ b/crates/mcpmux-core/src/service/app_settings_service.rs @@ -22,6 +22,14 @@ pub mod keys { pub const PORT: &str = "gateway.port"; /// Auto-start gateway on app launch (bool) pub const AUTO_START: &str = "gateway.auto_start"; + /// Web admin server enabled (bool) + pub const ADMIN_ENABLED: &str = "gateway.admin_enabled"; + /// Web admin server port (u16) + pub const ADMIN_PORT: &str = "gateway.admin_port"; + /// Trust Cloudflare Access JWT (bool) + pub const ADMIN_TRUST_CF_ACCESS: &str = "gateway.admin_trust_cf_access"; + /// Cloudflare team domain for JWT issuer verification + pub const ADMIN_CF_TEAM_DOMAIN: &str = "gateway.admin_cf_team_domain"; } /// OAuth callback settings namespace @@ -190,6 +198,77 @@ impl AppSettingsService { .await } + // ========================================================================= + // Admin server settings + // ========================================================================= + + /// Default admin server port. + pub const DEFAULT_ADMIN_PORT: u16 = 45819; + + /// Get whether the web admin server is enabled (default: false). + pub async fn get_admin_enabled(&self) -> bool { + self.get_string(keys::gateway::ADMIN_ENABLED) + .await + .map(|v| v == "true") + .unwrap_or(false) + } + + /// Set whether the web admin server is enabled. + pub async fn set_admin_enabled(&self, enabled: bool) -> anyhow::Result<()> { + info!("[Settings] Setting admin_enabled to {}", enabled); + self.repository + .set( + keys::gateway::ADMIN_ENABLED, + if enabled { "true" } else { "false" }, + ) + .await + } + + /// Get the configured admin server port (defaults to `DEFAULT_ADMIN_PORT`). + pub async fn get_admin_port(&self) -> Option { + self.get_typed(keys::gateway::ADMIN_PORT).await + } + + /// Set the admin server port. + pub async fn set_admin_port(&self, port: u16) -> anyhow::Result<()> { + info!("[Settings] Setting admin port to {}", port); + self.repository + .set(keys::gateway::ADMIN_PORT, &port.to_string()) + .await + } + + /// Get whether to trust Cloudflare Access JWTs (default: false). + pub async fn get_admin_trust_cf_access(&self) -> bool { + self.get_string(keys::gateway::ADMIN_TRUST_CF_ACCESS) + .await + .map(|v| v == "true") + .unwrap_or(false) + } + + /// Set whether to trust Cloudflare Access JWTs. + pub async fn set_admin_trust_cf_access(&self, trust: bool) -> anyhow::Result<()> { + info!("[Settings] Setting admin_trust_cf_access to {}", trust); + self.repository + .set( + keys::gateway::ADMIN_TRUST_CF_ACCESS, + if trust { "true" } else { "false" }, + ) + .await + } + + /// Get the Cloudflare team domain for admin JWT verification. + pub async fn get_admin_cf_team_domain(&self) -> Option { + self.get_string(keys::gateway::ADMIN_CF_TEAM_DOMAIN).await + } + + /// Set the Cloudflare team domain. + pub async fn set_admin_cf_team_domain(&self, domain: &str) -> anyhow::Result<()> { + info!("[Settings] Setting admin_cf_team_domain"); + self.repository + .set(keys::gateway::ADMIN_CF_TEAM_DOMAIN, domain) + .await + } + // ========================================================================= // OAuth settings // ========================================================================= diff --git a/crates/mcpmux-core/src/service/space_service.rs b/crates/mcpmux-core/src/service/space_service.rs index b5af6926..5110ec94 100644 --- a/crates/mcpmux-core/src/service/space_service.rs +++ b/crates/mcpmux-core/src/service/space_service.rs @@ -33,6 +33,11 @@ impl SpaceService { } } + /// Return a clone of the underlying space repository. + pub fn space_repository(&self) -> Arc { + self.repository.clone() + } + /// List all spaces pub async fn list(&self) -> anyhow::Result> { self.repository.list().await diff --git a/crates/mcpmux-gateway/Cargo.toml b/crates/mcpmux-gateway/Cargo.toml index 2268ae07..4ce4d774 100644 --- a/crates/mcpmux-gateway/Cargo.toml +++ b/crates/mcpmux-gateway/Cargo.toml @@ -17,7 +17,9 @@ async-stream = "0.3" # Web framework axum.workspace = true tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } +tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } +jsonwebtoken = "9" +subtle = "2.6" http = "1.1" http-body-util.workspace = true @@ -65,3 +67,6 @@ mcpmux-storage.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } + +[features] +test-utils = [] diff --git a/crates/mcpmux-gateway/src/admin/bridge_context.rs b/crates/mcpmux-gateway/src/admin/bridge_context.rs new file mode 100644 index 00000000..7cb5afe0 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/bridge_context.rs @@ -0,0 +1,53 @@ +//! Shared dependency context for admin command bridges. + +use std::path::PathBuf; +use std::sync::Arc; + +use mcpmux_core::{ + AppSettingsRepository, ApplicationServices, FeatureSetRepository, GatewayPortService, + ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceService, + WorkspaceAppearanceRepository, WorkspaceBindingRepository, +}; + +use super::runtime::GatewayRuntime; +use super::write_runtime::GatewayWriteRuntime; + +/// Git/build metadata compiled into the desktop binary at build time. +#[derive(Clone, Debug, Default)] +pub struct BackendBuildStamp { + pub git_sha: String, + pub git_branch: String, + pub commit_time: String, + pub build_time: String, +} + +/// Shared dependency graph used by admin bridge functions. +/// +/// This mirrors the desktop `AppState` dependency surface so handlers can stay +/// thin and bridge modules can be reused across transports. +#[derive(Clone)] +pub struct AdminBridgeCtx { + pub services: Arc, + pub spaces_dir: PathBuf, + pub data_dir: PathBuf, + pub gateway_port_service: Arc, + pub server_discovery: Arc, + pub settings_repository: Arc, + pub workspace_binding_repository: Arc, + pub workspace_appearance_repository: Arc, + pub server_feature_repository: Arc, + pub server_log_manager: Arc, + pub space_service: Arc, + pub gateway_runtime: Arc, + /// Gateway-dependent write operations (start/stop, server connections, OAuth grants). + pub gateway_writes: Arc, + pub feature_set_repository: Arc, + /// Optional OS auto-launch value injected by desktop runtime. + pub auto_launch_enabled: Option, + /// Desktop app version (`CARGO_PKG_VERSION` from the app crate). + pub app_version: String, + /// Desktop bundle version when available (macOS app bundle). + pub bundle_version: Option, + /// Git/build metadata compiled into the desktop binary (`MCPMUX_BUILD_*`). + pub backend_build: BackendBuildStamp, +} diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/mod.rs b/crates/mcpmux-gateway/src/admin/command_bridge/mod.rs new file mode 100644 index 00000000..b6c1ef67 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/command_bridge/mod.rs @@ -0,0 +1,9 @@ +//! Shared backend entry for Tauri commands and admin HTTP handlers. +//! +//! Each submodule mirrors a Tauri command group (`commands/*.rs`). Handlers +//! delegate here so business logic is not duplicated across IPC and REST. + +pub mod oauth; +pub mod read; +pub mod space; +pub mod write; diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/oauth.rs b/crates/mcpmux-gateway/src/admin/command_bridge/oauth.rs new file mode 100644 index 00000000..fd525ac4 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/command_bridge/oauth.rs @@ -0,0 +1,40 @@ +//! OAuth consent bridge — pending consent reads and approve/reject writes. + +use anyhow::{anyhow, Result}; +use serde_json::Value; + +use super::super::bridge_context::AdminBridgeCtx; + +/// Body for admin HTTP consent approve/reject endpoints. +#[derive(Debug, serde::Deserialize)] +pub struct OAuthConsentBody { + pub request_id: String, + pub consent_token: String, + #[serde(default)] + pub client_alias: Option, +} + +/// Validate a pending OAuth consent request and return authoritative details. +/// +/// ponytail: full consent flow is Tauri-IPC-gated; this stub prevents HTTP bypass. +pub async fn get_pending_consent(_ctx: &AdminBridgeCtx, _request_id: String) -> Result { + Err(anyhow!("OAuth consent requires the desktop Tauri command")) +} + +/// Approve a pending OAuth consent request. +/// +/// ponytail: approval must go through Tauri IPC, not HTTP, for security. +pub async fn approve_oauth_consent(_ctx: &AdminBridgeCtx, body: OAuthConsentBody) -> Result { + let _ = body; + Err(anyhow!( + "OAuth consent approval requires the desktop Tauri command" + )) +} + +/// Reject a pending OAuth consent request. +pub async fn reject_oauth_consent(_ctx: &AdminBridgeCtx, body: OAuthConsentBody) -> Result { + let _ = body; + Err(anyhow!( + "OAuth consent rejection requires the desktop Tauri command" + )) +} diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/read.rs b/crates/mcpmux-gateway/src/admin/command_bridge/read.rs new file mode 100644 index 00000000..7c3e9dc9 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/command_bridge/read.rs @@ -0,0 +1,789 @@ +//! Read-only admin bridge endpoints for Phase 4 parity. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use mcpmux_core::{ + validate_workspace_root as validate_workspace_root_path, AppSettingsService, FeatureSet, + FeatureSetMember, FeatureType, LogLevel, MemberMode, MemberType, WorkspaceRootValidation, +}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::admin::bridge_context::AdminBridgeCtx; +use crate::admin::command_bridge::space::{self, SpaceBridgeCtx}; + +const LOCAL_ICON_PREFIX: &str = "local:workspace-icons/"; +const WORKSPACE_ICON_DIR: &str = "workspace-icons"; + +pub(crate) fn as_json(value: T) -> Result { + serde_json::to_value(value).map_err(Into::into) +} + +pub(crate) fn to_client_response(client: mcpmux_core::Client) -> Value { + json!({ + "id": client.id.to_string(), + "name": client.name, + "client_type": client.client_type, + "last_seen": client.last_seen.map(|dt| dt.to_rfc3339()), + }) +} + +pub(crate) fn to_feature_set_member_response(member: &FeatureSetMember) -> Value { + json!({ + "id": member.id, + "feature_set_id": member.feature_set_id, + "member_type": member.member_type.as_str(), + "member_id": member.member_id, + "mode": member.mode.as_str(), + "surfaced": member.surfaced, + }) +} + +pub(crate) fn to_feature_set_response(feature_set: FeatureSet) -> Value { + json!({ + "id": feature_set.id, + "name": feature_set.name, + "description": feature_set.description, + "icon": feature_set.icon, + "space_id": feature_set.space_id, + "feature_set_type": feature_set.feature_set_type.as_str(), + "server_id": feature_set.server_id, + "is_builtin": feature_set.is_builtin, + "is_deleted": feature_set.is_deleted, + "members": feature_set + .members + .iter() + .map(to_feature_set_member_response) + .collect::>(), + }) +} + +pub(crate) fn to_workspace_binding_response(binding: mcpmux_core::WorkspaceBinding) -> Value { + json!({ + "id": binding.id.to_string(), + "workspace_root": binding.workspace_root, + "client_id": binding.client_id, + "label": binding.label, + "space_id": binding.space_id.to_string(), + "feature_set_ids": binding.feature_set_ids, + "created_at": binding.created_at.to_rfc3339(), + "updated_at": binding.updated_at.to_rfc3339(), + }) +} + +pub(crate) fn to_workspace_appearance_response( + appearance: mcpmux_core::WorkspaceAppearance, +) -> Value { + json!({ + "workspace_root": appearance.workspace_root, + "icon": appearance.icon, + "updated_at": appearance.updated_at.to_rfc3339(), + }) +} + +fn to_server_feature_response(feature: mcpmux_core::ServerFeature) -> Value { + json!({ + "id": feature.id.to_string(), + "space_id": feature.space_id, + "server_id": feature.server_id, + "feature_type": feature.feature_type.as_str(), + "feature_name": feature.feature_name, + "display_name": feature.display_name, + "description": feature.description, + "input_schema": feature.raw_json, + "discovered_at": feature.discovered_at.to_rfc3339(), + "last_seen_at": feature.last_seen_at.to_rfc3339(), + "is_available": feature.is_available, + }) +} + +fn collect_member_ids( + feature_set: &FeatureSet, + lookup: &HashMap, + allowed: &mut HashSet, + excluded: &mut HashSet, + visited: &mut HashSet, +) { + if !visited.insert(feature_set.id.clone()) { + return; + } + for member in &feature_set.members { + match member.member_type { + MemberType::Feature => match member.mode { + MemberMode::Include => { + allowed.insert(member.member_id.clone()); + } + MemberMode::Exclude => { + excluded.insert(member.member_id.clone()); + } + }, + MemberType::FeatureSet => { + if let Some(nested) = lookup.get(&member.member_id) { + collect_member_ids(nested, lookup, allowed, excluded, visited); + } + } + } + } +} + +fn local_ref_to_file_name(icon_ref: &str) -> Option<&str> { + let file_name = icon_ref.strip_prefix(LOCAL_ICON_PREFIX)?; + if file_name.contains('/') || file_name.contains('\\') { + return None; + } + if Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + != Some("png") + { + return None; + } + Some(file_name) +} + +fn icon_ref_to_path(data_dir: &Path, icon_ref: &str) -> Option { + let file_name = local_ref_to_file_name(icon_ref)?; + Some(data_dir.join(WORKSPACE_ICON_DIR).join(file_name)) +} + +/// Resolve a validated `local:workspace-icons/…` ref to an on-disk path. +pub fn workspace_icon_path(data_dir: &Path, icon_ref: &str) -> Option { + icon_ref_to_path(data_dir, icon_ref) +} + +pub(crate) fn space_ctx<'a>(ctx: &'a AdminBridgeCtx) -> SpaceBridgeCtx<'a> { + SpaceBridgeCtx { + services: &ctx.services, + spaces_dir: &ctx.spaces_dir, + } +} + +pub async fn list_spaces(ctx: &AdminBridgeCtx) -> Result { + as_json(space::list_spaces(&space_ctx(ctx)).await?) +} + +pub async fn get_space(ctx: &AdminBridgeCtx, id: String) -> Result { + let id = Uuid::parse_str(&id)?; + as_json(space::get_space(&space_ctx(ctx), id).await?) +} + +pub async fn read_space_config(ctx: &AdminBridgeCtx, space_id: String) -> Result { + as_json(space::read_space_config(&space_ctx(ctx), &space_id).await?) +} + +pub async fn get_gateway_status(ctx: &AdminBridgeCtx, space_id: Option) -> Result { + ctx.gateway_runtime.get_gateway_status(space_id).await +} + +pub async fn probe_gateway_start(ctx: &AdminBridgeCtx, port: Option) -> Result { + ctx.gateway_runtime.probe_gateway_start(port).await +} + +pub async fn take_pending_port_conflict(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.take_pending_port_conflict().await +} + +pub async fn get_gateway_port_settings(ctx: &AdminBridgeCtx) -> Result { + let mut value = ctx.gateway_runtime.get_gateway_port_settings().await?; + // ponytail: get_gateway_public_url lands in Phase 5; return null for now + if let Some(obj) = value.as_object_mut() { + obj.insert("publicUrl".to_string(), json!(null)); + } + Ok(value) +} + +pub async fn reset_gateway_port(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.reset_gateway_port().await +} + +pub async fn list_connected_servers(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.list_connected_servers().await +} + +pub async fn get_pool_stats(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.get_pool_stats().await +} + +pub async fn get_server_statuses(ctx: &AdminBridgeCtx, space_id: String) -> Result { + ctx.gateway_runtime.get_server_statuses(space_id).await +} + +pub async fn list_installed_servers( + ctx: &AdminBridgeCtx, + space_id: Option, +) -> Result { + let servers = if let Some(space_id) = space_id { + ctx.services.server().list_for_space(&space_id).await? + } else { + ctx.services.server().list().await? + }; + as_json(servers) +} + +pub async fn discover_servers(ctx: &AdminBridgeCtx) -> Result { + ctx.server_discovery.refresh_if_needed().await?; + as_json(ctx.server_discovery.list().await) +} + +pub async fn get_server_definition(ctx: &AdminBridgeCtx, server_id: String) -> Result { + ctx.server_discovery.refresh_if_needed().await?; + as_json(ctx.server_discovery.get(&server_id).await) +} + +pub async fn get_registry_ui_config(ctx: &AdminBridgeCtx) -> Result { + ctx.server_discovery.refresh_if_needed().await?; + as_json(ctx.server_discovery.ui_config().await) +} + +pub async fn get_registry_home_config(ctx: &AdminBridgeCtx) -> Result { + ctx.server_discovery.refresh_if_needed().await?; + as_json(ctx.server_discovery.home_config().await) +} + +pub async fn is_registry_offline(ctx: &AdminBridgeCtx) -> Result { + as_json(ctx.server_discovery.is_offline().await) +} + +pub async fn list_clients(ctx: &AdminBridgeCtx) -> Result { + let clients = ctx.services.client().list().await?; + Ok(Value::Array( + clients + .into_iter() + .map(to_client_response) + .collect::>(), + )) +} + +pub async fn get_client(ctx: &AdminBridgeCtx, id: String) -> Result { + let id = Uuid::parse_str(&id)?; + let client = ctx.services.client().get(id).await?; + Ok(client.map(to_client_response).unwrap_or(Value::Null)) +} + +pub async fn list_feature_sets(ctx: &AdminBridgeCtx) -> Result { + let sets = ctx.services.permission().list_feature_sets().await?; + Ok(Value::Array( + sets.into_iter() + .map(to_feature_set_response) + .collect::>(), + )) +} + +pub async fn list_feature_sets_by_space(ctx: &AdminBridgeCtx, space_id: String) -> Result { + let sets = ctx + .services + .permission() + .list_feature_sets_for_space(&space_id) + .await?; + Ok(Value::Array( + sets.into_iter() + .map(to_feature_set_response) + .collect::>(), + )) +} + +pub async fn get_feature_set(ctx: &AdminBridgeCtx, id: String) -> Result { + let set = ctx.services.permission().get_feature_set(&id).await?; + Ok(set.map(to_feature_set_response).unwrap_or(Value::Null)) +} + +pub async fn get_feature_set_with_members(ctx: &AdminBridgeCtx, id: String) -> Result { + get_feature_set(ctx, id).await +} + +pub async fn list_workspace_bindings(ctx: &AdminBridgeCtx) -> Result { + let bindings = ctx.workspace_binding_repository.list().await?; + Ok(Value::Array( + bindings + .into_iter() + .map(to_workspace_binding_response) + .collect::>(), + )) +} + +pub async fn list_workspace_bindings_for_space( + ctx: &AdminBridgeCtx, + space_id: String, +) -> Result { + let space_id = Uuid::parse_str(&space_id)?; + let bindings = ctx + .workspace_binding_repository + .list_for_space(&space_id) + .await?; + Ok(Value::Array( + bindings + .into_iter() + .map(to_workspace_binding_response) + .collect::>(), + )) +} + +pub async fn list_reported_workspace_roots(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.list_reported_workspace_roots().await +} + +pub async fn validate_workspace_root(path: String) -> Result { + match validate_workspace_root_path(&path) { + WorkspaceRootValidation::Empty => Err(anyhow!("")), + WorkspaceRootValidation::Ok { normalized } => as_json(normalized), + WorkspaceRootValidation::Invalid { reason } => Err(anyhow!(reason)), + } +} + +pub async fn get_workspace_effective_features( + ctx: &AdminBridgeCtx, + workspace_root: String, +) -> Result { + let normalized = match validate_workspace_root_path(&workspace_root) { + WorkspaceRootValidation::Empty => return Err(anyhow!("workspace_root cannot be empty")), + WorkspaceRootValidation::Ok { normalized } => normalized, + WorkspaceRootValidation::Invalid { reason } => return Err(anyhow!(reason)), + }; + + let default_space = ctx + .space_service + .get_default() + .await? + .ok_or_else(|| anyhow!("No default Space configured"))?; + + // ponytail: find_longest_prefix_match lands in Phase 5; inline prefix search + let all_bindings = ctx + .workspace_binding_repository + .list_for_space(&default_space.id) + .await?; + let binding = all_bindings + .into_iter() + .filter(|b| normalized.starts_with(b.workspace_root.as_str())) + .max_by_key(|b| b.workspace_root.len()); + + let (source, binding_id, space_id, feature_set_ids) = match binding { + Some(binding) => ( + "binding".to_string(), + Some(binding.id.to_string()), + binding.space_id, + binding.feature_set_ids, + ), + None => { + let sets = ctx + .services + .permission() + .list_feature_sets_for_space(&default_space.id.to_string()) + .await?; + let fallback = sets + .into_iter() + .find(|set| set.feature_set_type.as_str() == "starter") + .ok_or_else(|| anyhow!("Default Space has no Starter FeatureSet"))?; + ( + "unbound".to_string(), + None, + default_space.id, + vec![fallback.id], + ) + } + }; + + let space = ctx + .space_service + .get(&space_id) + .await? + .ok_or_else(|| anyhow!("Resolved Space no longer exists"))?; + + let mut resolved_sets: Vec = Vec::with_capacity(feature_set_ids.len()); + for id in &feature_set_ids { + let set = ctx + .services + .permission() + .get_feature_set(id) + .await? + .ok_or_else(|| anyhow!("Resolved FeatureSet {id} not found"))?; + resolved_sets.push(set); + } + + let mut lookup: HashMap = HashMap::new(); + for set in ctx + .services + .permission() + .list_feature_sets_for_space(&space_id.to_string()) + .await? + { + lookup.insert(set.id.clone(), set); + } + for set in &resolved_sets { + lookup.insert(set.id.clone(), set.clone()); + } + + let mut allowed = HashSet::::new(); + let mut excluded = HashSet::::new(); + let mut visited = HashSet::::new(); + for set in &resolved_sets { + collect_member_ids(set, &lookup, &mut allowed, &mut excluded, &mut visited); + } + excluded.retain(|id| !allowed.contains(id)); + + let all_features = ctx + .server_feature_repository + .list_for_space(&space_id.to_string()) + .await?; + let mut server_totals = HashMap::::new(); + for feature in &all_features { + let entry = server_totals + .entry(feature.server_id.clone()) + .or_insert_with(|| json!({ "tools": 0, "prompts": 0, "resources": 0 })); + let key = match feature.feature_type { + FeatureType::Tool => "tools", + FeatureType::Prompt => "prompts", + FeatureType::Resource => "resources", + }; + let current = entry[key].as_u64().unwrap_or(0); + entry[key] = json!(current + 1); + } + + let filtered = all_features + .into_iter() + .filter(|feature| { + let id = feature.id.to_string(); + allowed.contains(&id) && !excluded.contains(&id) + }) + .collect::>(); + + let to_effective = |feature: mcpmux_core::ServerFeature| { + json!({ + "id": feature.id.to_string(), + "feature_name": feature.feature_name, + "display_name": feature.display_name, + "description": feature.description, + "server_id": feature.server_id, + "server_alias": feature.server_alias, + "server_status": "unknown", + "available": feature.is_available, + }) + }; + + let mut tools = vec![]; + let mut prompts = vec![]; + let mut resources = vec![]; + for feature in filtered { + match feature.feature_type { + FeatureType::Tool => tools.push(to_effective(feature)), + FeatureType::Prompt => prompts.push(to_effective(feature)), + FeatureType::Resource => resources.push(to_effective(feature)), + } + } + + Ok(json!({ + "workspace_root": normalized, + "source": source, + "binding_id": binding_id, + "space_id": space_id.to_string(), + "space_name": space.name, + "feature_sets": resolved_sets + .into_iter() + .map(|set| json!({ + "id": set.id, + "name": set.name, + "feature_set_type": set.feature_set_type.as_str(), + })) + .collect::>(), + "tools": tools, + "prompts": prompts, + "resources": resources, + "server_totals": server_totals, + })) +} + +pub async fn list_workspace_appearances(ctx: &AdminBridgeCtx) -> Result { + let items = ctx.workspace_appearance_repository.list().await?; + Ok(Value::Array( + items + .into_iter() + .map(to_workspace_appearance_response) + .collect::>(), + )) +} + +pub async fn resolve_workspace_icon_path(ctx: &AdminBridgeCtx, icon_ref: String) -> Result { + let Some(path) = icon_ref_to_path(&ctx.data_dir, &icon_ref) else { + return Ok(Value::Null); + }; + match tokio::fs::metadata(&path).await { + Ok(_) => as_json(Some(path.to_string_lossy().to_string())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => as_json(None::), + Err(err) => Err(anyhow!("failed to resolve icon path: {err}")), + } +} + +pub async fn get_startup_settings(ctx: &AdminBridgeCtx) -> Result { + let start_minimized = ctx + .settings_repository + .get("startup.start_minimized") + .await + .ok() + .flatten() + .map(|value| value == "true") + .unwrap_or(true); + let close_to_tray = ctx + .settings_repository + .get("ui.close_to_tray") + .await + .ok() + .flatten() + .map(|value| value == "true") + .unwrap_or(true); + Ok(json!({ + "autoLaunch": ctx.auto_launch_enabled.unwrap_or(false), + "startMinimized": start_minimized, + "closeToTray": close_to_tray, + })) +} + +pub async fn get_server_update_settings(ctx: &AdminBridgeCtx) -> Result { + let policy = match ctx + .settings_repository + .get("servers.default_update_policy") + .await + { + Ok(Some(value)) => value, + _ => "notify".to_string(), + }; + let last_checked_at = ctx + .settings_repository + .get("servers.last_version_probe_at") + .await + .ok() + .flatten(); + Ok(json!({ + "defaultUpdatePolicy": policy, + "lastCheckedAt": last_checked_at, + })) +} + +pub async fn get_meta_tools_enabled(ctx: &AdminBridgeCtx) -> Result { + let enabled = match ctx + .settings_repository + .get("gateway.meta_tools_enabled") + .await + { + Ok(Some(value)) => !matches!(value.as_str(), "false" | "0"), + _ => true, + }; + as_json(enabled) +} + +pub async fn get_version(ctx: &AdminBridgeCtx) -> Result { + as_json(ctx.app_version.clone()) +} + +pub async fn get_bundle_version(ctx: &AdminBridgeCtx) -> Result { + as_json(ctx.bundle_version.clone()) +} + +pub async fn get_build_info(ctx: &AdminBridgeCtx) -> Result { + as_json(serde_json::json!({ + "git_sha": ctx.backend_build.git_sha, + "git_branch": ctx.backend_build.git_branch, + "commit_time": ctx.backend_build.commit_time, + "build_time": ctx.backend_build.build_time, + })) +} + +pub async fn get_logs_path(ctx: &AdminBridgeCtx) -> Result { + as_json(ctx.data_dir.join("logs").to_string_lossy().to_string()) +} + +pub async fn get_server_logs( + ctx: &AdminBridgeCtx, + server_id: String, + limit: Option, + level_filter: Option, +) -> Result { + let default_space = ctx + .space_service + .get_default() + .await? + .ok_or_else(|| anyhow!("No default space found"))?; + let level = level_filter.and_then(|value| LogLevel::parse(&value)); + let logs = ctx + .server_log_manager + .read_logs( + &default_space.id.to_string(), + &server_id, + limit.unwrap_or(100), + level, + ) + .await?; + let mapped = logs + .into_iter() + .map(|log| { + json!({ + "timestamp": log.timestamp.to_rfc3339(), + "level": log.level.as_str(), + "source": log.source.as_str(), + "message": log.message, + "metadata": log.metadata, + }) + }) + .collect::>(); + Ok(Value::Array(mapped)) +} + +pub async fn get_server_log_file(ctx: &AdminBridgeCtx, server_id: String) -> Result { + let default_space = ctx + .space_service + .get_default() + .await? + .ok_or_else(|| anyhow!("No default space found"))?; + let path = ctx + .server_log_manager + .get_log_file(&default_space.id.to_string(), &server_id); + as_json(path.to_string_lossy().to_string()) +} + +pub async fn get_log_retention_days(ctx: &AdminBridgeCtx) -> Result { + let settings = AppSettingsService::new(ctx.settings_repository.clone()); + as_json(settings.get_log_retention_days().await) +} + +pub async fn get_oauth_clients(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.get_oauth_clients().await +} + +pub async fn get_oauth_client_grants( + ctx: &AdminBridgeCtx, + client_id: String, + space_id: String, +) -> Result { + ctx.gateway_runtime + .get_oauth_client_grants(client_id, space_id) + .await +} + +pub async fn list_meta_tool_grants(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_runtime.list_meta_tool_grants().await +} + +pub async fn list_server_features( + ctx: &AdminBridgeCtx, + space_id: String, + include_unavailable: Option, +) -> Result { + let features = ctx + .server_feature_repository + .list_for_space(&space_id) + .await?; + let features = if include_unavailable.unwrap_or(false) { + features + } else { + features + .into_iter() + .filter(|feature| feature.is_available) + .collect::>() + }; + Ok(Value::Array( + features + .into_iter() + .map(to_server_feature_response) + .collect::>(), + )) +} + +pub async fn list_server_features_by_server( + ctx: &AdminBridgeCtx, + space_id: String, + server_id: String, + include_unavailable: Option, +) -> Result { + let features = ctx + .server_feature_repository + .list_for_server(&space_id, &server_id) + .await?; + let features = if include_unavailable.unwrap_or(false) { + features + } else { + features + .into_iter() + .filter(|feature| feature.is_available) + .collect::>() + }; + Ok(Value::Array( + features + .into_iter() + .map(to_server_feature_response) + .collect::>(), + )) +} + +pub async fn list_server_features_by_type( + ctx: &AdminBridgeCtx, + space_id: String, + server_id: String, + feature_type: String, + include_unavailable: Option, +) -> Result { + let parsed = + FeatureType::parse(&feature_type).ok_or_else(|| anyhow!("Invalid feature type"))?; + let features = ctx + .server_feature_repository + .list_for_server(&space_id, &server_id) + .await?; + let features = features + .into_iter() + .filter(|feature| feature.feature_type == parsed) + .collect::>(); + let features = if include_unavailable.unwrap_or(false) { + features + } else { + features + .into_iter() + .filter(|feature| feature.is_available) + .collect::>() + }; + Ok(Value::Array( + features + .into_iter() + .map(to_server_feature_response) + .collect::>(), + )) +} + +pub async fn get_server_feature(ctx: &AdminBridgeCtx, id: String) -> Result { + let id = Uuid::parse_str(&id)?; + let feature = ctx.server_feature_repository.get(&id).await?; + Ok(feature + .map(to_server_feature_response) + .unwrap_or(Value::Null)) +} + +pub async fn is_clone_id_available( + _ctx: &AdminBridgeCtx, + _space_id: String, + _source_server_id: String, + _suffix: String, +) -> Result { + // ponytail: clone_server lands in Phase 6 + Err(anyhow!("Server cloning not yet available")) +} + +pub async fn suggest_clone_suffix( + _ctx: &AdminBridgeCtx, + _space_id: String, + _source_server_id: String, +) -> Result { + // ponytail: clone_server lands in Phase 6 + Err(anyhow!("Server cloning not yet available")) +} + +pub async fn list_clone_dependents( + _ctx: &AdminBridgeCtx, + _space_id: String, + _source_server_id: String, +) -> Result { + // ponytail: clone_server lands in Phase 6 + Err(anyhow!("Server cloning not yet available")) +} + +pub async fn now_utc() -> Result { + as_json(Utc::now().to_rfc3339()) +} diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/space.rs b/crates/mcpmux-gateway/src/admin/command_bridge/space.rs new file mode 100644 index 00000000..4c64f0e2 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/command_bridge/space.rs @@ -0,0 +1,171 @@ +//! Space command bridge — shared logic for Tauri IPC and admin REST. + +use std::path::Path; + +use anyhow::{Context, Result}; +use mcpmux_core::{get_space_config_path, ApplicationServices, Space}; +use serde::Deserialize; +use tracing::info; +use uuid::Uuid; + +/// Default space configuration template written for new spaces. +pub const DEFAULT_SPACE_CONFIG: &str = r#"{ + "mcpServers": { + } +} +"#; + +/// Partial update payload for a space (name, icon, description). +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateSpaceInput { + pub name: Option, + pub icon: Option, + pub description: Option, +} + +/// Dependencies required by space bridge functions beyond `ApplicationServices`. +pub struct SpaceBridgeCtx<'a> { + pub services: &'a ApplicationServices, + pub spaces_dir: &'a Path, +} + +impl<'a> SpaceBridgeCtx<'a> { + /// Resolve the on-disk JSON config path for a space. + pub fn config_path(&self, space_id: &str) -> Result { + get_space_config_path(self.spaces_dir, space_id) + } +} + +/// List all spaces. +pub async fn list_spaces(ctx: &SpaceBridgeCtx<'_>) -> Result> { + ctx.services.space().list().await +} + +/// Get a space by ID. +pub async fn get_space(ctx: &SpaceBridgeCtx<'_>, id: Uuid) -> Result> { + ctx.services.space().get(id).await +} + +/// Create a space and ensure its default config file exists. +pub async fn create_space( + ctx: &SpaceBridgeCtx<'_>, + name: String, + icon: Option, +) -> Result { + let space = ctx.services.space().create(&name, icon).await?; + write_default_config_if_missing(ctx, &space.id.to_string())?; + info!("[command_bridge::space] Space '{}' created", space.name); + Ok(space) +} + +/// Update a space's display metadata. +pub async fn update_space( + ctx: &SpaceBridgeCtx<'_>, + id: Uuid, + input: UpdateSpaceInput, +) -> Result { + let name = input + .name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + let icon = input + .icon + .map(|i| i.trim().to_string()) + .filter(|i| !i.is_empty()); + let description = input.description.map(|d| d.trim().to_string()); + + let space = ctx + .services + .space() + .update(id, name, icon, description) + .await?; + info!("[command_bridge::space] Space '{}' updated", space.name); + Ok(space) +} + +/// Delete a space by ID. +pub async fn delete_space(ctx: &SpaceBridgeCtx<'_>, id: Uuid) -> Result<()> { + ctx.services.space().delete(id).await?; + info!("[command_bridge::space] Space '{}' deleted", id); + Ok(()) +} + +/// Read a space configuration file, creating the default template when missing. +pub async fn read_space_config(ctx: &SpaceBridgeCtx<'_>, space_id: &str) -> Result { + let config_path = ctx.config_path(space_id)?; + write_default_config_if_missing(ctx, space_id)?; + + std::fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config file: {}", config_path.display())) +} + +/// Save a space configuration file after JSON validation. +pub async fn save_space_config( + ctx: &SpaceBridgeCtx<'_>, + space_id: &str, + content: &str, +) -> Result<()> { + serde_json::from_str::(content).context("Invalid JSON")?; + + let config_path = ctx.config_path(space_id)?; + std::fs::write(&config_path, content) + .with_context(|| format!("Failed to write config file: {}", config_path.display())) +} + +/// Remove a server entry from a space config file. +pub async fn remove_server_from_config( + ctx: &SpaceBridgeCtx<'_>, + space_id: &str, + server_id: &str, +) -> Result { + let config_path = ctx.config_path(space_id)?; + if !config_path.exists() { + return Ok(false); + } + + let content = std::fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; + + let mut config: serde_json::Value = + serde_json::from_str(&content).context("Failed to parse config")?; + + let servers = config.get_mut("mcpServers").and_then(|v| v.as_object_mut()); + if let Some(servers) = servers { + if servers.remove(server_id).is_some() { + let new_content = + serde_json::to_string_pretty(&config).context("Failed to serialize config")?; + std::fs::write(&config_path, new_content).with_context(|| { + format!("Failed to write config file: {}", config_path.display()) + })?; + info!( + "[command_bridge::space] Removed server '{}' from space '{}'", + server_id, space_id + ); + return Ok(true); + } + } + + Ok(false) +} + +fn write_default_config_if_missing(ctx: &SpaceBridgeCtx<'_>, space_id: &str) -> Result<()> { + std::fs::create_dir_all(ctx.spaces_dir) + .with_context(|| format!("Failed to create spaces dir: {}", ctx.spaces_dir.display()))?; + + let config_path = ctx.config_path(space_id)?; + if config_path.exists() { + return Ok(()); + } + + std::fs::write(&config_path, DEFAULT_SPACE_CONFIG).with_context(|| { + format!( + "Failed to create default config file: {}", + config_path.display() + ) + })?; + info!( + "[command_bridge::space] Created default config file: {}", + config_path.display() + ); + Ok(()) +} diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/write.rs b/crates/mcpmux-gateway/src/admin/command_bridge/write.rs new file mode 100644 index 00000000..1a10aba3 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/command_bridge/write.rs @@ -0,0 +1,957 @@ +//! Write admin bridge endpoints for Phase 6 parity. + +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use mcpmux_core::{ + validate_workspace_root as validate_workspace_root_path, AppSettingsService, Client, + FeatureSet, FeatureSetMember, MemberMode, MemberType, ServerSource, UpdatePolicy, + WorkspaceAppearance, WorkspaceBinding, WorkspaceRootValidation, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::admin::bridge_context::AdminBridgeCtx; +use crate::admin::command_bridge::read::{ + as_json, space_ctx, to_client_response, to_feature_set_response, + to_workspace_appearance_response, to_workspace_binding_response, +}; +use crate::admin::command_bridge::space::{self, UpdateSpaceInput}; + +const LOCAL_ICON_PREFIX: &str = "local:workspace-icons/"; +const WORKSPACE_ICON_DIR: &str = "workspace-icons"; +const DEFAULT_UPDATE_POLICY_KEY: &str = "servers.default_update_policy"; + +#[derive(Debug, Deserialize)] +pub struct CreateSpaceBody { + pub name: String, + pub icon: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SaveSpaceConfigBody { + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateFeatureSetBody { + pub name: String, + pub space_id: String, + pub description: Option, + pub icon: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateFeatureSetBody { + pub name: Option, + pub description: Option, + pub icon: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddMemberBody { + pub member_type: String, + pub member_id: String, + pub mode: Option, + pub surfaced: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetMembersBody { + pub members: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateClientBody { + pub name: String, + pub client_type: String, +} + +#[derive(Debug, Deserialize)] +pub struct WorkspaceBindingBody { + pub workspace_root: String, + pub label: Option, + pub icon: Option, + pub space_id: String, + pub feature_set_ids: Vec, + pub client_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WorkspaceAppearanceBody { + pub workspace_root: String, + pub icon: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupSettingsBody { + pub auto_launch: bool, + pub start_minimized: bool, + pub close_to_tray: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerUpdateSettingsBody { + pub default_update_policy: String, +} + +#[derive(Debug, Deserialize)] +pub struct UninstallServerBody { + pub space_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct InstallServerBody { + pub id: String, + pub space_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct SaveServerInputsBody { + pub input_values: HashMap, + pub space_id: String, + pub env_overrides: Option>, + pub args_append: Option>, + pub extra_headers: Option>, + pub default_params: Option>, + pub default_params_strategy: Option, + pub display_name_override: Option, + pub update_policy: Option, + pub pinned_version: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetServerDisplayNameBody { + pub space_id: String, + pub display_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetServerOAuthConnectedBody { + pub space_id: String, + pub connected: bool, +} + +#[derive(Debug, Deserialize)] +pub struct ServerConnectionBody { + pub space_id: String, + pub server_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct DisconnectServerBody { + pub space_id: String, + pub server_id: String, + pub logout: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GatewayStartBody { + pub port: Option, + pub allow_dynamic_fallback: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GatewayPortBody { + pub port: u16, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayPublicUrlBody { + pub public_url: String, +} + +#[derive(Debug, Deserialize)] +pub struct CloneServerBody { + pub space_id: String, + pub source_server_id: String, + pub suffix: String, + pub alias: Option, + pub display_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UploadIconBody { + pub source_path: String, +} + +#[derive(Debug, Deserialize)] +pub struct MetaToolApprovalBody { + pub request_id: String, + pub client_id: String, + pub tool_name: String, + pub decision: String, +} + +#[derive(Debug, Deserialize)] +pub struct MetaToolRevokeBody { + pub client_id: String, + pub tool_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct OAuthClientUpdateBody { + pub client_alias: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OAuthGrantBody { + pub space_id: String, + pub feature_set_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct LogRetentionBody { + pub days: u32, +} + +#[derive(Debug, Deserialize)] +pub struct MetaToolsEnabledBody { + pub enabled: bool, +} + +fn normalize_label(label: &Option) -> Option { + label + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn normalize_workspace_root(raw: &str) -> Result { + match validate_workspace_root_path(raw) { + WorkspaceRootValidation::Empty => Err(anyhow!("workspace_root cannot be empty")), + WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + WorkspaceRootValidation::Invalid { reason } => Err(anyhow!(reason)), + } +} + +fn validate_feature_set_ids(ids: &[String]) -> Result> { + let cleaned: Vec = ids + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if cleaned.is_empty() { + return Err(anyhow!("at least one feature_set_id is required")); + } + let mut seen = std::collections::HashSet::new(); + Ok(cleaned + .into_iter() + .filter(|id| seen.insert(id.clone())) + .collect()) +} + +fn local_ref_to_file_name(icon_ref: &str) -> Option<&str> { + let file_name = icon_ref.strip_prefix(LOCAL_ICON_PREFIX)?; + if file_name.contains('/') || file_name.contains('\\') { + return None; + } + if Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + != Some("png") + { + return None; + } + Some(file_name) +} + +async fn maybe_remove_orphaned_icon(ctx: &AdminBridgeCtx, icon_ref: Option<&str>) -> Result<()> { + let Some(icon_ref) = icon_ref else { + return Ok(()); + }; + let Some(file_name) = local_ref_to_file_name(icon_ref) else { + return Ok(()); + }; + + let appearances = ctx.workspace_appearance_repository.list().await?; + if appearances.iter().any(|a| a.icon == icon_ref) { + return Ok(()); + } + + let file_path = ctx.data_dir.join(WORKSPACE_ICON_DIR).join(file_name); + match tokio::fs::remove_file(&file_path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(anyhow!("failed to remove orphaned icon file: {err}")), + } + Ok(()) +} + +fn parse_member_type(value: &str) -> MemberType { + if value == "feature_set" { + MemberType::FeatureSet + } else { + MemberType::Feature + } +} + +fn parse_member_mode(value: Option<&str>) -> MemberMode { + value + .and_then(MemberMode::parse) + .unwrap_or(MemberMode::Include) +} + +async fn get_feature_set_with_members(ctx: &AdminBridgeCtx, id: &str) -> Result { + ctx.feature_set_repository + .get_with_members(id) + .await? + .ok_or_else(|| anyhow!("Feature set not found")) +} + +async fn save_feature_set(ctx: &AdminBridgeCtx, mut feature_set: FeatureSet) -> Result { + feature_set.updated_at = Utc::now(); + ctx.feature_set_repository.update(&feature_set).await?; + Ok(to_feature_set_response(feature_set)) +} + +// --- Spaces --- + +pub async fn create_space(ctx: &AdminBridgeCtx, body: CreateSpaceBody) -> Result { + let space = space::create_space(&space_ctx(ctx), body.name, body.icon).await?; + as_json(space) +} + +pub async fn update_space( + ctx: &AdminBridgeCtx, + id: String, + input: UpdateSpaceInput, +) -> Result { + let id = Uuid::parse_str(&id)?; + let space = space::update_space(&space_ctx(ctx), id, input).await?; + as_json(space) +} + +pub async fn delete_space(ctx: &AdminBridgeCtx, id: String) -> Result { + let id = Uuid::parse_str(&id)?; + space::delete_space(&space_ctx(ctx), id).await?; + Ok(json!({ "ok": true })) +} + +pub async fn save_space_config( + ctx: &AdminBridgeCtx, + space_id: String, + body: SaveSpaceConfigBody, +) -> Result { + space::save_space_config(&space_ctx(ctx), &space_id, &body.content).await?; + Ok(json!({ "ok": true })) +} + +pub async fn remove_server_from_config( + ctx: &AdminBridgeCtx, + space_id: String, + server_id: String, +) -> Result { + let removed = space::remove_server_from_config(&space_ctx(ctx), &space_id, &server_id).await?; + as_json(removed) +} + +// --- Feature sets --- + +pub async fn create_feature_set(ctx: &AdminBridgeCtx, body: CreateFeatureSetBody) -> Result { + let set = ctx + .services + .permission() + .create_feature_set(&body.space_id, &body.name, body.description, body.icon) + .await?; + Ok(to_feature_set_response(set)) +} + +pub async fn update_feature_set( + ctx: &AdminBridgeCtx, + id: String, + body: UpdateFeatureSetBody, +) -> Result { + let set = ctx + .services + .permission() + .update_feature_set(id.as_str(), body.name, body.description, body.icon) + .await?; + Ok(to_feature_set_response(set)) +} + +pub async fn delete_feature_set(ctx: &AdminBridgeCtx, id: String) -> Result { + ctx.services.permission().delete_feature_set(&id).await?; + Ok(json!({ "ok": true })) +} + +pub async fn add_feature_set_member( + ctx: &AdminBridgeCtx, + feature_set_id: String, + body: AddMemberBody, +) -> Result { + let mut feature_set = get_feature_set_with_members(ctx, &feature_set_id).await?; + let fs_type = feature_set.feature_set_type.as_str(); + if fs_type != "starter" && fs_type != "default" && fs_type != "custom" { + return Err(anyhow!( + "Cannot modify members of '{fs_type}' type feature set" + )); + } + + let member_type = parse_member_type(&body.member_type); + let mode = parse_member_mode(body.mode.as_deref()); + + if feature_set + .members + .iter() + .any(|m| m.member_type == member_type && m.member_id == body.member_id) + { + return Err(anyhow!("Member already exists in this feature set")); + } + if member_type == MemberType::FeatureSet && body.member_id == feature_set_id { + return Err(anyhow!("Cannot add a feature set to itself")); + } + if member_type == MemberType::FeatureSet { + if let Some(target) = ctx.feature_set_repository.get(&body.member_id).await? { + let target_type = target.feature_set_type.as_str(); + if target_type == "all" || target_type == "default" { + return Err(anyhow!( + "Cannot include '{target_type}' type feature sets in other feature sets" + )); + } + } + } + + feature_set.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: feature_set_id.clone(), + member_type, + member_id: body.member_id, + mode, + surfaced: body.surfaced.unwrap_or(false), + }); + save_feature_set(ctx, feature_set).await +} + +pub async fn remove_feature_set_member( + ctx: &AdminBridgeCtx, + feature_set_id: String, + member_id: String, +) -> Result { + let mut feature_set = get_feature_set_with_members(ctx, &feature_set_id).await?; + if feature_set.is_builtin { + return Err(anyhow!("Cannot modify builtin feature set")); + } + feature_set.members.retain(|m| m.id != member_id); + save_feature_set(ctx, feature_set).await +} + +pub async fn set_feature_set_members( + ctx: &AdminBridgeCtx, + feature_set_id: String, + body: SetMembersBody, +) -> Result { + let mut feature_set = get_feature_set_with_members(ctx, &feature_set_id).await?; + let fs_type = feature_set.feature_set_type.as_str(); + if fs_type != "starter" && fs_type != "default" && fs_type != "custom" { + return Err(anyhow!( + "Cannot modify members of '{fs_type}' type feature set" + )); + } + + feature_set.members = body + .members + .into_iter() + .filter(|m| !(m.member_type == "feature_set" && m.member_id == feature_set_id)) + .map(|input| FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: feature_set_id.clone(), + member_type: parse_member_type(&input.member_type), + member_id: input.member_id, + mode: parse_member_mode(input.mode.as_deref()), + surfaced: input.surfaced.unwrap_or(false), + }) + .collect(); + save_feature_set(ctx, feature_set).await +} + +// --- Clients --- + +pub async fn create_client(ctx: &AdminBridgeCtx, body: CreateClientBody) -> Result { + let client = ctx + .services + .client() + .create(&body.name, &body.client_type) + .await?; + Ok(to_client_response(client)) +} + +pub async fn delete_client(ctx: &AdminBridgeCtx, id: String) -> Result { + let id = Uuid::parse_str(&id)?; + ctx.services.client().delete(id).await?; + Ok(json!({ "ok": true })) +} + +pub async fn init_preset_clients(ctx: &AdminBridgeCtx) -> Result { + let existing = ctx.services.client().list().await?; + if !existing.iter().any(|c| c.client_type == "cursor") { + let cursor = Client::cursor(); + ctx.services + .client() + .create(&cursor.name, &cursor.client_type) + .await?; + } + if !existing.iter().any(|c| c.client_type == "vscode") { + let vscode = Client::vscode(); + ctx.services + .client() + .create(&vscode.name, &vscode.client_type) + .await?; + } + if !existing.iter().any(|c| c.client_type == "claude") { + let claude = Client::claude_desktop(); + ctx.services + .client() + .create(&claude.name, &claude.client_type) + .await?; + } + Ok(json!({ "ok": true })) +} + +// --- Workspace bindings --- + +pub async fn create_workspace_binding( + ctx: &AdminBridgeCtx, + body: WorkspaceBindingBody, +) -> Result { + let space_id = Uuid::parse_str(&body.space_id)?; + let feature_set_ids = validate_feature_set_ids(&body.feature_set_ids)?; + let normalized = normalize_workspace_root(&body.workspace_root)?; + + let mut binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + binding.label = normalize_label(&body.label); + binding.client_id = body.client_id.clone(); + + ctx.workspace_binding_repository.create(&binding).await?; + + Ok(to_workspace_binding_response(binding)) +} + +pub async fn update_workspace_binding( + ctx: &AdminBridgeCtx, + id: String, + body: WorkspaceBindingBody, +) -> Result { + let id_uuid = Uuid::parse_str(&id)?; + let space_id = Uuid::parse_str(&body.space_id)?; + let feature_set_ids = validate_feature_set_ids(&body.feature_set_ids)?; + let normalized = normalize_workspace_root(&body.workspace_root)?; + + let existing = ctx + .workspace_binding_repository + .get(&id_uuid) + .await? + .ok_or_else(|| anyhow!("binding not found: {id}"))?; + + let label = if body.label.is_some() { + normalize_label(&body.label) + } else { + existing.label.clone() + }; + + let updated = WorkspaceBinding { + id: existing.id, + workspace_root: normalized, + client_id: body.client_id.or(existing.client_id), + label, + space_id, + feature_set_ids, + created_at: existing.created_at, + updated_at: Utc::now(), + }; + + ctx.workspace_binding_repository.update(&updated).await?; + + Ok(to_workspace_binding_response(updated)) +} + +pub async fn delete_workspace_binding(ctx: &AdminBridgeCtx, id: String) -> Result { + let id_uuid = Uuid::parse_str(&id)?; + ctx.workspace_binding_repository.delete(&id_uuid).await?; + Ok(json!({ "ok": true })) +} + +// --- Workspace appearances --- + +pub async fn upsert_workspace_appearance( + ctx: &AdminBridgeCtx, + body: WorkspaceAppearanceBody, +) -> Result { + let workspace_root = normalize_workspace_root(&body.workspace_root)?; + let icon = body.icon.trim(); + if icon.is_empty() { + return Err(anyhow!("icon cannot be empty")); + } + + let previous_icon = ctx + .workspace_appearance_repository + .get(&workspace_root) + .await? + .map(|a| a.icon); + + let appearance = WorkspaceAppearance::new(workspace_root, icon.to_string()); + ctx.workspace_appearance_repository + .upsert(&appearance) + .await?; + + if let Some(previous_icon) = previous_icon { + if previous_icon != appearance.icon { + maybe_remove_orphaned_icon(ctx, Some(previous_icon.as_str())).await?; + } + } + + Ok(to_workspace_appearance_response(appearance)) +} + +pub async fn delete_workspace_appearance( + ctx: &AdminBridgeCtx, + workspace_root: String, +) -> Result { + let normalized = normalize_workspace_root(&workspace_root)?; + let previous = ctx.workspace_appearance_repository.get(&normalized).await?; + ctx.workspace_appearance_repository + .delete(&normalized) + .await?; + if let Some(previous) = previous { + maybe_remove_orphaned_icon(ctx, Some(previous.icon.as_str())).await?; + } + Ok(json!({ "ok": true })) +} + +pub async fn upload_workspace_icon(_ctx: &AdminBridgeCtx, _body: UploadIconBody) -> Result { + // ponytail: workspace icon upload requires `image` crate, lands in Phase 7 + Err(anyhow!("Workspace icon upload not yet available")) +} + +// --- Settings --- + +pub async fn update_startup_settings( + ctx: &AdminBridgeCtx, + body: StartupSettingsBody, +) -> Result { + ctx.settings_repository + .set("startup.autostart_configured", "true") + .await?; + ctx.settings_repository + .set("startup.start_minimized", &body.start_minimized.to_string()) + .await?; + ctx.settings_repository + .set("ui.close_to_tray", &body.close_to_tray.to_string()) + .await?; + let _ = body.auto_launch; + Ok(json!({ "ok": true })) +} + +pub async fn update_server_update_settings( + ctx: &AdminBridgeCtx, + body: ServerUpdateSettingsBody, +) -> Result { + let policy = UpdatePolicy::from_db_str(&body.default_update_policy); + ctx.settings_repository + .set(DEFAULT_UPDATE_POLICY_KEY, policy.as_db_str()) + .await?; + Ok(json!({ "ok": true })) +} + +pub async fn set_meta_tools_enabled(ctx: &AdminBridgeCtx, enabled: bool) -> Result { + ctx.settings_repository + .set( + "gateway.meta_tools_enabled", + if enabled { "true" } else { "false" }, + ) + .await?; + Ok(json!({ "ok": true })) +} + +// --- Logs --- + +pub async fn clear_server_logs(ctx: &AdminBridgeCtx, server_id: String) -> Result { + let default_space = ctx + .space_service + .get_default() + .await? + .ok_or_else(|| anyhow!("No default space found"))?; + ctx.server_log_manager + .clear_logs(&default_space.id.to_string(), &server_id) + .await?; + Ok(json!({ "ok": true })) +} + +pub async fn set_log_retention_days(ctx: &AdminBridgeCtx, body: LogRetentionBody) -> Result { + let settings = AppSettingsService::new(ctx.settings_repository.clone()); + settings.set_log_retention_days(body.days).await?; + if body.days > 0 { + let _ = ctx + .server_log_manager + .cleanup_logs_older_than(body.days) + .await; + } + Ok(json!({ "ok": true })) +} + +// --- Registry / servers --- + +pub async fn refresh_registry(ctx: &AdminBridgeCtx) -> Result { + ctx.server_discovery.refresh().await?; + let servers = ctx.server_discovery.list().await; + let mut count = 0_u32; + for server in servers { + if let ServerSource::UserSpace { space_id, .. } = &server.source { + if ctx + .services + .server() + .get(space_id, &server.id) + .await? + .is_some() + { + continue; + } + let space_uuid = Uuid::parse_str(space_id)?; + ctx.services + .server() + .install(space_uuid, &server.id, &server, HashMap::new()) + .await?; + count += 1; + } + } + as_json(count) +} + +pub async fn install_server(ctx: &AdminBridgeCtx, body: InstallServerBody) -> Result { + ctx.server_discovery.refresh_if_needed().await?; + let definition = ctx + .server_discovery + .get(&body.id) + .await + .ok_or_else(|| anyhow!("Server definition not found"))?; + let space_uuid = Uuid::parse_str(&body.space_id)?; + let installed = ctx + .services + .server() + .install(space_uuid, &body.id, &definition, HashMap::new()) + .await?; + as_json(installed) +} + +pub async fn uninstall_server(ctx: &AdminBridgeCtx, id: String, space_id: String) -> Result { + let space_uuid = Uuid::parse_str(&space_id)?; + ctx.services.server().uninstall(space_uuid, &id).await?; + Ok(json!({ "ok": true })) +} + +pub async fn save_server_inputs( + ctx: &AdminBridgeCtx, + id: String, + body: SaveServerInputsBody, +) -> Result { + let space_uuid = Uuid::parse_str(&body.space_id)?; + let installed = ctx + .services + .server() + .update_config( + space_uuid, + &id, + body.input_values, + body.env_overrides, + body.args_append, + body.extra_headers, + ) + .await?; + as_json(installed) +} + +pub async fn set_server_display_name( + _ctx: &AdminBridgeCtx, + _id: String, + _body: SetServerDisplayNameBody, +) -> Result { + // ponytail: set_display_name_override lands in Phase 6 + Err(anyhow!("Server display name override not yet available")) +} + +pub async fn set_server_oauth_connected( + ctx: &AdminBridgeCtx, + id: String, + body: SetServerOAuthConnectedBody, +) -> Result { + let space_uuid = Uuid::parse_str(&body.space_id)?; + ctx.services + .server() + .set_oauth_connected(space_uuid, &id, body.connected) + .await?; + Ok(json!({ "ok": true })) +} + +pub async fn clone_server(_ctx: &AdminBridgeCtx, _body: CloneServerBody) -> Result { + // ponytail: clone_server lands in Phase 6 + Err(anyhow!("Server cloning not yet available")) +} + +// --- Gateway writes (delegated) --- + +pub async fn start_gateway(ctx: &AdminBridgeCtx, body: GatewayStartBody) -> Result { + ctx.gateway_writes + .start_gateway(body.port, body.allow_dynamic_fallback) + .await +} + +pub async fn stop_gateway(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_writes.stop_gateway().await +} + +pub async fn restart_gateway(ctx: &AdminBridgeCtx, body: GatewayStartBody) -> Result { + ctx.gateway_writes + .restart_gateway(body.port, body.allow_dynamic_fallback) + .await +} + +pub async fn disconnect_server(ctx: &AdminBridgeCtx, body: DisconnectServerBody) -> Result { + ctx.gateway_writes + .disconnect_server(body.server_id, body.space_id, body.logout) + .await +} + +pub async fn connect_all_enabled_servers(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_writes.connect_all_enabled_servers().await +} + +pub async fn refresh_oauth_tokens_on_startup(ctx: &AdminBridgeCtx) -> Result { + ctx.gateway_writes.refresh_oauth_tokens_on_startup().await +} + +pub async fn set_gateway_port(ctx: &AdminBridgeCtx, body: GatewayPortBody) -> Result { + ctx.gateway_writes.set_gateway_port(body.port).await +} + +pub async fn set_gateway_public_url( + _ctx: &AdminBridgeCtx, + _body: GatewayPublicUrlBody, +) -> Result { + // ponytail: public URL persistence lands in Phase 5 (AppSettingsService extension) + Err(anyhow!( + "Gateway public URL configuration not yet available" + )) +} + +pub async fn enable_server_v2(ctx: &AdminBridgeCtx, body: ServerConnectionBody) -> Result { + ctx.gateway_writes + .enable_server_v2(body.space_id, body.server_id) + .await +} + +pub async fn disable_server_v2(ctx: &AdminBridgeCtx, body: ServerConnectionBody) -> Result { + ctx.gateway_writes + .disable_server_v2(body.space_id, body.server_id) + .await +} + +pub async fn start_auth_v2(ctx: &AdminBridgeCtx, body: ServerConnectionBody) -> Result { + ctx.gateway_writes + .start_auth_v2(body.space_id, body.server_id) + .await +} + +pub async fn cancel_auth_v2(ctx: &AdminBridgeCtx, body: ServerConnectionBody) -> Result { + ctx.gateway_writes + .cancel_auth_v2(body.space_id, body.server_id) + .await +} + +pub async fn retry_connection(ctx: &AdminBridgeCtx, body: ServerConnectionBody) -> Result { + ctx.gateway_writes + .retry_connection(body.space_id, body.server_id) + .await +} + +pub async fn update_server_package( + ctx: &AdminBridgeCtx, + body: ServerConnectionBody, +) -> Result { + ctx.gateway_writes + .update_server_package(body.space_id, body.server_id) + .await +} + +pub async fn logout_server(ctx: &AdminBridgeCtx, body: ServerConnectionBody) -> Result { + ctx.gateway_writes + .logout_server(body.space_id, body.server_id) + .await +} + +pub async fn respond_to_meta_tool_approval( + ctx: &AdminBridgeCtx, + body: MetaToolApprovalBody, +) -> Result { + ctx.gateway_writes + .respond_to_meta_tool_approval( + body.request_id, + body.client_id, + body.tool_name, + body.decision, + ) + .await +} + +pub async fn revoke_meta_tool_grant( + ctx: &AdminBridgeCtx, + body: MetaToolRevokeBody, +) -> Result { + ctx.gateway_writes + .revoke_meta_tool_grant(body.client_id, body.tool_name) + .await +} + +pub async fn update_oauth_client( + ctx: &AdminBridgeCtx, + client_id: String, + body: OAuthClientUpdateBody, +) -> Result { + ctx.gateway_writes + .update_oauth_client(client_id, body.client_alias) + .await +} + +pub async fn delete_oauth_client(ctx: &AdminBridgeCtx, client_id: String) -> Result { + ctx.gateway_writes.delete_oauth_client(client_id).await +} + +pub async fn grant_oauth_client_feature_set( + ctx: &AdminBridgeCtx, + client_id: String, + body: OAuthGrantBody, +) -> Result { + ctx.gateway_writes + .grant_oauth_client_feature_set(client_id, body.space_id, body.feature_set_id) + .await +} + +pub async fn revoke_oauth_client_feature_set( + ctx: &AdminBridgeCtx, + client_id: String, + body: OAuthGrantBody, +) -> Result { + ctx.gateway_writes + .revoke_oauth_client_feature_set(client_id, body.space_id, body.feature_set_id) + .await +} + +/// Probe npm/PyPI for a single installed server package update. +pub async fn check_server_version( + _ctx: &AdminBridgeCtx, + _body: ServerConnectionBody, +) -> Result { + // ponytail: version probing lands in Phase 5 + Err(anyhow!("Server version checking not yet available")) +} + +/// Probe all notify/auto package-managed servers for available updates. +pub async fn check_all_server_versions(_ctx: &AdminBridgeCtx) -> Result { + // ponytail: version probing lands in Phase 5 + Err(anyhow!("Server version checking not yet available")) +} diff --git a/crates/mcpmux-gateway/src/admin/config.rs b/crates/mcpmux-gateway/src/admin/config.rs new file mode 100644 index 00000000..20b5c527 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/config.rs @@ -0,0 +1,72 @@ +//! Admin server configuration. + +use std::sync::Arc; + +use super::middleware::CfAccessValidator; + +/// Default admin listen port (loopback + CF tunnel). +pub const DEFAULT_ADMIN_PORT: u16 = 45819; + +/// Cloudflare Access JWT header forwarded by the tunnel edge. +pub const CF_ACCESS_JWT_HEADER: &str = "CF-Access-Jwt-Assertion"; + +/// Admin HTTP server configuration. +#[derive(Clone)] +pub struct AdminConfig { + /// Host to bind to (default loopback). + pub host: String, + /// Port to listen on. + pub port: u16, + /// Require and validate `CF-Access-Jwt-Assertion` when true. + /// + /// When enabled, **all** routes including `/api/v1/health` require a valid JWT, + /// or matching `CF-Access-Client-Id` / `CF-Access-Client-Secret` service-token + /// headers when `MCPMUX_CF_ACCESS_CLIENT_ID` and `MCPMUX_CF_ACCESS_CLIENT_SECRET` + /// are set in the admin process environment. + /// Cloudflare Tunnel origin health probes do not send `CF-Access-Jwt-Assertion`; + /// do not rely on tunnel health checks against the admin origin — use an external + /// monitor or a separate unauthenticated probe path if needed. + pub trust_cf_access: bool, + /// Cloudflare team domain for JWT cert validation (e.g. `myteam`). + pub cf_team_domain: Option, + /// Optional CF Access application AUD tag. + pub cf_access_audience: Option, + /// Inject a validator (integration tests); skips cert fetch when set. + pub cf_validator_override: Option>, +} + +impl std::fmt::Debug for AdminConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AdminConfig") + .field("host", &self.host) + .field("port", &self.port) + .field("trust_cf_access", &self.trust_cf_access) + .field("cf_team_domain", &self.cf_team_domain) + .field("cf_access_audience", &self.cf_access_audience) + .field( + "cf_validator_override", + &self.cf_validator_override.is_some(), + ) + .finish() + } +} + +impl Default for AdminConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: DEFAULT_ADMIN_PORT, + trust_cf_access: false, + cf_team_domain: None, + cf_access_audience: None, + cf_validator_override: None, + } + } +} + +impl AdminConfig { + /// Socket address string for binding. + pub fn bind_addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} diff --git a/crates/mcpmux-gateway/src/admin/event_hub.rs b/crates/mcpmux-gateway/src/admin/event_hub.rs new file mode 100644 index 00000000..510b6951 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/event_hub.rs @@ -0,0 +1,152 @@ +//! Admin SSE event hub — fans in EventBus, gateway domain events, and direct emits. + +use std::sync::Arc; + +use mcpmux_core::{ApplicationServices, DomainEvent, EventReceiver}; +use parking_lot::Mutex; +use tokio::sync::{broadcast, RwLock}; +use tokio::task::JoinHandle; +use tracing::{debug, info}; + +use super::ui_events::{map_domain_event_to_ui, AdminUiEventBus, UiEvent}; + +/// Merged outbound bus for admin SSE clients. +#[derive(Clone)] +pub struct AdminEventHub { + outbound: broadcast::Sender, + ui_event_bus: Arc, + services_task: Arc>>>, + direct_ui_task: Arc>>>, + gateway_task: Arc>>>, +} + +impl AdminEventHub { + /// Create a new admin event hub. + pub fn new(ui_event_bus: Arc) -> Self { + let (outbound, _) = broadcast::channel(512); + Self { + outbound, + ui_event_bus, + services_task: Arc::new(Mutex::new(None)), + direct_ui_task: Arc::new(Mutex::new(None)), + gateway_task: Arc::new(RwLock::new(None)), + } + } + + /// Direct UI event bus for Tauri `app.emit` fan-in. + pub fn ui_event_bus(&self) -> Arc { + self.ui_event_bus.clone() + } + + /// Subscribe to merged UI events for SSE streaming. + pub fn subscribe(&self) -> broadcast::Receiver { + self.outbound.subscribe() + } + + /// Publish a mapped domain event to SSE subscribers. + fn publish_domain(&self, event: DomainEvent) { + let (channel, payload) = map_domain_event_to_ui(&event); + let _ = self.outbound.send(UiEvent { + channel: channel.to_string(), + payload, + }); + } + + /// Abort a fan-in task if it is still running. + fn abort_task(slot: &mut Option>) { + if let Some(handle) = slot.take() { + handle.abort(); + } + } + + /// Start background fan-in from ApplicationServices EventBus and direct UI bus. + pub fn start(&self, services: Arc) { + { + let mut slot = self.services_task.lock(); + Self::abort_task(&mut slot); + } + + let hub = self.clone(); + let handle = tokio::spawn(async move { + let mut rx: EventReceiver = services.subscribe(); + info!("[AdminEventHub] ApplicationServices EventBus fan-in started"); + while let Some(event) = rx.recv().await { + hub.publish_domain(event); + } + debug!("[AdminEventHub] ApplicationServices EventBus fan-in stopped"); + }); + *self.services_task.lock() = Some(handle); + + { + let mut slot = self.direct_ui_task.lock(); + Self::abort_task(&mut slot); + } + + let hub = self.clone(); + let mut direct_rx = self.ui_event_bus.subscribe(); + let handle = tokio::spawn(async move { + info!("[AdminEventHub] Direct UI event fan-in started"); + loop { + match direct_rx.recv().await { + Ok(event) => { + let _ = hub.outbound.send(event); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + debug!("[AdminEventHub] Direct UI event fan-in stopped"); + }); + *self.direct_ui_task.lock() = Some(handle); + } + + /// Subscribe to gateway runtime domain events when the MCP gateway starts. + pub async fn register_gateway_events(&self, domain_event_tx: broadcast::Sender) { + if let Some(handle) = self.gateway_task.write().await.take() { + handle.abort(); + } + + let hub = self.clone(); + let handle = tokio::spawn(async move { + let mut rx = domain_event_tx.subscribe(); + info!("[AdminEventHub] Gateway domain event fan-in started"); + loop { + match rx.recv().await { + Ok(event) => hub.publish_domain(event), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + debug!("[AdminEventHub] Gateway domain event fan-in stopped"); + }); + + *self.gateway_task.write().await = Some(handle); + } + + /// Stop gateway domain event fan-in when the MCP gateway stops. + pub async fn clear_gateway_events(&self) { + if let Some(handle) = self.gateway_task.write().await.take() { + handle.abort(); + } + } + + /// Test-only publish of a direct channel event (integration tests / Playwright). + #[cfg(any(test, feature = "test-utils"))] + pub fn publish_test_event(&self, channel: &str, payload: serde_json::Value) { + self.ui_event_bus.publish(channel, payload); + } + + /// Count active EventBus fan-in tasks (integration tests for router rebuild idempotency). + #[cfg(any(test, feature = "test-utils"))] + pub fn active_fan_in_task_count(&self) -> usize { + let services = self.services_task.lock().is_some() as usize; + let direct = self.direct_ui_task.lock().is_some() as usize; + services + direct + } +} + +impl Default for AdminEventHub { + fn default() -> Self { + Self::new(Arc::new(AdminUiEventBus::new())) + } +} diff --git a/crates/mcpmux-gateway/src/admin/handlers/error.rs b/crates/mcpmux-gateway/src/admin/handlers/error.rs new file mode 100644 index 00000000..b4e2317b --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/error.rs @@ -0,0 +1,53 @@ +//! Admin API error helpers. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +/// HTTP error wrapper that always serializes as `{ "error": "" }`. +pub struct ApiError { + pub status: StatusCode, + pub message: String, +} + +impl ApiError { + pub fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + pub fn service_unavailable(message: impl Into) -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + message: message.into(), + } + } + + /// Converts bridge errors to HTTP JSON while preserving sentinel strings + /// like `PORT_IN_USE::` in the message field. + pub fn from_bridge(error: anyhow::Error) -> Self { + Self::internal(error.to_string()) + } +} + +/// Shared formatter used by tests to assert sentinel message preservation. +#[cfg(any(test, feature = "test-utils"))] +pub fn format_bridge_error_message(error: anyhow::Error) -> String { + ApiError::from_bridge(error).message +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} diff --git a/crates/mcpmux-gateway/src/admin/handlers/events.rs b/crates/mcpmux-gateway/src/admin/handlers/events.rs new file mode 100644 index 00000000..ff0602af --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/events.rs @@ -0,0 +1,83 @@ +//! Admin SSE event stream (`GET /api/v1/events`). + +use std::convert::Infallible; +use std::time::Duration; + +use async_stream::stream; +use axum::{ + extract::State, + response::sse::{Event, KeepAlive, Sse}, +}; +use futures::Stream; +use tracing::{debug, warn}; + +use super::super::router::AdminState; + +/// SSE stream bridging merged admin UI events to web clients. +pub async fn sse_events( + State(state): State, +) -> Sse>> { + let mut rx = state.event_hub.subscribe(); + + let stream = stream! { + loop { + match rx.recv().await { + Ok(ui_event) => { + debug!( + channel = %ui_event.channel, + "[Admin] SSE forwarding UI event" + ); + match Event::default() + .event(ui_event.channel) + .json_data(ui_event.payload) + { + Ok(event) => yield Ok(event), + Err(e) => warn!("[Admin] dropped non-serializable SSE event: {e}"), + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + debug!("[Admin] SSE client lagged, skipped {skipped} events"); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }; + + Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keepalive"), + ) +} + +#[cfg(any(test, feature = "test-utils"))] +mod test_publish { + use super::*; + use axum::{http::StatusCode, Json}; + use serde::Deserialize; + + /// Request body for test-only SSE publish endpoint. + #[derive(Debug, Deserialize)] + pub struct TestPublishEventRequest { + pub channel: String, + pub payload: serde_json::Value, + } + + /// Test-only endpoint to publish UI events for Playwright SSE smoke tests. + pub async fn publish_test_event( + State(state): State, + Json(body): Json, + ) -> StatusCode { + if std::env::var("MCPMUX_ADMIN_TEST").is_err() { + return StatusCode::NOT_FOUND; + } + state + .event_hub + .publish_test_event(&body.channel, body.payload); + StatusCode::NO_CONTENT + } +} + +#[cfg(any(test, feature = "test-utils"))] +pub use test_publish::publish_test_event; diff --git a/crates/mcpmux-gateway/src/admin/handlers/health.rs b/crates/mcpmux-gateway/src/admin/handlers/health.rs new file mode 100644 index 00000000..ca54a8e9 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/health.rs @@ -0,0 +1,24 @@ +//! Admin health endpoint. + +use axum::{extract::State, Json}; +use serde::Serialize; +use std::sync::atomic::Ordering; + +use super::super::router::AdminState; + +/// JSON body for `GET /api/v1/health`. +#[derive(Debug, Serialize)] +pub struct HealthResponse { + /// Always `"ok"` when the admin server is reachable. + pub status: &'static str, + /// Whether the MCP gateway process reports itself as running. + pub gateway_running: bool, +} + +/// Returns admin and gateway liveness for tunnel health checks. +pub async fn health(State(state): State) -> Json { + Json(HealthResponse { + status: "ok", + gateway_running: state.gateway_running.load(Ordering::Relaxed), + }) +} diff --git a/crates/mcpmux-gateway/src/admin/handlers/mod.rs b/crates/mcpmux-gateway/src/admin/handlers/mod.rs new file mode 100644 index 00000000..e12baadc --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/mod.rs @@ -0,0 +1,11 @@ +//! Admin REST handlers (Phase 2+). + +pub mod error; +pub mod events; +pub mod health; +pub mod oauth; +pub mod read; +pub mod spa; +pub mod write; + +pub use health::health; diff --git a/crates/mcpmux-gateway/src/admin/handlers/oauth.rs b/crates/mcpmux-gateway/src/admin/handlers/oauth.rs new file mode 100644 index 00000000..c48841d4 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/oauth.rs @@ -0,0 +1,68 @@ +//! OAuth consent admin REST handlers (web admin only). + +use axum::extract::{Query, State}; +use axum::Json; +use serde::Deserialize; +use serde_json::Value; + +use crate::admin::command_bridge::oauth as bridge; +use crate::admin::command_bridge::oauth::OAuthConsentBody; +use crate::admin::handlers::error::ApiError; +use crate::admin::router::AdminState; + +#[derive(Debug, Deserialize)] +pub struct PendingConsentQuery { + #[serde(rename = "requestId")] + pub request_id: String, +} + +fn ok(value: Value) -> Json { + Json(value) +} + +fn consent_error(err: anyhow::Error) -> ApiError { + let message = err.to_string(); + if message.contains("Invalid consent token") || message.contains("Consent token") { + return ApiError::bad_request(message); + } + if message.starts_with("NOT_FOUND") || message.starts_with("EXPIRED") { + return ApiError::bad_request(message); + } + if message.contains("Gateway not running") { + return ApiError::service_unavailable(message); + } + ApiError::from_bridge(err) +} + +/// GET /api/v1/oauth/consent/pending — load validated consent details for the modal. +pub async fn get_pending_consent( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::get_pending_consent(&state.bridge, query.request_id) + .await + .map(ok) + .map_err(consent_error) +} + +/// POST /api/v1/oauth/consent/approve — approve pending OAuth consent (CSRF required). +pub async fn approve_oauth_consent( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::approve_oauth_consent(&state.bridge, body) + .await + .map(ok) + .map_err(consent_error) +} + +/// POST /api/v1/oauth/consent/reject — deny pending OAuth consent (CSRF required). +pub async fn reject_oauth_consent( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::reject_oauth_consent(&state.bridge, body) + .await + .map(ok) + .map_err(consent_error) +} diff --git a/crates/mcpmux-gateway/src/admin/handlers/read.rs b/crates/mcpmux-gateway/src/admin/handlers/read.rs new file mode 100644 index 00000000..4ae9865f --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/read.rs @@ -0,0 +1,609 @@ +//! Read-only admin REST handlers delegating to command bridge functions. + +use axum::extract::{Path, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::Value; +use tracing::warn; + +use crate::admin::command_bridge::read as bridge; +use crate::admin::handlers::error::ApiError; +use crate::admin::router::AdminState; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceQuery { + pub space_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProbeQuery { + pub port: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateRootQuery { + pub path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveFeaturesQuery { + pub workspace_root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IconPathQuery { + pub icon_ref: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerLogsQuery { + pub limit: Option, + pub level_filter: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerFeatureQuery { + pub space_id: String, + pub include_unavailable: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerFeatureByServerQuery { + pub space_id: String, + pub server_id: String, + pub include_unavailable: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerFeatureByTypeQuery { + pub space_id: String, + pub server_id: String, + pub feature_type: String, + pub include_unavailable: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneAvailabilityQuery { + pub space_id: String, + pub source_server_id: String, + pub suffix: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneSuggestQuery { + pub space_id: String, + pub source_server_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneDependentsQuery { + pub space_id: String, + pub source_server_id: String, +} + +fn ok(value: Value) -> Json { + Json(value) +} + +pub async fn get_gateway_status( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::get_gateway_status(&state.bridge, query.space_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn probe_gateway_start( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::probe_gateway_start(&state.bridge, query.port) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn take_pending_port_conflict( + State(state): State, +) -> Result, ApiError> { + bridge::take_pending_port_conflict(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_gateway_port_settings( + State(state): State, +) -> Result, ApiError> { + bridge::get_gateway_port_settings(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn reset_gateway_port(State(state): State) -> Result, ApiError> { + bridge::reset_gateway_port(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_connected_servers( + State(state): State, +) -> Result, ApiError> { + bridge::list_connected_servers(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_pool_stats(State(state): State) -> Result, ApiError> { + bridge::get_pool_stats(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_server_statuses( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let space_id = query + .space_id + .ok_or_else(|| ApiError::bad_request("spaceId query parameter is required"))?; + match bridge::get_server_statuses(&state.bridge, space_id.clone()).await { + Ok(value) => Ok(ok(value)), + Err(error) => { + warn!("[Admin] get_server_statuses failed for space {space_id}: {error:#}"); + Err(ApiError::from_bridge(error)) + } + } +} + +pub async fn list_spaces(State(state): State) -> Result, ApiError> { + match bridge::list_spaces(&state.bridge).await { + Ok(value) => Ok(ok(value)), + Err(error) => { + warn!("[Admin] list_spaces failed: {error:#}"); + Err(ApiError::from_bridge(error)) + } + } +} + +pub async fn get_space( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::get_space(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn read_space_config( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::read_space_config(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_installed_servers( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::list_installed_servers(&state.bridge, query.space_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn discover_servers(State(state): State) -> Result, ApiError> { + bridge::discover_servers(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_server_definition( + State(state): State, + Path(server_id): Path, +) -> Result, ApiError> { + bridge::get_server_definition(&state.bridge, server_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_registry_ui_config( + State(state): State, +) -> Result, ApiError> { + bridge::get_registry_ui_config(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_registry_home_config( + State(state): State, +) -> Result, ApiError> { + bridge::get_registry_home_config(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn is_registry_offline(State(state): State) -> Result, ApiError> { + bridge::is_registry_offline(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_clients(State(state): State) -> Result, ApiError> { + bridge::list_clients(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_client( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::get_client(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_feature_sets(State(state): State) -> Result, ApiError> { + bridge::list_feature_sets(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_feature_sets_by_space( + State(state): State, + Path(space_id): Path, +) -> Result, ApiError> { + bridge::list_feature_sets_by_space(&state.bridge, space_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_feature_set( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::get_feature_set(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_feature_set_with_members( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::get_feature_set_with_members(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_workspace_bindings( + State(state): State, +) -> Result, ApiError> { + bridge::list_workspace_bindings(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_workspace_bindings_for_space( + State(state): State, + Path(space_id): Path, +) -> Result, ApiError> { + bridge::list_workspace_bindings_for_space(&state.bridge, space_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_reported_workspace_roots( + State(state): State, +) -> Result, ApiError> { + bridge::list_reported_workspace_roots(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn validate_workspace_root( + Query(query): Query, +) -> Result, ApiError> { + bridge::validate_workspace_root(query.path) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_workspace_effective_features( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::get_workspace_effective_features(&state.bridge, query.workspace_root) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_workspace_appearances( + State(state): State, +) -> Result, ApiError> { + bridge::list_workspace_appearances(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn resolve_workspace_icon_path( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::resolve_workspace_icon_path(&state.bridge, query.icon_ref) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +/// Stream a workspace icon PNG for web admin (`local:workspace-icons/…` refs). +pub async fn serve_workspace_icon( + State(state): State, + Query(query): Query, +) -> Response { + let Some(path) = bridge::workspace_icon_path(&state.bridge.data_dir, &query.icon_ref) else { + return StatusCode::NOT_FOUND.into_response(); + }; + + match tokio::fs::read(&path).await { + Ok(bytes) => ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "image/png"), + (header::CACHE_CONTROL, "private, max-age=3600"), + ], + bytes, + ) + .into_response(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + StatusCode::NOT_FOUND.into_response() + } + Err(err) => { + warn!( + path = %path.display(), + error = %err, + "[Admin] failed to read workspace icon" + ); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn get_startup_settings( + State(state): State, +) -> Result, ApiError> { + bridge::get_startup_settings(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_server_update_settings( + State(state): State, +) -> Result, ApiError> { + bridge::get_server_update_settings(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_meta_tools_enabled( + State(state): State, +) -> Result, ApiError> { + bridge::get_meta_tools_enabled(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_version(State(state): State) -> Result, ApiError> { + bridge::get_version(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_bundle_version(State(state): State) -> Result, ApiError> { + bridge::get_bundle_version(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_build_info(State(state): State) -> Result, ApiError> { + bridge::get_build_info(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_logs_path(State(state): State) -> Result, ApiError> { + bridge::get_logs_path(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_server_logs( + State(state): State, + Path(server_id): Path, + Query(query): Query, +) -> Result, ApiError> { + bridge::get_server_logs(&state.bridge, server_id, query.limit, query.level_filter) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_server_log_file( + State(state): State, + Path(server_id): Path, +) -> Result, ApiError> { + bridge::get_server_log_file(&state.bridge, server_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_log_retention_days( + State(state): State, +) -> Result, ApiError> { + bridge::get_log_retention_days(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_oauth_clients(State(state): State) -> Result, ApiError> { + bridge::get_oauth_clients(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_oauth_client_grants( + State(state): State, + Path((client_id, space_id)): Path<(String, String)>, +) -> Result, ApiError> { + bridge::get_oauth_client_grants(&state.bridge, client_id, space_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_meta_tool_grants( + State(state): State, +) -> Result, ApiError> { + bridge::list_meta_tool_grants(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_server_features( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::list_server_features(&state.bridge, query.space_id, query.include_unavailable) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_server_features_by_server( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::list_server_features_by_server( + &state.bridge, + query.space_id, + query.server_id, + query.include_unavailable, + ) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_server_features_by_type( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::list_server_features_by_type( + &state.bridge, + query.space_id, + query.server_id, + query.feature_type, + query.include_unavailable, + ) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn get_server_feature( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::get_server_feature(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn is_clone_id_available( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::is_clone_id_available( + &state.bridge, + query.space_id, + query.source_server_id, + query.suffix, + ) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn suggest_clone_suffix( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::suggest_clone_suffix(&state.bridge, query.space_id, query.source_server_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn list_clone_dependents( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + bridge::list_clone_dependents(&state.bridge, query.space_id, query.source_server_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} diff --git a/crates/mcpmux-gateway/src/admin/handlers/spa.rs b/crates/mcpmux-gateway/src/admin/handlers/spa.rs new file mode 100644 index 00000000..c95bc09f --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/spa.rs @@ -0,0 +1,34 @@ +//! Static SPA fallback when the production build is missing. + +use axum::http::{header, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; + +const MISSING_SPA_HTML: &str = r#" + + + + + McpMux Web Admin — build required + + + +

Web admin UI not built

+

The admin HTTP server is running, but index.html was not found in the configured frontend dist directory.

+

From the repo root, run:

+
pnpm build:web:admin
+

Then restart web admin mode in McpMux Settings (or restart the desktop app).

+ +"#; + +/// Fallback page when the SPA build is absent. +pub async fn missing_spa_build() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + Html(MISSING_SPA_HTML), + ) + .into_response() +} diff --git a/crates/mcpmux-gateway/src/admin/handlers/write.rs b/crates/mcpmux-gateway/src/admin/handlers/write.rs new file mode 100644 index 00000000..2e2247cc --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/handlers/write.rs @@ -0,0 +1,580 @@ +//! Write admin REST handlers delegating to command bridge functions. + +use axum::extract::{Path, State}; +use axum::Json; +use serde_json::Value; + +use crate::admin::command_bridge::space::UpdateSpaceInput; +use crate::admin::command_bridge::write as bridge; +use crate::admin::command_bridge::write::{ + AddMemberBody, CloneServerBody, CreateClientBody, CreateFeatureSetBody, CreateSpaceBody, + DisconnectServerBody, GatewayPortBody, GatewayPublicUrlBody, GatewayStartBody, + InstallServerBody, LogRetentionBody, MetaToolApprovalBody, MetaToolRevokeBody, + MetaToolsEnabledBody, OAuthClientUpdateBody, OAuthGrantBody, SaveServerInputsBody, + SaveSpaceConfigBody, ServerConnectionBody, ServerUpdateSettingsBody, SetMembersBody, + SetServerDisplayNameBody, SetServerOAuthConnectedBody, StartupSettingsBody, + UninstallServerBody, UpdateFeatureSetBody, UploadIconBody, WorkspaceAppearanceBody, + WorkspaceBindingBody, +}; +use crate::admin::handlers::error::ApiError; +use crate::admin::router::AdminState; + +fn ok(value: Value) -> Json { + Json(value) +} + +pub async fn create_space( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::create_space(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_space( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, ApiError> { + bridge::update_space(&state.bridge, id, input) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn delete_space( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::delete_space(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn save_space_config( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::save_space_config(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn remove_server_from_config( + State(state): State, + Path((space_id, server_id)): Path<(String, String)>, +) -> Result, ApiError> { + bridge::remove_server_from_config(&state.bridge, space_id, server_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn start_gateway( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::start_gateway(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn stop_gateway(State(state): State) -> Result, ApiError> { + bridge::stop_gateway(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn restart_gateway( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::restart_gateway(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn disconnect_server( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::disconnect_server(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn connect_all_enabled_servers( + State(state): State, +) -> Result, ApiError> { + bridge::connect_all_enabled_servers(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn refresh_oauth_tokens_on_startup( + State(state): State, +) -> Result, ApiError> { + bridge::refresh_oauth_tokens_on_startup(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_gateway_port( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_gateway_port(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_gateway_public_url( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_gateway_public_url(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn install_server( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::install_server(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn uninstall_server( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::uninstall_server(&state.bridge, id, body.space_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn save_server_inputs( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::save_server_inputs(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_server_display_name( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_server_display_name(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_server_oauth_connected( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_server_oauth_connected(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn enable_server_v2( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::enable_server_v2(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn disable_server_v2( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::disable_server_v2(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn start_auth_v2( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::start_auth_v2(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn cancel_auth_v2( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::cancel_auth_v2(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn retry_connection( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::retry_connection(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_server_package( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::update_server_package(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn logout_server( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::logout_server(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn clone_server( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::clone_server(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn create_feature_set( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::create_feature_set(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_feature_set( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::update_feature_set(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn delete_feature_set( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::delete_feature_set(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn add_feature_set_member( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::add_feature_set_member(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn remove_feature_set_member( + State(state): State, + Path((id, member_id)): Path<(String, String)>, +) -> Result, ApiError> { + bridge::remove_feature_set_member(&state.bridge, id, member_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_feature_set_members( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_feature_set_members(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn create_client( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::create_client(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn delete_client( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::delete_client(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn init_preset_clients(State(state): State) -> Result, ApiError> { + bridge::init_preset_clients(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn create_workspace_binding( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::create_workspace_binding(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_workspace_binding( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::update_workspace_binding(&state.bridge, id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn delete_workspace_binding( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + bridge::delete_workspace_binding(&state.bridge, id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn upsert_workspace_appearance( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::upsert_workspace_appearance(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn delete_workspace_appearance( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::delete_workspace_appearance(&state.bridge, body.workspace_root) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn upload_workspace_icon( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::upload_workspace_icon(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_startup_settings( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::update_startup_settings(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_server_update_settings( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::update_server_update_settings(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_meta_tools_enabled( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_meta_tools_enabled(&state.bridge, body.enabled) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn clear_server_logs( + State(state): State, + Path(server_id): Path, +) -> Result, ApiError> { + bridge::clear_server_logs(&state.bridge, server_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn set_log_retention_days( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::set_log_retention_days(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn refresh_registry(State(state): State) -> Result, ApiError> { + bridge::refresh_registry(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn respond_to_meta_tool_approval( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::respond_to_meta_tool_approval(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn revoke_meta_tool_grant( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + bridge::revoke_meta_tool_grant(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn update_oauth_client( + State(state): State, + Path(client_id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::update_oauth_client(&state.bridge, client_id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn delete_oauth_client( + State(state): State, + Path(client_id): Path, +) -> Result, ApiError> { + bridge::delete_oauth_client(&state.bridge, client_id) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn grant_oauth_client_feature_set( + State(state): State, + Path(client_id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::grant_oauth_client_feature_set(&state.bridge, client_id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn revoke_oauth_client_feature_set( + State(state): State, + Path(client_id): Path, + Json(body): Json, +) -> Result, ApiError> { + bridge::revoke_oauth_client_feature_set(&state.bridge, client_id, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn check_server_version( + State(state): State, + Path(server_id): Path, + Json(body): Json, +) -> Result, ApiError> { + let body = bridge::ServerConnectionBody { + space_id: body.space_id, + server_id, + }; + bridge::check_server_version(&state.bridge, body) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} + +pub async fn check_all_server_versions( + State(state): State, +) -> Result, ApiError> { + bridge::check_all_server_versions(&state.bridge) + .await + .map(ok) + .map_err(ApiError::from_bridge) +} diff --git a/crates/mcpmux-gateway/src/admin/live_runtime.rs b/crates/mcpmux-gateway/src/admin/live_runtime.rs new file mode 100644 index 00000000..e2f7b3c9 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/live_runtime.rs @@ -0,0 +1,215 @@ +//! Admin read runtime backed by a live [`GatewayServer`]. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use mcpmux_core::{is_port_available, GatewayPortService}; +use serde_json::{json, Value}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::admin::runtime::GatewayRuntime; +use crate::pool::{ConnectionStatus, PoolService, ServerManager}; +use crate::server::{GatewayServer, GatewayState}; +use crate::services::{ApprovalBroker, GrantService, SessionRootsRegistry}; + +/// Admin read runtime wired to a running MCP gateway. +pub struct LiveGatewayRuntime { + gateway_state: Arc>, + gateway_port_service: Arc, + listen_url: String, + pool_service: Arc, + server_manager: Arc, + session_roots: Arc, + approval_broker: Arc, + grant_service: Arc, +} + +impl LiveGatewayRuntime { + /// Connect admin bridge reads to an active gateway server instance. + pub fn from_gateway_server( + server: &GatewayServer, + gateway_port_service: Arc, + listen_url: impl Into, + ) -> Self { + Self { + gateway_state: server.state(), + gateway_port_service, + listen_url: listen_url.into(), + pool_service: server.pool_service(), + server_manager: server.server_manager(), + session_roots: server.session_roots(), + approval_broker: server.approval_broker(), + grant_service: server.grant_service(), + } + } +} + +#[async_trait] +impl GatewayRuntime for LiveGatewayRuntime { + async fn get_gateway_status(&self, space_id: Option) -> Result { + let active_sessions = self.gateway_state.read().await.sessions.len(); + let connected_backends = if let Some(space_id) = space_id { + let space_uuid = Uuid::parse_str(&space_id)?; + self.server_manager + .connected_count_for_space(&space_uuid) + .await + } else { + self.server_manager.connected_count().await + }; + + Ok(json!({ + "running": true, + "url": self.listen_url, + "active_sessions": active_sessions, + "connected_backends": connected_backends, + })) + } + + async fn probe_gateway_start(&self, port: Option) -> Result { + let (preferred_port, source) = if let Some(port) = port { + (port, "override") + } else if let Some(port) = self.gateway_port_service.load_persisted_port().await { + (port, "configured") + } else { + (mcpmux_core::DEFAULT_GATEWAY_PORT, "default") + }; + Ok(json!({ + "preferredPort": preferred_port, + "preferredAvailable": is_port_available(preferred_port), + "source": source, + })) + } + + async fn take_pending_port_conflict(&self) -> Result { + Ok(Value::Null) + } + + async fn get_gateway_port_settings(&self) -> Result { + let configured_port = self.gateway_port_service.load_persisted_port().await; + let active_port = self + .listen_url + .split("://") + .nth(1) + .and_then(|host_port| host_port.split('/').next()) + .and_then(|host_port| host_port.rsplit(':').next()) + .and_then(|port| port.parse::().ok()); + Ok(json!({ + "configuredPort": configured_port, + "defaultPort": mcpmux_core::DEFAULT_GATEWAY_PORT, + "activePort": active_port, + })) + } + + async fn reset_gateway_port(&self) -> Result { + self.gateway_port_service.clear_persisted_port().await?; + Ok(json!({ "ok": true })) + } + + async fn list_connected_servers(&self) -> Result { + Ok(json!([])) + } + + async fn get_pool_stats(&self) -> Result { + let stats = self.pool_service.stats(); + Ok(json!({ + "total_instances": stats.total_instances, + "connected_instances": stats.connected_instances, + "total_space_server_mappings": stats.connecting_instances + + stats.failed_instances + + stats.oauth_pending_instances, + })) + } + + async fn list_reported_workspace_roots(&self) -> Result { + Ok(json!(self.session_roots.list_all_roots())) + } + + async fn list_meta_tool_grants(&self) -> Result { + Ok(json!(self + .approval_broker + .list_always_allow() + .into_iter() + .map(|(client_id, tool_name)| json!({ + "client_id": client_id, + "tool_name": tool_name, + })) + .collect::>())) + } + + async fn get_oauth_clients(&self) -> Result { + let gateway_state = self.gateway_state.read().await; + let Some(repository) = gateway_state.inbound_client_repository() else { + return Err(anyhow::anyhow!("Database not available")); + }; + let clients = repository.list_clients().await?; + let approved = clients + .into_iter() + .filter(|client| client.approved) + .map(|client| { + json!({ + "client_id": client.client_id, + "registration_type": client.registration_type.as_str(), + "client_name": client.client_name, + "client_alias": client.client_alias, + "redirect_uris": client.redirect_uris, + "scope": client.scope, + "approved": client.approved, + "logo_uri": client.logo_uri, + "client_uri": client.client_uri, + "software_id": client.software_id, + "software_version": client.software_version, + "metadata_url": client.metadata_url, + "metadata_cached_at": client.metadata_cached_at, + "metadata_cache_ttl": client.metadata_cache_ttl, + "last_seen": client.last_seen, + "created_at": client.created_at, + "reports_roots": client.reports_roots, + "roots_capability_known": client.roots_capability_known, + }) + }) + .collect::>(); + Ok(json!(approved)) + } + + async fn get_oauth_client_grants(&self, client_id: String, space_id: String) -> Result { + Ok(json!( + self.grant_service + .get_grants_for_space(&client_id, &space_id) + .await? + )) + } + + async fn get_server_statuses(&self, space_id: String) -> Result { + let space_uuid = + Uuid::parse_str(&space_id).map_err(|e| anyhow::anyhow!("Invalid space_id: {e}"))?; + let statuses = self.server_manager.get_all_statuses(space_uuid).await; + let mut result = serde_json::Map::new(); + for (server_id, (status, flow_id, has_connected_before, message)) in statuses { + result.insert( + server_id.clone(), + json!({ + "server_id": server_id, + "status": connection_status_to_ui(status), + "flow_id": flow_id, + "has_connected_before": has_connected_before, + "message": message, + }), + ); + } + Ok(json!(result)) + } +} + +fn connection_status_to_ui(status: ConnectionStatus) -> &'static str { + match status { + ConnectionStatus::Disconnected => "disconnected", + ConnectionStatus::Connecting => "connecting", + ConnectionStatus::Connected => "connected", + ConnectionStatus::Refreshing => "refreshing", + ConnectionStatus::AuthRequired => "oauth_required", + ConnectionStatus::Authenticating => "authenticating", + ConnectionStatus::Error => "error", + } +} diff --git a/crates/mcpmux-gateway/src/admin/middleware/cf_access.rs b/crates/mcpmux-gateway/src/admin/middleware/cf_access.rs new file mode 100644 index 00000000..ef0f4ae1 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/middleware/cf_access.rs @@ -0,0 +1,388 @@ +//! Cloudflare Access JWT validation for the admin HTTP server. +//! +//! When `trust_cf_access` is enabled, requests must include a valid +//! `CF-Access-Jwt-Assertion` header signed by Cloudflare team certs, or +//! matching `CF-Access-Client-Id` / `CF-Access-Client-Secret` service-token +//! headers when `MCPMUX_CF_ACCESS_CLIENT_ID` and `MCPMUX_CF_ACCESS_CLIENT_SECRET` +//! are set in the environment. + +use axum::{ + extract::{Request, State}, + http::{HeaderMap, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; +use subtle::ConstantTimeEq; +use thiserror::Error; +use tracing::debug; + +#[cfg(any(test, feature = "test-utils"))] +use std::sync::Arc; + +use super::super::config::CF_ACCESS_JWT_HEADER; +use super::super::router::AdminState; + +/// Service-token headers Cloudflare Access accepts at the edge. +pub const CF_ACCESS_CLIENT_ID_HEADER: &str = "cf-access-client-id"; +pub const CF_ACCESS_CLIENT_SECRET_HEADER: &str = "cf-access-client-secret"; + +/// Env vars for optional origin-side service-token auth (tunnel smoke / automation). +pub const CF_ACCESS_CLIENT_ID_ENV: &str = "MCPMUX_CF_ACCESS_CLIENT_ID"; +pub const CF_ACCESS_CLIENT_SECRET_ENV: &str = "MCPMUX_CF_ACCESS_CLIENT_SECRET"; + +#[cfg(any(test, feature = "test-utils"))] +/// PEM-encoded RSA public key used only by test helpers. +const TEST_RSA_PUBLIC_PEM: &str = + include_str!("../../../../../tests/fixtures/cf_access_test_pubkey.pem"); + +#[cfg(any(test, feature = "test-utils"))] +/// PEM-encoded RSA private key used only by test helpers. +const TEST_RSA_PRIVATE_PEM: &str = + include_str!("../../../../../tests/fixtures/cf_access_test_private.pem"); + +/// Errors from CF Access JWT validation. +#[derive(Debug, Error)] +pub enum CfAccessError { + /// JWT header or signature could not be parsed or verified. + #[error("invalid JWT: {0}")] + InvalidJwt(String), + /// No matching decoding key for the token `kid`. + #[error("unknown key id: {0}")] + UnknownKeyId(String), + /// Cert fetch or configuration error. + #[error("{0}")] + Config(String), +} + +/// Validated Cloudflare Access JWT claims (subset used for checks). +#[derive(Debug, Deserialize)] +pub struct CfAccessClaims { + /// Subject (user email or service identity). + pub sub: String, + /// Issuer (`https://.cloudflareaccess.com`). + pub iss: String, + /// Audience (application AUD tag). + pub aud: serde_json::Value, + /// Expiration (unix seconds). + pub exp: i64, +} + +impl std::fmt::Debug for CfAccessValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CfAccessValidator") + .field("keys", &self.keys.len()) + .field("issuer", &self.issuer) + .field("audience", &self.audience) + .finish() + } +} + +/// Validates `CF-Access-Jwt-Assertion` tokens against Cloudflare team certs. +pub struct CfAccessValidator { + keys: Vec, + issuer: Option, + audience: Option, +} + +impl CfAccessValidator { + /// Build a validator from PEM-encoded RSA public certificates. + pub fn from_pem_certs( + certs: Vec, + issuer: Option, + audience: Option, + ) -> Result { + let mut keys = Vec::new(); + for cert in certs { + let key = DecodingKey::from_rsa_pem(cert.as_bytes()) + .map_err(|e| CfAccessError::Config(format!("invalid PEM cert: {e}")))?; + keys.push(key); + } + if keys.is_empty() { + return Err(CfAccessError::Config("no certificates provided".into())); + } + Ok(Self { + keys, + issuer, + audience, + }) + } + + /// Fetch team certs from Cloudflare and build a validator. + /// + /// CF's `/cdn-cgi/access/certs` endpoint returns three representations of + /// the same signing material: + /// - `keys` — JWKS format (`{kid, kty, alg, use, e, n}`) — standard JWT key + /// format directly consumable by `DecodingKey::from_jwk` + /// - `public_cert` / `public_certs` — X.509 certificates in PEM form + /// + /// We use `keys` because `jsonwebtoken::DecodingKey::from_rsa_pem` does + /// NOT support X.509 certificate PEM — only PKCS#1 RSA Public Key or + /// PKCS#8 SubjectPublicKeyInfo. Feeding it a full X.509 cert produces a + /// malformed key that fails every signature verification with + /// `InvalidSignature`. JWKS sidesteps that entirely. + pub async fn from_team_domain( + team_domain: &str, + audience: Option, + ) -> Result { + let url = format!("https://{team_domain}.cloudflareaccess.com/cdn-cgi/access/certs"); + let issuer = format!("https://{team_domain}.cloudflareaccess.com"); + let response = reqwest::get(&url) + .await + .map_err(|e| CfAccessError::Config(format!("cert fetch failed: {e}")))?; + if !response.status().is_success() { + return Err(CfAccessError::Config(format!( + "cert fetch returned {}", + response.status() + ))); + } + let body: CertsResponse = response + .json() + .await + .map_err(|e| CfAccessError::Config(format!("cert JSON parse failed: {e}")))?; + if body.keys.is_empty() { + return Err(CfAccessError::Config( + "CF certs response has no JWKS keys".into(), + )); + } + let mut keys = Vec::with_capacity(body.keys.len()); + for jwk in body.keys { + let key = DecodingKey::from_jwk(&jwk) + .map_err(|e| CfAccessError::Config(format!("invalid JWK from CF Access: {e}")))?; + keys.push(key); + } + Ok(Self { + keys, + issuer: Some(issuer), + audience, + }) + } + + /// Validate a JWT string and return decoded claims. + /// + /// `validate_aud` is gated on whether an audience is configured. The + /// `jsonwebtoken` crate defaults `validate_aud` to `true`, which rejects + /// tokens carrying an `aud` claim when the validator's audience set is + /// empty — even when the signature and issuer are valid. CF Access JWTs + /// always carry `aud` (the application UUID), so leaving the default in + /// place breaks every token when the operator has not pasted the AUD tag. + pub fn validate(&self, token: &str) -> Result { + let header = decode_header(token).map_err(|e| CfAccessError::InvalidJwt(e.to_string()))?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.validate_exp = true; + validation.validate_aud = self.audience.is_some(); + if let Some(ref iss) = self.issuer { + validation.set_issuer(&[iss.as_str()]); + } + if let Some(ref aud) = self.audience { + validation.set_audience(&[aud.as_str()]); + } + + let mut last_err: Option = None; + for key in &self.keys { + match decode::(token, key, &validation) { + Ok(token_data) => { + debug!(sub = %token_data.claims.sub, "CF Access JWT validated"); + return Ok(token_data.claims); + } + Err(e) => { + last_err = Some(CfAccessError::InvalidJwt(e.to_string())); + } + } + } + + if let Some(err) = last_err { + return Err(err); + } + + let kid = header.kid.unwrap_or_else(|| "unknown".into()); + Err(CfAccessError::UnknownKeyId(kid)) + } +} + +/// Cloudflare Access `/cdn-cgi/access/certs` response shape. +/// +/// We deserialize only `keys` (the JWKS) and ignore the X.509 `public_cert` / +/// `public_certs` fields. See `from_team_domain` for why. +#[derive(Debug, Deserialize)] +struct CertsResponse { + #[serde(default)] + keys: Vec, +} + +/// Axum middleware: require valid CF Access JWT when enabled in config. +pub async fn cf_access_middleware( + State(state): State, + headers: HeaderMap, + request: Request, + next: Next, +) -> Response { + if !state.config.trust_cf_access { + return next.run(request).await; + } + + let Some(validator) = state.cf_validator.as_ref() else { + return cf_access_unauthorized("CF Access validation not configured"); + }; + + let token = headers + .get(CF_ACCESS_JWT_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + match token { + Some(jwt) => match validator.validate(jwt) { + Ok(_) => next.run(request).await, + Err(e) => { + debug!(error = %e, "CF Access JWT rejected"); + cf_access_unauthorized("invalid CF Access token") + } + }, + None if service_token_matches(&headers) => { + debug!("CF Access service token accepted from env-configured credentials"); + next.run(request).await + } + None => cf_access_unauthorized("missing CF-Access-Jwt-Assertion"), + } +} + +/// Return true when request service-token headers match env-configured credentials. +pub fn service_token_matches(headers: &HeaderMap) -> bool { + let Ok(expected_id) = std::env::var(CF_ACCESS_CLIENT_ID_ENV) else { + return false; + }; + let Ok(expected_secret) = std::env::var(CF_ACCESS_CLIENT_SECRET_ENV) else { + return false; + }; + if expected_id.is_empty() || expected_secret.is_empty() { + return false; + } + + let Some(id) = header_value(headers, CF_ACCESS_CLIENT_ID_HEADER) else { + return false; + }; + let Some(secret) = header_value(headers, CF_ACCESS_CLIENT_SECRET_HEADER) else { + return false; + }; + + constant_time_eq(id, &expected_id) && constant_time_eq(secret, &expected_secret) +} + +fn header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + left.as_bytes().ct_eq(right.as_bytes()).into() +} + +fn cf_access_unauthorized(message: &str) -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": message })), + ) + .into_response() +} + +/// Test-only validator backed by the repo fixture key pair. +#[cfg(any(test, feature = "test-utils"))] +#[doc(hidden)] +pub fn test_validator() -> Arc { + Arc::new( + CfAccessValidator::from_pem_certs( + vec![TEST_RSA_PUBLIC_PEM.to_string()], + Some("https://test.cloudflareaccess.com".into()), + Some("test-audience".into()), + ) + .expect("test validator"), + ) +} + +/// Test-only signed JWT accepted by [`test_validator`]. +#[cfg(any(test, feature = "test-utils"))] +#[doc(hidden)] +pub fn test_valid_jwt() -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + + let claims = serde_json::json!({ + "sub": "test@example.com", + "iss": "https://test.cloudflareaccess.com", + "aud": "test-audience", + "exp": chrono::Utc::now().timestamp() + 3600, + }); + let key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_PEM.as_bytes()).expect("test private key"); + encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign test jwt") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_accepts_test_fixture_jwt() { + let validator = test_validator(); + let jwt = test_valid_jwt(); + assert!(validator.validate(&jwt).is_ok()); + } + + #[test] + fn validate_rejects_missing_signature() { + let validator = test_validator(); + let err = validator.validate("not.a.jwt").unwrap_err(); + assert!(matches!(err, CfAccessError::InvalidJwt(_))); + } + + #[test] + fn validate_rejects_wrong_audience() { + let validator = CfAccessValidator::from_pem_certs( + vec![TEST_RSA_PUBLIC_PEM.to_string()], + Some("https://test.cloudflareaccess.com".into()), + Some("other-audience".into()), + ) + .unwrap(); + let jwt = test_valid_jwt(); + let err = validator.validate(&jwt).unwrap_err(); + assert!(matches!(err, CfAccessError::InvalidJwt(_))); + } + + #[test] + fn from_pem_certs_rejects_empty_list() { + let err = CfAccessValidator::from_pem_certs(vec![], None, None).unwrap_err(); + assert!(matches!(err, CfAccessError::Config(_))); + } + + #[test] + fn from_pem_certs_rejects_invalid_pem() { + let err = + CfAccessValidator::from_pem_certs(vec!["not-a-cert".into()], None, None).unwrap_err(); + assert!(matches!(err, CfAccessError::Config(_))); + } + + #[test] + fn service_token_matches_env_headers() { + std::env::set_var(CF_ACCESS_CLIENT_ID_ENV, "svc-id"); + std::env::set_var(CF_ACCESS_CLIENT_SECRET_ENV, "svc-secret"); + + let mut headers = HeaderMap::new(); + headers.insert(CF_ACCESS_CLIENT_ID_HEADER, "svc-id".parse().unwrap()); + headers.insert( + CF_ACCESS_CLIENT_SECRET_HEADER, + "svc-secret".parse().unwrap(), + ); + assert!(service_token_matches(&headers)); + + headers.insert(CF_ACCESS_CLIENT_SECRET_HEADER, "wrong".parse().unwrap()); + assert!(!service_token_matches(&headers)); + + std::env::remove_var(CF_ACCESS_CLIENT_ID_ENV); + std::env::remove_var(CF_ACCESS_CLIENT_SECRET_ENV); + } +} diff --git a/crates/mcpmux-gateway/src/admin/middleware/csrf.rs b/crates/mcpmux-gateway/src/admin/middleware/csrf.rs new file mode 100644 index 00000000..930199db --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/middleware/csrf.rs @@ -0,0 +1,85 @@ +//! CSRF token middleware for admin mutating HTTP routes. + +use axum::{ + extract::{Request, State}, + http::{Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use parking_lot::Mutex; +use serde_json::json; +use std::sync::Arc; +use subtle::ConstantTimeEq; +use tracing::debug; +use uuid::Uuid; + +use super::super::router::AdminState; + +/// Header name clients must send on POST/PUT/DELETE requests. +pub const CSRF_HEADER: &str = "X-CSRF-Token"; + +/// Generate a fresh random CSRF token. +pub fn generate_csrf_token() -> String { + Uuid::new_v4().to_string() +} + +/// Return the current CSRF token for SPA bootstrap. +pub async fn get_csrf_token(State(state): State) -> Json { + let token = state.csrf_token.lock().clone(); + Json(json!({ "token": token })) +} + +fn is_csrf_exempt(method: &Method, path: &str) -> bool { + if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) { + return true; + } + matches!( + path, + "/api/v1/csrf-token" | "/api/v1/health" | "/api/v1/events" + ) +} + +/// Require matching `X-CSRF-Token` on mutating requests. +pub async fn csrf_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + + if is_csrf_exempt(&method, &path) { + return next.run(request).await; + } + + if !matches!( + method, + Method::POST | Method::PUT | Method::DELETE | Method::PATCH + ) { + return next.run(request).await; + } + + let expected = state.csrf_token.lock().clone(); + let provided = request + .headers() + .get(CSRF_HEADER) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + + if provided.as_bytes().ct_ne(expected.as_bytes()).into() { + debug!(path = %path, "[Admin] CSRF token mismatch"); + return ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "Invalid or missing CSRF token" })), + ) + .into_response(); + } + + next.run(request).await +} + +/// Shared CSRF token storage for admin state construction. +pub fn new_csrf_token_store() -> Arc> { + Arc::new(Mutex::new(generate_csrf_token())) +} diff --git a/crates/mcpmux-gateway/src/admin/middleware/mod.rs b/crates/mcpmux-gateway/src/admin/middleware/mod.rs new file mode 100644 index 00000000..a55881c0 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/middleware/mod.rs @@ -0,0 +1,11 @@ +//! Admin HTTP middleware. + +pub mod cf_access; +pub mod csrf; + +pub use cf_access::{cf_access_middleware, CfAccessError, CfAccessValidator}; +pub use csrf::{csrf_middleware, get_csrf_token, new_csrf_token_store, CSRF_HEADER}; + +#[cfg(any(test, feature = "test-utils"))] +#[doc(hidden)] +pub use cf_access::{test_valid_jwt, test_validator}; diff --git a/crates/mcpmux-gateway/src/admin/mod.rs b/crates/mcpmux-gateway/src/admin/mod.rs new file mode 100644 index 00000000..59139541 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/mod.rs @@ -0,0 +1,40 @@ +//! Web admin HTTP server (REST + static SPA). +//! +//! Serves the built React admin UI and `/api/v1/*` REST endpoints on a +//! separate loopback port (default `45819`), gated by Cloudflare Access +//! when configured. + +pub mod bridge_context; +pub mod command_bridge; +mod config; +pub mod event_hub; +mod handlers; +mod live_runtime; +mod middleware; +mod router; +pub mod runtime; +mod server; +pub mod ui_events; +pub mod write_runtime; + +pub use bridge_context::{AdminBridgeCtx, BackendBuildStamp}; +pub use config::{AdminConfig, CF_ACCESS_JWT_HEADER, DEFAULT_ADMIN_PORT}; +pub use event_hub::AdminEventHub; +#[cfg(any(test, feature = "test-utils"))] +pub use handlers::error::format_bridge_error_message; +pub use live_runtime::LiveGatewayRuntime; +pub use middleware::{new_csrf_token_store, CfAccessError, CfAccessValidator, CSRF_HEADER}; +pub use router::{build_admin_router, AdminState}; +pub use runtime::GatewayRuntime; +#[cfg(any(test, feature = "test-utils"))] +pub use runtime::StubGatewayRuntime; +pub use server::{AdminServer, AdminServerHandle}; +pub use ui_events::{map_domain_event_to_ui, AdminUiEventBus, UiEvent}; +pub use write_runtime::GatewayWriteRuntime; +pub use write_runtime::LiveGatewayWriteRuntime; +#[cfg(any(test, feature = "test-utils"))] +pub use write_runtime::StubGatewayWriteRuntime; + +#[cfg(any(test, feature = "test-utils"))] +#[doc(hidden)] +pub use middleware::{test_valid_jwt, test_validator}; diff --git a/crates/mcpmux-gateway/src/admin/router.rs b/crates/mcpmux-gateway/src/admin/router.rs new file mode 100644 index 00000000..65071165 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/router.rs @@ -0,0 +1,379 @@ +//! Admin Axum router — health, static SPA, API routes. + +use axum::{ + middleware, + routing::{delete, get, post, put}, + Router, +}; +use mcpmux_core::ApplicationServices; +use parking_lot::Mutex; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use tower_http::services::{ServeDir, ServeFile}; +use tracing::warn; + +use super::config::AdminConfig; +use super::event_hub::AdminEventHub; +use super::handlers::{events, health, oauth, read, spa, write}; +use super::middleware::{cf_access_middleware, csrf_middleware, get_csrf_token, CfAccessValidator}; +use crate::admin::bridge_context::AdminBridgeCtx; + +/// Shared state for admin HTTP handlers. +#[derive(Clone)] +pub struct AdminState { + /// Application services (same instance as Tauri commands). + pub services: Arc, + /// Admin server configuration. + pub config: AdminConfig, + /// MCP gateway running flag (updated by desktop when gateway starts/stops). + pub gateway_running: Arc, + /// Directory containing built frontend assets (`index.html`, etc.). + pub frontend_dist: PathBuf, + /// CF Access JWT validator when `trust_cf_access` is enabled. + pub cf_validator: Option>, + /// Shared read bridge context used by REST handlers. + pub bridge: Arc, + /// Merged EventBus + direct UI event fan-in for SSE. + pub event_hub: Arc, + /// CSRF token for mutating requests. + pub csrf_token: Arc>, +} + +/// Build the admin router with health, API stubs, and SPA static fallback. +pub fn build_admin_router(state: AdminState) -> Router { + state.event_hub.start(state.services.clone()); + + let mut router = Router::new() + .route("/api/v1/health", get(health)) + .route("/api/v1/csrf-token", get(get_csrf_token)) + .route("/api/v1/events", get(events::sse_events)) + .route("/api/v1/gateway/status", get(read::get_gateway_status)) + .route( + "/api/v1/gateway/probe-start", + get(read::probe_gateway_start), + ) + .route( + "/api/v1/gateway/pending-port-conflict", + get(read::take_pending_port_conflict), + ) + .route( + "/api/v1/gateway/port-settings", + get(read::get_gateway_port_settings), + ) + .route("/api/v1/gateway/reset-port", get(read::reset_gateway_port)) + .route( + "/api/v1/gateway/connected-servers", + get(read::list_connected_servers), + ) + .route("/api/v1/gateway/pool-stats", get(read::get_pool_stats)) + .route("/api/v1/gateway/start", post(write::start_gateway)) + .route("/api/v1/gateway/stop", post(write::stop_gateway)) + .route("/api/v1/gateway/restart", post(write::restart_gateway)) + .route("/api/v1/gateway/disconnect", post(write::disconnect_server)) + .route( + "/api/v1/gateway/connect-all", + post(write::connect_all_enabled_servers), + ) + .route( + "/api/v1/gateway/refresh-oauth-tokens", + post(write::refresh_oauth_tokens_on_startup), + ) + .route("/api/v1/gateway/port", put(write::set_gateway_port)) + .route( + "/api/v1/gateway/public-url", + put(write::set_gateway_public_url), + ) + .route( + "/api/v1/spaces", + get(read::list_spaces).post(write::create_space), + ) + .route( + "/api/v1/spaces/{id}", + get(read::get_space) + .put(write::update_space) + .delete(write::delete_space), + ) + .route( + "/api/v1/spaces/{id}/config", + get(read::read_space_config).put(write::save_space_config), + ) + .route( + "/api/v1/spaces/{space_id}/config/servers/{server_id}", + delete(write::remove_server_from_config), + ) + .route( + "/api/v1/servers/installed", + get(read::list_installed_servers), + ) + .route("/api/v1/servers/install", post(write::install_server)) + .route("/api/v1/servers/{id}", delete(write::uninstall_server)) + .route( + "/api/v1/servers/{id}/inputs", + put(write::save_server_inputs), + ) + .route( + "/api/v1/servers/{id}/display-name", + put(write::set_server_display_name), + ) + .route( + "/api/v1/servers/{id}/oauth-connected", + put(write::set_server_oauth_connected), + ) + .route( + "/api/v1/servers/connections", + get(read::get_server_statuses), + ) + .route( + "/api/v1/servers/connections/enable", + post(write::enable_server_v2), + ) + .route( + "/api/v1/servers/connections/disable", + post(write::disable_server_v2), + ) + .route( + "/api/v1/servers/connections/start-auth", + post(write::start_auth_v2), + ) + .route( + "/api/v1/servers/connections/cancel-auth", + post(write::cancel_auth_v2), + ) + .route( + "/api/v1/servers/connections/retry", + post(write::retry_connection), + ) + .route( + "/api/v1/servers/connections/update-package", + post(write::update_server_package), + ) + .route( + "/api/v1/servers/connections/logout", + post(write::logout_server), + ) + .route( + "/api/v1/servers/updates/check-all", + post(write::check_all_server_versions), + ) + .route( + "/api/v1/servers/{server_id}/updates/check", + post(write::check_server_version), + ) + .route("/api/v1/servers/clones", post(write::clone_server)) + .route("/api/v1/registry/discover", get(read::discover_servers)) + .route( + "/api/v1/registry/definition/{server_id}", + get(read::get_server_definition), + ) + .route( + "/api/v1/registry/ui-config", + get(read::get_registry_ui_config), + ) + .route( + "/api/v1/registry/home-config", + get(read::get_registry_home_config), + ) + .route("/api/v1/registry/offline", get(read::is_registry_offline)) + .route("/api/v1/registry/refresh", post(write::refresh_registry)) + .route( + "/api/v1/clients", + get(read::list_clients).post(write::create_client), + ) + .route( + "/api/v1/clients/{id}", + get(read::get_client).delete(write::delete_client), + ) + .route( + "/api/v1/clients/init-presets", + post(write::init_preset_clients), + ) + .route( + "/api/v1/feature-sets", + get(read::list_feature_sets).post(write::create_feature_set), + ) + .route( + "/api/v1/feature-sets/by-space/{space_id}", + get(read::list_feature_sets_by_space), + ) + .route( + "/api/v1/feature-sets/{id}", + get(read::get_feature_set) + .put(write::update_feature_set) + .delete(write::delete_feature_set), + ) + .route( + "/api/v1/feature-sets/{id}/with-members", + get(read::get_feature_set_with_members), + ) + .route( + "/api/v1/feature-sets/{id}/members", + post(write::add_feature_set_member).put(write::set_feature_set_members), + ) + .route( + "/api/v1/feature-sets/{id}/members/{member_id}", + delete(write::remove_feature_set_member), + ) + .route( + "/api/v1/workspaces/bindings", + get(read::list_workspace_bindings).post(write::create_workspace_binding), + ) + .route( + "/api/v1/workspaces/bindings/space/{space_id}", + get(read::list_workspace_bindings_for_space), + ) + .route( + "/api/v1/workspaces/bindings/{id}", + put(write::update_workspace_binding).delete(write::delete_workspace_binding), + ) + .route( + "/api/v1/workspaces/reported-roots", + get(read::list_reported_workspace_roots), + ) + .route( + "/api/v1/workspaces/validate-root", + get(read::validate_workspace_root), + ) + .route( + "/api/v1/workspaces/effective-features", + get(read::get_workspace_effective_features), + ) + .route( + "/api/v1/workspaces/appearances", + get(read::list_workspace_appearances) + .post(write::upload_workspace_icon) + .put(write::upsert_workspace_appearance) + .delete(write::delete_workspace_appearance), + ) + .route( + "/api/v1/workspaces/icon-path", + get(read::resolve_workspace_icon_path), + ) + .route("/api/v1/workspaces/icon", get(read::serve_workspace_icon)) + .route( + "/api/v1/settings/startup", + get(read::get_startup_settings).put(write::update_startup_settings), + ) + .route( + "/api/v1/settings/server-updates", + get(read::get_server_update_settings).put(write::update_server_update_settings), + ) + .route( + "/api/v1/settings/meta-tools-enabled", + get(read::get_meta_tools_enabled).put(write::set_meta_tools_enabled), + ) + .route("/api/v1/app/version", get(read::get_version)) + .route("/api/v1/app/bundle-version", get(read::get_bundle_version)) + .route("/api/v1/app/build-info", get(read::get_build_info)) + .route("/api/v1/app/logs-path", get(read::get_logs_path)) + .route( + "/api/v1/logs/server/{server_id}", + get(read::get_server_logs).delete(write::clear_server_logs), + ) + .route( + "/api/v1/logs/server/{server_id}/file", + get(read::get_server_log_file), + ) + .route( + "/api/v1/logs/retention-days", + get(read::get_log_retention_days).put(write::set_log_retention_days), + ) + .route("/api/v1/oauth/clients", get(read::get_oauth_clients)) + .route( + "/api/v1/oauth/clients/{client_id}", + put(write::update_oauth_client).delete(write::delete_oauth_client), + ) + .route( + "/api/v1/oauth/clients/{client_id}/grants", + post(write::grant_oauth_client_feature_set), + ) + .route( + "/api/v1/oauth/clients/{client_id}/grants/revoke", + post(write::revoke_oauth_client_feature_set), + ) + .route( + "/api/v1/oauth/clients/{client_id}/grants/{space_id}", + get(read::get_oauth_client_grants), + ) + .route( + "/api/v1/oauth/consent/pending", + get(oauth::get_pending_consent), + ) + .route( + "/api/v1/oauth/consent/approve", + post(oauth::approve_oauth_consent), + ) + .route( + "/api/v1/oauth/consent/reject", + post(oauth::reject_oauth_consent), + ) + .route( + "/api/v1/meta-tools/grants", + get(read::list_meta_tool_grants), + ) + .route( + "/api/v1/meta-tools/approval", + post(write::respond_to_meta_tool_approval), + ) + .route( + "/api/v1/meta-tools/grants/revoke", + post(write::revoke_meta_tool_grant), + ) + .route("/api/v1/server-features", get(read::list_server_features)) + .route( + "/api/v1/server-features/by-server", + get(read::list_server_features_by_server), + ) + .route( + "/api/v1/server-features/by-type", + get(read::list_server_features_by_type), + ) + .route( + "/api/v1/server-features/{id}", + get(read::get_server_feature), + ) + .route( + "/api/v1/servers/clones/available", + get(read::is_clone_id_available), + ) + .route( + "/api/v1/servers/clones/suggest", + get(read::suggest_clone_suffix), + ) + .route( + "/api/v1/servers/clones/dependents", + get(read::list_clone_dependents), + ); + + #[cfg(any(test, feature = "test-utils"))] + { + router = router.route( + "/api/v1/test/events/publish", + post(events::publish_test_event), + ); + } + + if state.frontend_dist.join("index.html").is_file() { + let index = state.frontend_dist.join("index.html"); + let static_files = + ServeDir::new(&state.frontend_dist).not_found_service(ServeFile::new(index)); + router = router.fallback_service(static_files); + } else { + warn!( + "[Admin] frontend dist missing index.html at {:?} — serving build hint page", + state.frontend_dist + ); + router = router.fallback(get(spa::missing_spa_build)); + } + + router + .layer(middleware::from_fn_with_state( + state.clone(), + csrf_middleware, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + cf_access_middleware, + )) + .with_state(state) +} diff --git a/crates/mcpmux-gateway/src/admin/runtime.rs b/crates/mcpmux-gateway/src/admin/runtime.rs new file mode 100644 index 00000000..0f7e0e96 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/runtime.rs @@ -0,0 +1,102 @@ +//! Runtime adapter for gateway-state dependent admin reads. + +use anyhow::Result; +use async_trait::async_trait; +#[cfg(any(test, feature = "test-utils"))] +use serde_json::json; +use serde_json::Value; + +/// Async runtime adapter for reads that depend on live gateway state. +#[async_trait] +pub trait GatewayRuntime: Send + Sync { + async fn get_gateway_status(&self, _space_id: Option) -> Result; + async fn probe_gateway_start(&self, _port: Option) -> Result; + async fn take_pending_port_conflict(&self) -> Result; + async fn get_gateway_port_settings(&self) -> Result; + async fn reset_gateway_port(&self) -> Result; + async fn list_connected_servers(&self) -> Result; + async fn get_pool_stats(&self) -> Result; + async fn list_reported_workspace_roots(&self) -> Result; + async fn list_meta_tool_grants(&self) -> Result; + async fn get_oauth_clients(&self) -> Result; + async fn get_oauth_client_grants(&self, _client_id: String, _space_id: String) + -> Result; + async fn get_server_statuses(&self, _space_id: String) -> Result; +} + +/// Test/default runtime that returns empty or safe defaults. +#[cfg(any(test, feature = "test-utils"))] +pub struct StubGatewayRuntime; + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait] +impl GatewayRuntime for StubGatewayRuntime { + async fn get_gateway_status(&self, _space_id: Option) -> Result { + Ok(json!({ + "running": false, + "url": null, + "active_sessions": 0, + "connected_backends": 0, + })) + } + + async fn probe_gateway_start(&self, _port: Option) -> Result { + Ok(json!({ + "preferredPort": mcpmux_core::DEFAULT_GATEWAY_PORT, + "preferredAvailable": true, + "source": "default", + })) + } + + async fn take_pending_port_conflict(&self) -> Result { + Ok(Value::Null) + } + + async fn get_gateway_port_settings(&self) -> Result { + Ok(json!({ + "configuredPort": null, + "defaultPort": mcpmux_core::DEFAULT_GATEWAY_PORT, + "activePort": null, + })) + } + + async fn reset_gateway_port(&self) -> Result { + Ok(json!({ "ok": true })) + } + + async fn list_connected_servers(&self) -> Result { + Ok(json!([])) + } + + async fn get_pool_stats(&self) -> Result { + Ok(json!({ + "total_instances": 0, + "connected_instances": 0, + "total_space_server_mappings": 0, + })) + } + + async fn list_reported_workspace_roots(&self) -> Result { + Ok(json!([])) + } + + async fn list_meta_tool_grants(&self) -> Result { + Ok(json!([])) + } + + async fn get_oauth_clients(&self) -> Result { + Ok(json!([])) + } + + async fn get_oauth_client_grants( + &self, + _client_id: String, + _space_id: String, + ) -> Result { + Ok(json!([])) + } + + async fn get_server_statuses(&self, _space_id: String) -> Result { + Ok(json!({})) + } +} diff --git a/crates/mcpmux-gateway/src/admin/server.rs b/crates/mcpmux-gateway/src/admin/server.rs new file mode 100644 index 00000000..3768a953 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/server.rs @@ -0,0 +1,117 @@ +//! Admin HTTP server lifecycle (bind, serve, graceful shutdown). + +use axum::Router; +use mcpmux_core::ApplicationServices; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use super::bridge_context::AdminBridgeCtx; +use super::config::AdminConfig; +use super::event_hub::AdminEventHub; +use super::middleware::new_csrf_token_store; +use super::middleware::CfAccessValidator; +use super::router::{build_admin_router, AdminState}; + +/// Running admin server handle for graceful shutdown. +pub struct AdminServerHandle { + pub task: tokio::task::JoinHandle>, + shutdown: CancellationToken, +} + +impl AdminServerHandle { + /// Signal the admin server to stop accepting new connections. + pub fn shutdown(&self) { + self.shutdown.cancel(); + } +} + +/// Web admin HTTP server (static SPA + `/api/v1/*`). +pub struct AdminServer { + config: AdminConfig, + router: Router, + bind_addr: SocketAddr, +} + +impl AdminServer { + /// Build the admin server without binding. + pub async fn new( + config: AdminConfig, + services: Arc, + bridge: Arc, + event_hub: Arc, + gateway_running: Arc, + frontend_dist: PathBuf, + cf_validator: Option>, + ) -> anyhow::Result { + let bind_addr: SocketAddr = config + .bind_addr() + .parse() + .map_err(|e| anyhow::anyhow!("invalid admin bind address: {e}"))?; + + let router = build_admin_router(AdminState { + services, + config: config.clone(), + gateway_running, + frontend_dist, + cf_validator, + bridge, + event_hub, + csrf_token: new_csrf_token_store(), + }); + + Ok(Self { + config, + router, + bind_addr, + }) + } + + /// Load CF Access validator from team domain when trust is enabled. + pub async fn build_cf_validator( + config: &AdminConfig, + ) -> anyhow::Result>> { + if !config.trust_cf_access { + return Ok(None); + } + if let Some(ref validator) = config.cf_validator_override { + return Ok(Some(validator.clone())); + } + let team = config.cf_team_domain.as_deref().ok_or_else(|| { + anyhow::anyhow!("admin_trust_cf_access requires gateway.admin_cf_team_domain") + })?; + let validator = + CfAccessValidator::from_team_domain(team, config.cf_access_audience.clone()).await?; + Ok(Some(Arc::new(validator))) + } + + /// Bind and serve until shutdown is cancelled. + pub async fn run_with_shutdown(self, shutdown: CancellationToken) -> anyhow::Result<()> { + let listener = TcpListener::bind(self.bind_addr).await?; + info!( + "[Admin] Listening on {} (cf_access={})", + self.bind_addr, self.config.trust_cf_access + ); + + axum::serve(listener, self.router) + .with_graceful_shutdown(async move { + shutdown.cancelled().await; + info!("[Admin] Graceful shutdown"); + }) + .await?; + + Ok(()) + } + + /// Start the admin server in the background. + pub fn spawn(self) -> AdminServerHandle { + let shutdown = CancellationToken::new(); + let token = shutdown.clone(); + let task = tokio::spawn(async move { self.run_with_shutdown(token).await }); + AdminServerHandle { task, shutdown } + } +} diff --git a/crates/mcpmux-gateway/src/admin/ui_events.rs b/crates/mcpmux-gateway/src/admin/ui_events.rs new file mode 100644 index 00000000..018d0acd --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/ui_events.rs @@ -0,0 +1,414 @@ +//! UI channel mapping and direct admin event bus for web SSE. + +use mcpmux_core::DomainEvent; +use serde_json::Value; +use tokio::sync::broadcast; +use tracing::warn; + +/// A UI-facing event ready for Tauri emit or SSE fan-out. +#[derive(Debug, Clone)] +pub struct UiEvent { + /// Tauri / SSE channel name (e.g. `space-changed`). + pub channel: String, + /// JSON payload matching the desktop Tauri emit shape. + pub payload: Value, +} + +/// Broadcast bus for events emitted directly from Tauri commands (`app.emit`) +/// without passing through the domain EventBus or gateway domain channel. +#[derive(Clone)] +pub struct AdminUiEventBus { + tx: broadcast::Sender, +} + +impl AdminUiEventBus { + /// Create a direct UI event bus with default capacity. + pub fn new() -> Self { + Self::with_capacity(256) + } + + /// Create a direct UI event bus with a custom channel capacity. + pub fn with_capacity(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity); + Self { tx } + } + + /// Publish a channel/payload pair to SSE subscribers. + pub fn publish(&self, channel: impl Into, payload: Value) { + let event = UiEvent { + channel: channel.into(), + payload, + }; + if self.tx.send(event).is_err() { + warn!("[AdminUiEventBus] No SSE subscribers for direct UI event"); + } + } + + /// Subscribe to direct UI events. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +impl Default for AdminUiEventBus { + fn default() -> Self { + Self::new() + } +} + +/// Map a `DomainEvent` to the Tauri channel name and JSON payload the React +/// hooks expect. Shared by the desktop EventBus bridge and admin SSE fan-in. +pub fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, Value) { + match event { + DomainEvent::SpaceCreated { + space_id, + name, + icon, + } => ( + "space-changed", + serde_json::json!({ + "action": "created", + "space_id": space_id, + "name": name, + "icon": icon, + }), + ), + DomainEvent::SpaceUpdated { space_id, name } => ( + "space-changed", + serde_json::json!({ + "action": "updated", + "space_id": space_id, + "name": name, + }), + ), + DomainEvent::SpaceDeleted { space_id } => ( + "space-changed", + serde_json::json!({ + "action": "deleted", + "space_id": space_id, + }), + ), + DomainEvent::ServerInstalled { + space_id, + server_id, + server_name, + } => ( + "server-changed", + serde_json::json!({ + "action": "installed", + "space_id": space_id, + "server_id": server_id, + "server_name": server_name, + }), + ), + DomainEvent::ServerUninstalled { + space_id, + server_id, + } => ( + "server-changed", + serde_json::json!({ + "action": "uninstalled", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::ServerConfigUpdated { + space_id, + server_id, + } => ( + "server-changed", + serde_json::json!({ + "action": "config_updated", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::ServerEnabled { + space_id, + server_id, + } => ( + "server-changed", + serde_json::json!({ + "action": "enabled", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::ServerDisabled { + space_id, + server_id, + } => ( + "server-changed", + serde_json::json!({ + "action": "disabled", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::ServerStatusChanged { + space_id, + server_id, + status, + flow_id, + has_connected_before, + message, + features, + } => ( + "server-status-changed", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + "status": status.as_str(), + "flow_id": flow_id, + "has_connected_before": has_connected_before, + "message": message, + "features": features.as_ref().map(|f| serde_json::json!({ + "tools_count": f.tools.len(), + "prompts_count": f.prompts.len(), + "resources_count": f.resources.len(), + })), + }), + ), + DomainEvent::ServerAuthProgress { + space_id, + server_id, + remaining_seconds, + flow_id, + } => ( + "server-auth-progress", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + "remaining_seconds": remaining_seconds, + "flow_id": flow_id, + }), + ), + DomainEvent::ServerFeaturesRefreshed { + space_id, + server_id, + features, + added, + removed, + } => ( + "server-features-refreshed", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + "tools_count": features.tools.len(), + "prompts_count": features.prompts.len(), + "resources_count": features.resources.len(), + "added": added, + "removed": removed, + }), + ), + DomainEvent::FeatureSetCreated { + space_id, + feature_set_id, + name, + feature_set_type, + } => ( + "feature-set-changed", + serde_json::json!({ + "action": "created", + "space_id": space_id, + "feature_set_id": feature_set_id, + "name": name, + "feature_set_type": feature_set_type, + }), + ), + DomainEvent::FeatureSetUpdated { + space_id, + feature_set_id, + name, + } => ( + "feature-set-changed", + serde_json::json!({ + "action": "updated", + "space_id": space_id, + "feature_set_id": feature_set_id, + "name": name, + }), + ), + DomainEvent::FeatureSetDeleted { + space_id, + feature_set_id, + } => ( + "feature-set-changed", + serde_json::json!({ + "action": "deleted", + "space_id": space_id, + "feature_set_id": feature_set_id, + }), + ), + DomainEvent::FeatureSetMembersChanged { + space_id, + feature_set_id, + added_count, + removed_count, + } => ( + "feature-set-changed", + serde_json::json!({ + "action": "members_changed", + "space_id": space_id, + "feature_set_id": feature_set_id, + "added_count": added_count, + "removed_count": removed_count, + }), + ), + DomainEvent::ClientRegistered { + client_id, + client_name, + registration_type, + } => ( + "client-changed", + serde_json::json!({ + "action": "registered", + "client_id": client_id, + "client_name": client_name, + "registration_type": registration_type, + }), + ), + DomainEvent::ClientReconnected { + client_id, + client_name, + } => ( + "client-changed", + serde_json::json!({ + "action": "reconnected", + "client_id": client_id, + "client_name": client_name, + }), + ), + DomainEvent::ClientUpdated { client_id } => ( + "client-changed", + serde_json::json!({ + "action": "updated", + "client_id": client_id, + }), + ), + DomainEvent::ClientDeleted { client_id } => ( + "client-changed", + serde_json::json!({ + "action": "deleted", + "client_id": client_id, + }), + ), + DomainEvent::ClientTokenIssued { client_id } => ( + "client-changed", + serde_json::json!({ + "action": "token_issued", + "client_id": client_id, + }), + ), + DomainEvent::GatewayStarted { url, port } => ( + "gateway-changed", + serde_json::json!({ + "action": "started", + "url": url, + "port": port, + }), + ), + DomainEvent::GatewayStopped => ( + "gateway-changed", + serde_json::json!({ + "action": "stopped", + }), + ), + DomainEvent::ToolsChanged { + space_id, + server_id, + } => ( + "mcp-notification", + serde_json::json!({ + "type": "tools_changed", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::PromptsChanged { + space_id, + server_id, + } => ( + "mcp-notification", + serde_json::json!({ + "type": "prompts_changed", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::ResourcesChanged { + space_id, + server_id, + } => ( + "mcp-notification", + serde_json::json!({ + "type": "resources_changed", + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::MetaToolInvoked { + client_id, + session_id, + tool_name, + decision, + resolved_feature_set_id, + summary, + } => ( + "meta-tool-invoked", + serde_json::json!({ + "client_id": client_id, + "session_id": session_id, + "tool_name": tool_name, + "decision": decision, + "resolved_feature_set_id": resolved_feature_set_id, + "summary": summary, + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + ), + DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root, + } => ( + "workspace-binding-changed", + serde_json::json!({ + "space_id": space_id, + "workspace_root": workspace_root, + }), + ), + DomainEvent::SessionRootsChanged => ("session-roots-changed", serde_json::json!({})), + DomainEvent::WorkspaceNeedsBinding { + client_id, + session_id, + space_id, + workspace_root, + space_locked, + } => ( + "workspace-needs-binding", + serde_json::json!({ + "client_id": client_id, + "session_id": session_id, + "space_id": space_id, + "workspace_root": workspace_root, + "space_locked": space_locked, + }), + ), + DomainEvent::ClientGrantChanged { + client_id, + space_id, + } => ( + "client-grant-changed", + serde_json::json!({ + "client_id": client_id, + "space_id": space_id, + }), + ), + DomainEvent::BuiltinServerConfigChanged { space_id } => ( + "server-changed", + serde_json::json!({ + "action": "config-changed", + "space_id": space_id, + }), + ), + } +} diff --git a/crates/mcpmux-gateway/src/admin/write_runtime.rs b/crates/mcpmux-gateway/src/admin/write_runtime.rs new file mode 100644 index 00000000..19c8cbc7 --- /dev/null +++ b/crates/mcpmux-gateway/src/admin/write_runtime.rs @@ -0,0 +1,444 @@ +//! Runtime adapter for gateway-dependent admin write operations. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use mcpmux_core::InstalledServerRepository; +use serde_json::{json, Value}; +use tokio::sync::RwLock; +use tracing::warn; +use uuid::Uuid; + +use crate::pool::transport::resolution::build_transport_config; +use crate::pool::{ + ConnectionContext, ConnectionResult, FeatureService, PoolService, ServerKey, ServerManager, +}; +use crate::server::GatewayServer; +use crate::GatewayState; + +/// Async runtime adapter for writes that depend on live gateway / desktop state. +#[async_trait] +pub trait GatewayWriteRuntime: Send + Sync { + async fn start_gateway( + &self, + port: Option, + allow_dynamic_fallback: Option, + ) -> Result; + async fn stop_gateway(&self) -> Result; + async fn restart_gateway( + &self, + port: Option, + allow_dynamic_fallback: Option, + ) -> Result; + async fn disconnect_server( + &self, + server_id: String, + space_id: String, + logout: Option, + ) -> Result; + async fn connect_all_enabled_servers(&self) -> Result; + async fn refresh_oauth_tokens_on_startup(&self) -> Result; + async fn set_gateway_port(&self, port: u16) -> Result; + async fn enable_server_v2(&self, space_id: String, server_id: String) -> Result; + async fn disable_server_v2(&self, space_id: String, server_id: String) -> Result; + async fn start_auth_v2(&self, space_id: String, server_id: String) -> Result; + async fn cancel_auth_v2(&self, space_id: String, server_id: String) -> Result; + async fn retry_connection(&self, space_id: String, server_id: String) -> Result; + async fn update_server_package(&self, space_id: String, server_id: String) -> Result; + async fn logout_server(&self, space_id: String, server_id: String) -> Result; + async fn respond_to_meta_tool_approval( + &self, + request_id: String, + client_id: String, + tool_name: String, + decision: String, + ) -> Result; + async fn revoke_meta_tool_grant(&self, client_id: String, tool_name: String) -> Result; + async fn update_oauth_client( + &self, + client_id: String, + client_alias: Option, + ) -> Result; + async fn delete_oauth_client(&self, client_id: String) -> Result; + async fn grant_oauth_client_feature_set( + &self, + client_id: String, + space_id: String, + feature_set_id: String, + ) -> Result; + async fn revoke_oauth_client_feature_set( + &self, + client_id: String, + space_id: String, + feature_set_id: String, + ) -> Result; + /// Live gateway state for inbound OAuth consent (web admin). + async fn gateway_state(&self) -> Option>>; +} + +fn gateway_write_unavailable() -> anyhow::Error { + anyhow!("Gateway write operation not implemented for this runtime") +} + +/// Headless admin write runtime backed by a live [`GatewayServer`]. +pub struct LiveGatewayWriteRuntime { + gateway_state: Arc>, + pool_service: Arc, + server_manager: Arc, + feature_service: Arc, + installed_server_repo: Arc, + data_dir: PathBuf, +} + +impl LiveGatewayWriteRuntime { + /// Wire headless admin writes to an active gateway server instance. + pub fn from_gateway_server( + server: &GatewayServer, + data_dir: PathBuf, + installed_server_repo: Arc, + ) -> Self { + Self { + gateway_state: server.state(), + pool_service: server.pool_service(), + server_manager: server.server_manager(), + feature_service: server.feature_service(), + installed_server_repo, + data_dir, + } + } +} + +#[async_trait] +impl GatewayWriteRuntime for LiveGatewayWriteRuntime { + async fn start_gateway( + &self, + _port: Option, + _allow_dynamic_fallback: Option, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn stop_gateway(&self) -> Result { + Err(gateway_write_unavailable()) + } + + async fn restart_gateway( + &self, + _port: Option, + _allow_dynamic_fallback: Option, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn disconnect_server( + &self, + _server_id: String, + _space_id: String, + _logout: Option, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn connect_all_enabled_servers(&self) -> Result { + Err(gateway_write_unavailable()) + } + + async fn refresh_oauth_tokens_on_startup(&self) -> Result { + Err(gateway_write_unavailable()) + } + + async fn set_gateway_port(&self, _port: u16) -> Result { + Err(gateway_write_unavailable()) + } + + async fn enable_server_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn disable_server_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn start_auth_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn cancel_auth_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn retry_connection(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn update_server_package(&self, space_id: String, server_id: String) -> Result { + let space_uuid = Uuid::parse_str(&space_id)?; + + self.pool_service.remove_instance(space_uuid, &server_id); + + let installed = self + .installed_server_repo + .get_by_server_id(&space_id, &server_id) + .await? + .ok_or_else(|| anyhow!("Server not found: {space_id}/{server_id}"))?; + + let server_definition = installed + .get_definition() + .ok_or_else(|| anyhow!("Server {server_id} has no cached definition"))?; + + self.installed_server_repo + .set_enabled(&installed.id, true) + .await?; + + let key = ServerKey::new(space_uuid, &server_id); + self.server_manager.set_connecting(&key).await; + + let transport = build_transport_config( + &server_definition.transport, + &installed, + Some(&self.data_dir), + ); + + let ctx = ConnectionContext::auto(space_uuid, server_id.clone(), transport); + let result = self.pool_service.connect_server(&ctx).await; + + match result { + ConnectionResult::Connected { features, .. } => { + self.server_manager.set_connected(&key, features).await; + } + ConnectionResult::OAuthRequired { .. } => { + self.server_manager.set_auth_required(&key, None).await; + if let Err(error) = self + .feature_service + .mark_unavailable(&space_id, &server_id) + .await + { + warn!("[LiveGatewayWriteRuntime] Failed to mark features unavailable: {error}"); + } + } + ConnectionResult::Failed { error } => { + self.server_manager.set_error(&key, error.clone()).await; + if let Err(mark_error) = self + .feature_service + .mark_unavailable(&space_id, &server_id) + .await + { + warn!( + "[LiveGatewayWriteRuntime] Failed to mark features unavailable: {mark_error}" + ); + } + return Err(anyhow!(error)); + } + } + + Ok(json!({ "ok": true })) + } + + async fn logout_server(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn respond_to_meta_tool_approval( + &self, + _request_id: String, + _client_id: String, + _tool_name: String, + _decision: String, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn revoke_meta_tool_grant( + &self, + _client_id: String, + _tool_name: String, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn update_oauth_client( + &self, + _client_id: String, + _client_alias: Option, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn delete_oauth_client(&self, _client_id: String) -> Result { + Err(gateway_write_unavailable()) + } + + async fn grant_oauth_client_feature_set( + &self, + _client_id: String, + _space_id: String, + _feature_set_id: String, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn revoke_oauth_client_feature_set( + &self, + _client_id: String, + _space_id: String, + _feature_set_id: String, + ) -> Result { + Err(gateway_write_unavailable()) + } + + async fn gateway_state(&self) -> Option>> { + Some(self.gateway_state.clone()) + } +} + +#[cfg(any(test, feature = "test-utils"))] +fn gateway_not_running() -> anyhow::Error { + anyhow!("Gateway not running") +} + +/// Test/default write runtime — gateway ops fail; port persist succeeds as no-op. +#[cfg(any(test, feature = "test-utils"))] +#[derive(Default)] +pub struct StubGatewayWriteRuntime { + pub gateway_port_service: Option>, + pub gateway_state: Option>>, +} + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait] +impl GatewayWriteRuntime for StubGatewayWriteRuntime { + async fn start_gateway( + &self, + _port: Option, + _allow_dynamic_fallback: Option, + ) -> Result { + Err(gateway_not_running()) + } + + async fn stop_gateway(&self) -> Result { + Ok(json!({ "ok": true })) + } + + async fn restart_gateway( + &self, + _port: Option, + _allow_dynamic_fallback: Option, + ) -> Result { + Err(gateway_not_running()) + } + + async fn disconnect_server( + &self, + _server_id: String, + _space_id: String, + _logout: Option, + ) -> Result { + Err(gateway_not_running()) + } + + async fn connect_all_enabled_servers(&self) -> Result { + Err(gateway_not_running()) + } + + async fn refresh_oauth_tokens_on_startup(&self) -> Result { + Ok(json!({ + "servers_checked": 0, + "tokens_refreshed": 0, + "refresh_failed": 0, + })) + } + + async fn set_gateway_port(&self, port: u16) -> Result { + if port < 1024 { + return Err(anyhow!( + "Port {port} is in the privileged range (≤ 1023). Choose a port between 1024 and 65535." + )); + } + if let Some(ref svc) = self.gateway_port_service { + svc.save_port(port).await?; + } + Ok(json!({ "ok": true })) + } + + async fn enable_server_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn disable_server_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn start_auth_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn cancel_auth_v2(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn retry_connection(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn update_server_package(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn logout_server(&self, _space_id: String, _server_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn respond_to_meta_tool_approval( + &self, + _request_id: String, + _client_id: String, + _tool_name: String, + _decision: String, + ) -> Result { + Err(gateway_not_running()) + } + + async fn revoke_meta_tool_grant( + &self, + _client_id: String, + _tool_name: String, + ) -> Result { + Err(gateway_not_running()) + } + + async fn update_oauth_client( + &self, + _client_id: String, + _client_alias: Option, + ) -> Result { + Err(gateway_not_running()) + } + + async fn delete_oauth_client(&self, _client_id: String) -> Result { + Err(gateway_not_running()) + } + + async fn grant_oauth_client_feature_set( + &self, + _client_id: String, + _space_id: String, + _feature_set_id: String, + ) -> Result { + Err(gateway_not_running()) + } + + async fn revoke_oauth_client_feature_set( + &self, + _client_id: String, + _space_id: String, + _feature_set_id: String, + ) -> Result { + Err(gateway_not_running()) + } + + async fn gateway_state(&self) -> Option>> { + self.gateway_state.clone() + } +} diff --git a/crates/mcpmux-gateway/src/lib.rs b/crates/mcpmux-gateway/src/lib.rs index c974b0a3..deabccc8 100644 --- a/crates/mcpmux-gateway/src/lib.rs +++ b/crates/mcpmux-gateway/src/lib.rs @@ -8,6 +8,7 @@ //! - Dependency Injection for clean architecture //! - Event-driven architecture via DomainEvent consumers +pub mod admin; pub mod auth; pub mod consumers; pub mod logging; @@ -15,12 +16,15 @@ pub mod mcp; pub mod oauth; pub mod permissions; pub mod pool; +pub mod public_base_url; pub mod server; pub mod services; +pub use admin::{AdminConfig, AdminServer, AdminServerHandle, DEFAULT_ADMIN_PORT}; pub use auth::AccessKeyAuth; pub use oauth::{OAuthConfig, OAuthManager, OAuthToken}; pub use permissions::{PermissionFilter, PermissionSet}; +pub use public_base_url::{normalize_public_url, resolve_request_base_url}; pub use server::{ AutoConnectResult, DependenciesBuilder, GatewayConfig, GatewayDependencies, GatewayServer, GatewayServerHandle, GatewayState, PendingAuthorization, StartupOrchestrator, diff --git a/crates/mcpmux-gateway/src/public_base_url.rs b/crates/mcpmux-gateway/src/public_base_url.rs new file mode 100644 index 00000000..e59a89cb --- /dev/null +++ b/crates/mcpmux-gateway/src/public_base_url.rs @@ -0,0 +1,217 @@ +//! Resolve the OAuth / metadata base URL for inbound gateway requests. +//! +//! Local clients keep `http://localhost:{port}`; tunnel traffic uses the +//! configured public URL when Cloudflare Access or forwarded-host signals match. + +use axum::http::HeaderMap; + +/// Header Cloudflare Access adds to origin requests after successful auth. +const CF_ACCESS_JWT_HEADER: &str = "cf-access-jwt-assertion"; + +/// Normalize and validate an operator-supplied public gateway URL. +/// +/// Returns an empty string when `url` is blank (clears the setting). +pub fn normalize_public_url(url: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Ok(String::new()); + } + + let parsed = url::Url::parse(trimmed).map_err(|e| format!("Invalid URL: {e}"))?; + if parsed.scheme() != "https" { + return Err("Public gateway URL must use https".into()); + } + let Some(host) = parsed.host_str() else { + return Err("Public gateway URL must include a hostname".into()); + }; + + Ok(format!("https://{host}")) +} + +/// Pick the base URL for OAuth metadata and WWW-Authenticate on this request. +pub fn resolve_request_base_url( + headers: &HeaderMap, + local_base_url: &str, + configured_public_url: Option<&str>, +) -> String { + let Some(public) = configured_public_url.filter(|value| !value.is_empty()) else { + return local_base_url.to_string(); + }; + + let Some(public_host) = url_host(public) else { + return local_base_url.to_string(); + }; + + // cloudflared + CF Access inject this on every request that passed the edge policy. + // Service tokens and browser sessions both get it; loopback clients never do. + if header_value(headers, CF_ACCESS_JWT_HEADER).is_some() { + return public.to_string(); + } + + if let Some(forwarded_host) = header_value(headers, "x-forwarded-host") { + let host = forwarded_host.split(',').next().unwrap_or("").trim(); + if host_matches_public(host, &public_host) { + let proto = header_value(headers, "x-forwarded-proto") + .and_then(|value| value.split(',').next()) + .unwrap_or("https") + .trim(); + return format!("{proto}://{host}"); + } + } + + // Bypassed CF Access paths reach the origin without JWT or forwarded-host + // headers, but cloudflared still sets Host to the public hostname. + if let Some(host) = header_value(headers, "host") { + let host = host.split(',').next().unwrap_or("").trim(); + if host_matches_public(host, &public_host) { + return public.to_string(); + } + } + + local_base_url.to_string() +} + +/// Host values accepted by rmcp Streamable HTTP `Host` validation. +/// +/// Loopback defaults prevent DNS rebinding; the configured public hostname is +/// added when tunnel traffic arrives with an external `Host` header. +pub fn streamable_http_allowed_hosts( + local_port: u16, + configured_public_url: Option<&str>, +) -> Vec { + let mut hosts = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + format!("localhost:{local_port}"), + format!("127.0.0.1:{local_port}"), + ]; + + let Some(public) = configured_public_url.filter(|value| !value.is_empty()) else { + return hosts; + }; + + let Ok(parsed) = url::Url::parse(public) else { + return hosts; + }; + let Some(host) = parsed.host_str() else { + return hosts; + }; + + hosts.push(host.to_string()); + if let Some(port) = parsed.port() { + hosts.push(format!("{host}:{port}")); + } + + hosts +} + +fn header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +fn url_host(url: &str) -> Option { + url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase)) +} + +fn host_matches_public(forwarded_host: &str, public_host: &str) -> bool { + forwarded_host + .split(':') + .next() + .unwrap_or(forwarded_host) + .eq_ignore_ascii_case(public_host) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderMap; + + #[test] + fn normalize_accepts_https_origin() { + assert_eq!( + normalize_public_url("https://mcp.example.com/mcp").unwrap(), + "https://mcp.example.com" + ); + } + + #[test] + fn normalize_rejects_http() { + assert!(normalize_public_url("http://mcp.example.com").is_err()); + } + + #[test] + fn resolve_uses_local_without_public_url() { + let headers = HeaderMap::new(); + assert_eq!( + resolve_request_base_url(&headers, "http://localhost:45818", None), + "http://localhost:45818" + ); + } + + #[test] + fn resolve_uses_public_url_when_forwarded_host_matches() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-host", "mcp.example.com".parse().unwrap()); + headers.insert("x-forwarded-proto", "https".parse().unwrap()); + assert_eq!( + resolve_request_base_url( + &headers, + "http://localhost:45818", + Some("https://mcp.example.com") + ), + "https://mcp.example.com" + ); + } + + #[test] + fn resolve_uses_public_url_when_cf_access_jwt_present() { + let mut headers = HeaderMap::new(); + headers.insert(CF_ACCESS_JWT_HEADER, "jwt".parse().unwrap()); + assert_eq!( + resolve_request_base_url( + &headers, + "http://localhost:45818", + Some("https://mcp.example.com") + ), + "https://mcp.example.com" + ); + } + + #[test] + fn resolve_keeps_local_when_forwarded_host_differs() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-host", "other.example.com".parse().unwrap()); + assert_eq!( + resolve_request_base_url( + &headers, + "http://localhost:45818", + Some("https://mcp.example.com") + ), + "http://localhost:45818" + ); + } + + #[test] + fn resolve_uses_public_url_when_host_header_matches() { + let mut headers = HeaderMap::new(); + headers.insert("host", "mcp.example.com".parse().unwrap()); + assert_eq!( + resolve_request_base_url( + &headers, + "http://localhost:45818", + Some("https://mcp.example.com") + ), + "https://mcp.example.com" + ); + } + + #[test] + fn streamable_allowed_hosts_includes_public_hostname() { + let hosts = streamable_http_allowed_hosts(45818, Some("https://mcp.example.com")); + assert!(hosts.contains(&"mcp.example.com".to_string())); + assert!(hosts.contains(&"127.0.0.1:45818".to_string())); + } +} diff --git a/crates/mcpmux-gateway/src/server/dependencies.rs b/crates/mcpmux-gateway/src/server/dependencies.rs index 8e1a7afc..43eab46a 100644 --- a/crates/mcpmux-gateway/src/server/dependencies.rs +++ b/crates/mcpmux-gateway/src/server/dependencies.rs @@ -8,10 +8,11 @@ use std::sync::Arc; use crate::services::ClientMetadataService; use mcpmux_core::{ - AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, FeatureSetRepository, - InboundMcpClientRepository, InstalledServerRepository, OutboundOAuthRepository, - ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceBaseDirRepository, - SpaceBuiltinConfigRepository, SpaceRepository, WorkspaceBindingRepository, + AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, EventBus, + FeatureSetRepository, InboundMcpClientRepository, InstalledServerRepository, + OutboundOAuthRepository, ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, + SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, + WorkspaceBindingRepository, }; use mcpmux_storage::{Database, InboundClientRepository}; use tokio::sync::Mutex; @@ -59,6 +60,8 @@ pub struct GatewayDependencies { pub state_dir: Option, /// App settings repository (for OAuth port persistence) pub settings_repo: Option>, + /// Application event bus (shared with desktop ApplicationServices) + pub event_bus: Option>, } impl GatewayDependencies { @@ -115,6 +118,7 @@ impl GatewayDependencies { jwt_secret, state_dir, settings_repo: None, // Use builder for this + event_bus: None, } } } @@ -136,6 +140,7 @@ pub struct DependenciesBuilder { jwt_secret: Option>, state_dir: Option, settings_repo: Option>, + event_bus: Option>, } impl DependenciesBuilder { @@ -156,6 +161,7 @@ impl DependenciesBuilder { jwt_secret: None, state_dir: None, settings_repo: None, + event_bus: None, } } @@ -217,6 +223,11 @@ impl DependenciesBuilder { self } + pub fn with_event_bus(mut self, bus: Arc) -> Self { + self.event_bus = Some(bus); + self + } + pub fn build(self) -> Result { let database = self.database.ok_or("database is required")?; @@ -289,6 +300,7 @@ impl DependenciesBuilder { jwt_secret: self.jwt_secret, state_dir: self.state_dir, settings_repo: self.settings_repo, + event_bus: self.event_bus, }) } } diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 00000000..13cd9a7b --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,6 @@ +# CF Access test fixtures + +`cf_access_test_private.pem` and `cf_access_test_pubkey.pem` are **test-only** RSA keys +generated with `openssl genrsa`. They are compiled into binaries only when the +`mcpmux-gateway/test-utils` feature is enabled (integration tests). Never use these keys +outside the test suite. From bcf95650d5758a4282d0847250335ff1979834f1 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 19:35:44 -0600 Subject: [PATCH 004/148] =?UTF-8?q?feat(port):=20Phase=204=20=E2=80=94=20m?= =?UTF-8?q?acOS=20shell=20+=20Tauri=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add macos_dock.rs: set_dock_visible() wraps ActivationPolicy + dock visibility, cfg-gated #[cfg(target_os = "macos")] with a no-op stub for other platforms - Add macos_permissions.rs: ensure_contacts_registered() triggers CNContactStore TCC prompt on first launch so McpMux appears in System Settings → Privacy & Security → Contacts; no-op on non-macOS - Add main_window.rs: show_main_window() / hide_main_window_to_tray() helpers used by tray, deep-link focus, and close-to-tray handler - Add Info.plist: NSContactsUsageDescription, NSCalendarsUsageDescription, NSRemindersUsageDescription, NSAppleEventsUsageDescription TCC keys - Wire lib.rs: declare new modules, call ensure_contacts_registered() + set_dock_visible(false) in setup, use main_window helpers throughout - Add commands/workspace_appearance.rs: list/upsert/delete workspace appearances + upload/resolve icon file commands - Register workspace_appearance in commands/mod.rs and invoke_handler - Add DomainEvent::WorkspaceAppearanceChanged to mcpmux-core and handle in gateway ui_events + desktop gateway bridge - Add target-specific macOS Cargo deps: objc2, objc2-foundation, objc2-contacts, block2 Autonomous decisions: - WorkspaceBinding.icon check in maybe_remove_orphaned_icon_file deferred to Phase 7 (field not yet on the entity); left a ponytail: comment - DomainEvent::WorkspaceAppearanceChanged added now (minimal addition alongside Phase 2 entity) to unblock the commands compiling Signed-off-by: crimsonsunset --- Cargo.lock | 15 + apps/desktop/src-tauri/Cargo.toml | 6 + apps/desktop/src-tauri/Info.plist | 14 + .../desktop/src-tauri/src/commands/gateway.rs | 4 + apps/desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/workspace_appearance.rs | 259 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 43 +-- apps/desktop/src-tauri/src/macos_dock.rs | 26 ++ .../src-tauri/src/macos_permissions.rs | 114 ++++++++ apps/desktop/src-tauri/src/main_window.rs | 23 ++ crates/mcpmux-core/src/domain/event.rs | 13 +- crates/mcpmux-gateway/src/admin/ui_events.rs | 6 + 12 files changed, 504 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src-tauri/Info.plist create mode 100644 apps/desktop/src-tauri/src/commands/workspace_appearance.rs create mode 100644 apps/desktop/src-tauri/src/macos_dock.rs create mode 100644 apps/desktop/src-tauri/src/macos_permissions.rs create mode 100644 apps/desktop/src-tauri/src/main_window.rs diff --git a/Cargo.lock b/Cargo.lock index 8151338d..5502b489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2653,6 +2653,7 @@ version = "0.5.0" dependencies = [ "anyhow", "async-trait", + "block2", "chrono", "dirs 5.0.1", "dotenvy", @@ -2663,6 +2664,9 @@ dependencies = [ "mcpmux-storage", "notify", "notify-debouncer-mini", + "objc2", + "objc2-contacts", + "objc2-foundation", "open", "reqwest 0.13.2", "serde", @@ -3118,6 +3122,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-contacts" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b034b578389f89a85c055eacc8d8b368be5f04a6c1b07f672bf3aec21d0ef621" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-data" version = "0.3.2" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index d6d3f0b5..f7493b2e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -51,3 +51,9 @@ notify-debouncer-mini = "0.5" mcpmux-core.workspace = true mcpmux-gateway.workspace = true mcpmux-storage.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-contacts = { version = "0.3", features = ["CNContactStore", "block2"] } +block2 = "0.6" diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist new file mode 100644 index 00000000..53b5cfb3 --- /dev/null +++ b/apps/desktop/src-tauri/Info.plist @@ -0,0 +1,14 @@ + + + + + NSContactsUsageDescription + McpMux relays MCP servers that may read macOS Contacts (e.g. resolving phone numbers and emails to display names in messaging tools). + NSCalendarsUsageDescription + McpMux relays MCP servers that may read macOS Calendar events. + NSRemindersUsageDescription + McpMux relays MCP servers that may read macOS Reminders. + NSAppleEventsUsageDescription + McpMux relays MCP servers that may automate other apps via AppleScript. + + diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index bd432924..bd2f67b4 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -846,6 +846,10 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "builtin-server-config-changed", serde_json::json!({ "space_id": space_id }), ), + DomainEvent::WorkspaceAppearanceChanged { workspace_root } => ( + "workspace-appearance-changed", + serde_json::json!({ "workspace_root": workspace_root }), + ), } } diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 3aaa5bc0..7fcb9630 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -20,6 +20,7 @@ pub mod server_feature; pub mod server_manager; pub mod settings; pub mod space; +pub mod workspace_appearance; pub mod workspace_binding; pub mod workspace_install; @@ -40,5 +41,6 @@ pub use server_feature::*; pub use server_manager::*; pub use settings::*; pub use space::*; +pub use workspace_appearance::*; pub use workspace_binding::*; pub use workspace_install::*; diff --git a/apps/desktop/src-tauri/src/commands/workspace_appearance.rs b/apps/desktop/src-tauri/src/commands/workspace_appearance.rs new file mode 100644 index 00000000..dc2ef16b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/workspace_appearance.rs @@ -0,0 +1,259 @@ +//! Tauri commands for workspace appearance metadata and local icon files. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use image::GenericImageView; +use mcpmux_core::{validate_workspace_root as validate_root, DomainEvent, WorkspaceAppearance}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::RwLock; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::gateway::GatewayAppState; +use crate::state::AppState; + +const LOCAL_ICON_PREFIX: &str = "local:workspace-icons/"; +const WORKSPACE_ICON_DIR: &str = "workspace-icons"; +const MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024; +const MAX_ICON_DIMENSION: u32 = 256; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceAppearanceDto { + pub workspace_root: String, + pub icon: String, + pub updated_at: String, +} + +impl From for WorkspaceAppearanceDto { + fn from(value: WorkspaceAppearance) -> Self { + Self { + workspace_root: value.workspace_root, + icon: value.icon, + updated_at: value.updated_at.to_rfc3339(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct WorkspaceAppearanceInput { + pub workspace_root: String, + pub icon: String, +} + +fn normalize_and_validate(raw: &str) -> Result { + match validate_root(raw) { + mcpmux_core::WorkspaceRootValidation::Empty => Err("workspace_root cannot be empty".into()), + mcpmux_core::WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + mcpmux_core::WorkspaceRootValidation::Invalid { reason } => Err(reason), + } +} + +fn normalize_icon(icon: &str) -> Option { + let trimmed = icon.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) +} + +pub(crate) fn local_ref_to_file_name(icon: &str) -> Option<&str> { + let file_name = icon.strip_prefix(LOCAL_ICON_PREFIX)?; + if file_name.contains('/') || file_name.contains('\\') { + return None; + } + if Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + != Some("png") + { + return None; + } + Some(file_name) +} + +fn icon_ref_to_path(data_dir: &Path, icon_ref: &str) -> Option { + let file_name = local_ref_to_file_name(icon_ref)?; + Some(data_dir.join(WORKSPACE_ICON_DIR).join(file_name)) +} + +pub(crate) async fn maybe_remove_orphaned_icon_file( + state: &AppState, + icon_ref: Option<&str>, +) -> Result<(), String> { + let Some(icon_ref) = icon_ref else { + return Ok(()); + }; + let Some(file_name) = local_ref_to_file_name(icon_ref) else { + return Ok(()); + }; + + let icon_ref_owned = icon_ref.to_string(); + let appearances = state + .workspace_appearance_repository + .list() + .await + .map_err(|e| e.to_string())?; + + // ponytail: WorkspaceBinding.icon is added in Phase 7; only check appearances for now. + if appearances.iter().any(|a| a.icon == icon_ref_owned) { + return Ok(()); + } + + let file_path = state.data_dir().join(WORKSPACE_ICON_DIR).join(file_name); + match tokio::fs::remove_file(&file_path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("failed to remove orphaned icon file: {err}")), + } + Ok(()) +} + +pub(crate) async fn emit_workspace_appearance_changed( + gateway_state: &Arc>, + workspace_root: String, +) { + let guard = gateway_state.read().await; + let Some(ref gateway_state) = guard.gateway_state else { + debug!("[workspace_appearance] gateway not running — skipping emit"); + return; + }; + gateway_state + .read() + .await + .emit_domain_event(DomainEvent::WorkspaceAppearanceChanged { workspace_root }); +} + +#[tauri::command] +pub async fn list_workspace_appearances( + state: State<'_, AppState>, +) -> Result, String> { + state + .workspace_appearance_repository + .list() + .await + .map(|items| items.into_iter().map(Into::into).collect()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn upsert_workspace_appearance( + input: WorkspaceAppearanceInput, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let workspace_root = normalize_and_validate(&input.workspace_root)?; + let icon = normalize_icon(&input.icon).ok_or_else(|| "icon cannot be empty".to_string())?; + let previous_icon = state + .workspace_appearance_repository + .get(&workspace_root) + .await + .map_err(|e| e.to_string())? + .map(|a| a.icon); + + let appearance = WorkspaceAppearance::new(workspace_root.clone(), icon); + state + .workspace_appearance_repository + .upsert(&appearance) + .await + .map_err(|e| e.to_string())?; + + if let Some(previous_icon) = previous_icon { + if previous_icon != appearance.icon { + maybe_remove_orphaned_icon_file(&state, Some(previous_icon.as_str())).await?; + } + } + + emit_workspace_appearance_changed(gateway_state.inner(), workspace_root).await; + Ok(appearance.into()) +} + +#[tauri::command] +pub async fn delete_workspace_appearance( + workspace_root: String, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result<(), String> { + let normalized = normalize_and_validate(&workspace_root)?; + let previous = state + .workspace_appearance_repository + .get(&normalized) + .await + .map_err(|e| e.to_string())?; + + state + .workspace_appearance_repository + .delete(&normalized) + .await + .map_err(|e| e.to_string())?; + + if let Some(previous) = previous { + maybe_remove_orphaned_icon_file(&state, Some(previous.icon.as_str())).await?; + } + + emit_workspace_appearance_changed(gateway_state.inner(), normalized).await; + Ok(()) +} + +#[tauri::command] +pub async fn upload_workspace_icon( + source_path: String, + state: State<'_, AppState>, +) -> Result { + let source = PathBuf::from(source_path); + let metadata = tokio::fs::metadata(&source) + .await + .map_err(|e| format!("failed to inspect source file: {e}"))?; + if metadata.len() > MAX_UPLOAD_BYTES { + return Err("icon file must be 2MB or smaller".to_string()); + } + + let bytes = tokio::fs::read(&source) + .await + .map_err(|e| format!("failed to read icon file: {e}"))?; + let image = + image::load_from_memory(&bytes).map_err(|e| format!("failed to decode image file: {e}"))?; + + let (width, height) = image.dimensions(); + let normalized = if width > MAX_ICON_DIMENSION || height > MAX_ICON_DIMENSION { + image.resize( + MAX_ICON_DIMENSION, + MAX_ICON_DIMENSION, + image::imageops::FilterType::Lanczos3, + ) + } else { + image + }; + + let icon_dir = state.data_dir().join(WORKSPACE_ICON_DIR); + tokio::fs::create_dir_all(&icon_dir) + .await + .map_err(|e| format!("failed to create workspace icon directory: {e}"))?; + + let file_name = format!("{}.png", Uuid::new_v4()); + let target_path = icon_dir.join(&file_name); + normalized + .save_with_format(&target_path, image::ImageFormat::Png) + .map_err(|e| format!("failed to store icon file: {e}"))?; + + Ok(format!("{LOCAL_ICON_PREFIX}{file_name}")) +} + +#[tauri::command] +pub async fn resolve_workspace_icon_path( + icon_ref: String, + state: State<'_, AppState>, +) -> Result, String> { + let Some(path) = icon_ref_to_path(state.data_dir(), &icon_ref) else { + return Ok(None); + }; + match tokio::fs::metadata(&path).await { + Ok(_) => Ok(Some(path.to_string_lossy().to_string())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + warn!(path = %path.display(), "[workspace_appearance] icon file missing"); + Ok(None) + } + Err(err) => Err(format!("failed to resolve icon path: {err}")), + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 88088b90..ca7a8fb5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,9 @@ use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; mod commands; +mod macos_dock; +mod macos_permissions; +mod main_window; mod services; mod state; mod tray; @@ -245,19 +248,7 @@ pub fn run() { } } - if let Some(window) = app.get_webview_window("main") { - if let Err(e) = window.show() { - warn!("Failed to show window: {}", e); - } - if let Err(e) = window.unminimize() { - warn!("Failed to unminimize window: {}", e); - } - if let Err(e) = window.set_focus() { - warn!("Failed to focus window: {}", e); - } - } else { - warn!("Main window not found"); - } + main_window::show_main_window(app); })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -269,6 +260,16 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .setup(|app| { + if commands::should_start_hidden() { + macos_dock::set_dock_visible(app.handle(), false); + } + + // Register McpMux with macOS TCC for resources that spawned MCP + // servers may need. Without this, the app never appears in + // Privacy & Security → Contacts and child reads of AddressBook + // hit a silent EPERM. Idempotent + non-blocking. + macos_permissions::ensure_contacts_registered(); + info!("Initializing application state..."); // Get data directory (Local, not Roaming - machine-specific data) @@ -700,9 +701,7 @@ pub fn run() { Ok(Some(value)) if value == "true" => { // Close to tray - hide window instead of closing info!("[Window] Close requested, hiding to tray"); - if let Some(window) = app_handle_clone.get_webview_window("main") { - let _ = window.hide(); - } + main_window::hide_main_window_to_tray(&app_handle_clone); } Ok(Some(value)) if value == "false" => { // Actually close the app @@ -712,9 +711,7 @@ pub fn run() { _ => { // Default behavior: close to tray info!("[Window] Close requested (default), hiding to tray"); - if let Some(window) = app_handle_clone.get_webview_window("main") { - let _ = window.hide(); - } + main_window::hide_main_window_to_tray(&app_handle_clone); } } }); @@ -727,7 +724,7 @@ pub fn run() { // Check if app should start hidden (auto-launch with --hidden flag) if commands::should_start_hidden() { info!("[Window] Starting hidden (--hidden flag present)"); - let _ = main_window.hide(); + main_window::hide_main_window_to_tray(app.handle()); } } @@ -956,6 +953,12 @@ pub fn run() { commands::list_workspace_install_clients, commands::generate_workspace_config_snippet, commands::install_workspace_mcp_config, + // Workspace appearance commands + commands::list_workspace_appearances, + commands::upsert_workspace_appearance, + commands::delete_workspace_appearance, + commands::upload_workspace_icon, + commands::resolve_workspace_icon_path, // Meta-tool approval (self-management mcpmux_* tools) commands::respond_to_meta_tool_approval, commands::list_meta_tool_grants, diff --git a/apps/desktop/src-tauri/src/macos_dock.rs b/apps/desktop/src-tauri/src/macos_dock.rs new file mode 100644 index 00000000..bdd48bd9 --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_dock.rs @@ -0,0 +1,26 @@ +//! macOS Dock visibility for tray-only mode. + +#[cfg(target_os = "macos")] +use tauri::{ActivationPolicy, AppHandle, Runtime}; +#[cfg(target_os = "macos")] +use tracing::warn; + +/// Show or hide the app in the macOS Dock (no-op on other platforms). +#[cfg(target_os = "macos")] +pub fn set_dock_visible(app: &AppHandle, visible: bool) { + let policy = if visible { + ActivationPolicy::Regular + } else { + ActivationPolicy::Accessory + }; + if let Err(e) = app.set_activation_policy(policy) { + warn!("[macOS] Failed to set activation policy (visible={visible}): {e}"); + } + if let Err(e) = app.set_dock_visibility(visible) { + warn!("[macOS] Failed to set dock visibility (visible={visible}): {e}"); + } +} + +/// Show or hide the app in the macOS Dock (no-op on other platforms). +#[cfg(not(target_os = "macos"))] +pub fn set_dock_visible(_app: &tauri::AppHandle, _visible: bool) {} diff --git a/apps/desktop/src-tauri/src/macos_permissions.rs b/apps/desktop/src-tauri/src/macos_permissions.rs new file mode 100644 index 00000000..ec739f7a --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_permissions.rs @@ -0,0 +1,114 @@ +//! macOS TCC (Transparency, Consent, and Control) permission registration. +//! +//! McpMux spawns child MCP server processes that may read TCC-restricted +//! resources (Contacts, Calendar, Reminders, AppleEvents). macOS evaluates +//! TCC against the *responsible process* — for child processes spawned via +//! posix_spawn, that's the McpMux app bundle. So McpMux itself must: +//! +//! 1. Declare `NS*UsageDescription` keys in its Info.plist (done in +//! `apps/desktop/src-tauri/Info.plist`). +//! 2. Call into the corresponding framework once at runtime so macOS +//! registers the bundle in System Settings → Privacy & Security → +//! and the user can toggle access on. +//! +//! Without (2), the System Settings panel never lists McpMux at all (Apple +//! only shows apps that have actually requested access), and every child +//! MCP server hits a silent EPERM with no path to fix it. + +#[cfg(target_os = "macos")] +use tracing::{debug, info, warn}; + +/// Triggers the macOS Contacts permission prompt if undetermined. +/// +/// Idempotent — safe to call on every app launch. macOS only prompts the +/// first time; subsequent calls return the cached decision instantly. +/// +/// Runs the actual `requestAccess` call on a background thread because the +/// completion handler fires on an arbitrary queue and we don't want to +/// block the Tauri setup hook. +#[cfg(target_os = "macos")] +pub fn ensure_contacts_registered() { + use objc2_contacts::{CNAuthorizationStatus, CNContactStore, CNEntityType}; + + // SAFETY: `authorizationStatusForEntityType` is a pure read of the + // system TCC database — no side effects, no main-thread requirement. + let status = + unsafe { CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) }; + + match status { + CNAuthorizationStatus::Authorized => { + debug!("[Permissions] Contacts: authorized"); + } + CNAuthorizationStatus::Denied => { + warn!( + "[Permissions] Contacts: denied. Child MCP servers reading AddressBook \ + will fail with EPERM until granted in System Settings → Privacy & Security \ + → Contacts → McpMux." + ); + } + CNAuthorizationStatus::Restricted => { + warn!("[Permissions] Contacts: restricted by system policy."); + } + CNAuthorizationStatus::NotDetermined => { + info!( + "[Permissions] Contacts: not determined — requesting access \ + to register McpMux in the system Privacy panel." + ); + request_contacts_access(); + } + // CNAuthorizationStatus is non-exhaustive — newer macOS versions may + // add cases (e.g. Limited). Treat unknown as a soft warning rather + // than re-prompting, since requestAccess is a no-op past first call. + other => { + debug!( + ?other, + "[Permissions] Contacts: unknown status, skipping prompt" + ); + } + } +} + +/// Calls `CNContactStore.requestAccess(for:.contacts, completionHandler:)`. +/// +/// macOS shows the system prompt on first call only; the completion handler +/// fires on an arbitrary queue and the result is cached in TCC.db. We log +/// the outcome but don't block on it — by the time the user clicks Allow/Deny +/// the app is already running. +#[cfg(target_os = "macos")] +fn request_contacts_access() { + use block2::RcBlock; + use objc2::rc::Retained; + use objc2::runtime::Bool; + use objc2_contacts::{CNContactStore, CNEntityType}; + use objc2_foundation::NSError; + + let store: Retained = unsafe { CNContactStore::new() }; + + // Block fires on an arbitrary GCD queue once the user dismisses the + // prompt (or immediately, if a decision is cached). + let handler = RcBlock::new(|granted: Bool, error: *mut NSError| { + if granted.as_bool() { + info!("[Permissions] Contacts: user granted access"); + } else if !error.is_null() { + // SAFETY: CN guarantees error is a valid NSError when non-null. + let err = unsafe { &*error }; + warn!( + "[Permissions] Contacts request failed: {}", + err.localizedDescription() + ); + } else { + warn!("[Permissions] Contacts: user denied access"); + } + }); + + // SAFETY: `requestAccessForEntityType_completionHandler` is the + // documented entry point. The block stays alive for the duration of + // the async request because RcBlock retains it. + unsafe { + store.requestAccessForEntityType_completionHandler(CNEntityType::Contacts, &handler); + } +} + +/// No-op on non-macOS platforms. +#[cfg(not(target_os = "macos"))] +pub fn ensure_contacts_registered() {} diff --git a/apps/desktop/src-tauri/src/main_window.rs b/apps/desktop/src-tauri/src/main_window.rs new file mode 100644 index 00000000..b75dbe31 --- /dev/null +++ b/apps/desktop/src-tauri/src/main_window.rs @@ -0,0 +1,23 @@ +//! Main window show/hide helpers shared by tray, deep links, and gateway focus. + +use tauri::{AppHandle, Manager, Runtime}; + +use crate::macos_dock; + +/// Unminimize, show, and focus the main window; restore Dock presence on macOS. +pub fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + macos_dock::set_dock_visible(app, true); +} + +/// Hide the main window to the tray and remove Dock presence on macOS. +pub fn hide_main_window_to_tray(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + macos_dock::set_dock_visible(app, false); +} diff --git a/crates/mcpmux-core/src/domain/event.rs b/crates/mcpmux-core/src/domain/event.rs index 60bfa0e6..aa3b410c 100644 --- a/crates/mcpmux-core/src/domain/event.rs +++ b/crates/mcpmux-core/src/domain/event.rs @@ -410,6 +410,15 @@ pub enum DomainEvent { /// different tool list, so the notifier re-pushes `tools/list_changed` to /// that Space's peers. BuiltinServerConfigChanged { space_id: Uuid }, + + // ════════════════════════════════════════════════════════════════════════ + // WORKSPACE APPEARANCES + // ════════════════════════════════════════════════════════════════════════ + /// A workspace appearance (icon, theme) was upserted or deleted. + /// + /// The desktop Workspaces tab and space switcher listen for this to + /// refresh their icon display without a full reload. + WorkspaceAppearanceChanged { workspace_root: String }, } // ============================================================================ @@ -451,6 +460,7 @@ impl DomainEvent { Self::SessionRootsChanged => "session_roots_changed", Self::MetaToolInvoked { .. } => "meta_tool_invoked", Self::BuiltinServerConfigChanged { .. } => "builtin_server_config_changed", + Self::WorkspaceAppearanceChanged { .. } => "workspace_appearance_changed", } } @@ -528,7 +538,8 @@ impl DomainEvent { | Self::GatewayStarted { .. } | Self::GatewayStopped | Self::SessionRootsChanged - | Self::MetaToolInvoked { .. } => None, + | Self::MetaToolInvoked { .. } + | Self::WorkspaceAppearanceChanged { .. } => None, } } diff --git a/crates/mcpmux-gateway/src/admin/ui_events.rs b/crates/mcpmux-gateway/src/admin/ui_events.rs index 018d0acd..68f18d9b 100644 --- a/crates/mcpmux-gateway/src/admin/ui_events.rs +++ b/crates/mcpmux-gateway/src/admin/ui_events.rs @@ -410,5 +410,11 @@ pub fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, Value) { "space_id": space_id, }), ), + DomainEvent::WorkspaceAppearanceChanged { workspace_root } => ( + "workspace-appearance-changed", + serde_json::json!({ + "workspace_root": workspace_root, + }), + ), } } From 124baa36697f40cd40ce0ba6105e49ab5f0de32e Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 20:16:20 -0600 Subject: [PATCH 005/148] =?UTF-8?q?feat(port):=20Phase=205=20=E2=80=94=20M?= =?UTF-8?q?eta-tools=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port dev's split meta_tools module layout and supporting gateway services from the i18n branch (supersedes upstream consolidated files per plan Decision #6): - Split meta_tools modules: invoke_*, search_tools(_index), list_servers, meta_tool_common, disclosure_*, feature_set_tools, bind_workspace, set_workspace_root, token_budget, approval_broker/types, diagnose_*. - Gateway services: tool_discovery*, embedding, embedding_warmer, discovery_rank, prompt_discovery, resource_discovery. - Wire embedding warmer into MCPNotifier on connect / feature-refresh. - Add WorkspaceBinding::new_scoped_multi and a client-scoped-with-global -fallback find_longest_prefix_match default impl; use it in the admin effective-features bridge. - Additive FeatureService grant helpers, ToolCallResult.structured_content, routing format helpers, session_roots search cache. - Reconcile meta_tool_approval Tauri command with the always-approve broker. package_version / server_version_probe deferred to Phase 6. Signed-off-by: crimsonsunset --- Cargo.lock | 1338 ++++++++- Cargo.toml | 1 + .../desktop/src-tauri/src/commands/gateway.rs | 20 - .../src/commands/meta_tool_approval.rs | 17 +- .../src/domain/workspace_binding.rs | 14 +- crates/mcpmux-core/src/repository/mod.rs | 56 +- crates/mcpmux-gateway/Cargo.toml | 11 +- .../src/admin/command_bridge/read.rs | 9 +- .../src/consumers/mcp_notifier.rs | 23 +- crates/mcpmux-gateway/src/lib.rs | 4 +- crates/mcpmux-gateway/src/mcp/handler.rs | 23 +- .../src/pool/features/facade.rs | 93 + .../mcpmux-gateway/src/pool/features/mod.rs | 2 +- .../src/pool/features/resolution.rs | 205 +- crates/mcpmux-gateway/src/pool/mod.rs | 8 +- crates/mcpmux-gateway/src/pool/routing.rs | 46 +- .../src/server/service_container.rs | 19 +- .../src/services/discovery_rank.rs | 484 ++++ .../mcpmux-gateway/src/services/embedding.rs | 531 ++++ .../src/services/embedding_warmer.rs | 239 ++ .../src/services/meta_tools/approval.rs | 571 +--- .../services/meta_tools/approval_broker.rs | 332 +++ .../meta_tools/approval_broker_tests.rs | 180 ++ .../src/services/meta_tools/approval_types.rs | 38 + .../src/services/meta_tools/bind_workspace.rs | 176 ++ .../services/meta_tools/diagnose_server.rs | 303 ++ .../src/services/meta_tools/diagnose_tests.rs | 220 ++ .../src/services/meta_tools/diagnose_view.rs | 167 ++ .../services/meta_tools/disclosure_backend.rs | 54 + .../services/meta_tools/disclosure_read.rs | 245 ++ .../services/meta_tools/disclosure_search.rs | 274 ++ .../services/meta_tools/feature_set_tools.rs | 252 ++ .../src/services/meta_tools/invoke_alias.rs | 62 + .../src/services/meta_tools/invoke_backend.rs | 41 + .../meta_tools/invoke_payload_parse.rs | 175 ++ .../meta_tools/invoke_result_filter.rs | 85 + .../meta_tools/invoke_result_filter_tests.rs | 440 +++ .../meta_tools/invoke_result_shaping.rs | 229 ++ .../src/services/meta_tools/invoke_tool.rs | 383 +++ .../services/meta_tools/invoke_tool_tests.rs | 138 + .../src/services/meta_tools/list_servers.rs | 173 ++ .../services/meta_tools/meta_tool_common.rs | 300 ++ .../src/services/meta_tools/mod.rs | 113 +- .../src/services/meta_tools/registry.rs | 190 +- .../src/services/meta_tools/search_tools.rs | 446 +++ .../services/meta_tools/search_tools_index.rs | 128 + .../services/meta_tools/set_workspace_root.rs | 111 + .../src/services/meta_tools/token_budget.rs | 192 ++ .../src/services/meta_tools/tools.rs | 972 ------- crates/mcpmux-gateway/src/services/mod.rs | 19 +- .../src/services/prompt_discovery.rs | 172 ++ .../src/services/resource_discovery.rs | 183 ++ .../src/services/session_roots.rs | 27 + .../src/services/tool_discovery.rs | 38 + .../src/services/tool_discovery_index.rs | 107 + .../src/services/tool_discovery_search.rs | 624 ++++ .../src/services/tool_discovery_tests.rs | 28 + .../src/services/tool_discovery_types.rs | 70 + tests/rust/Cargo.toml | 2 +- tests/rust/tests/integration/meta_tools.rs | 2532 ++++++++++++----- 60 files changed, 11569 insertions(+), 2366 deletions(-) create mode 100644 crates/mcpmux-gateway/src/services/discovery_rank.rs create mode 100644 crates/mcpmux-gateway/src/services/embedding.rs create mode 100644 crates/mcpmux-gateway/src/services/embedding_warmer.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/approval_broker.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/approval_broker_tests.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/approval_types.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/diagnose_server.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/diagnose_tests.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/diagnose_view.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/disclosure_backend.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/disclosure_read.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/disclosure_search.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/feature_set_tools.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_alias.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_payload_parse.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter_tests.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_result_shaping.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_tool.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/invoke_tool_tests.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/list_servers.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/meta_tool_common.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/search_tools.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/search_tools_index.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/set_workspace_root.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/token_budget.rs delete mode 100644 crates/mcpmux-gateway/src/services/meta_tools/tools.rs create mode 100644 crates/mcpmux-gateway/src/services/prompt_discovery.rs create mode 100644 crates/mcpmux-gateway/src/services/resource_discovery.rs create mode 100644 crates/mcpmux-gateway/src/services/tool_discovery.rs create mode 100644 crates/mcpmux-gateway/src/services/tool_discovery_index.rs create mode 100644 crates/mcpmux-gateway/src/services/tool_discovery_search.rs create mode 100644 crates/mcpmux-gateway/src/services/tool_discovery_tests.rs create mode 100644 crates/mcpmux-gateway/src/services/tool_discovery_types.rs diff --git a/Cargo.lock b/Cargo.lock index 5502b489..1eaca4cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -29,6 +31,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -44,6 +64,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -68,6 +94,32 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -277,6 +329,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.16.1" @@ -363,6 +458,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -375,6 +476,18 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -390,6 +503,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -442,6 +564,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.19.1" @@ -542,6 +670,15 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.55" @@ -627,6 +764,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -637,6 +780,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -646,6 +804,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-random" version = "0.1.18" @@ -717,7 +888,7 @@ dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -768,6 +939,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -827,6 +1017,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -847,6 +1047,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -874,6 +1088,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -896,6 +1121,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -949,6 +1183,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -969,6 +1213,37 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1190,6 +1465,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1232,6 +1513,26 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1259,6 +1560,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "event-listener" version = "5.4.1" @@ -1280,6 +1587,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1292,12 +1614,35 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastembed" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f54fc1188b7f7eac8f47be2ab7b3a79ffd842cc8ff2e38316dd59ba4858890e" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1356,6 +1701,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1363,7 +1723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1377,6 +1737,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1689,6 +2055,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1857,14 +2233,25 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ @@ -1877,7 +2264,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1885,6 +2272,13 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -1919,6 +2313,27 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs 6.0.0", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", + "windows-sys 0.60.2", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1928,6 +2343,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "html5ever" version = "0.29.1" @@ -2028,7 +2449,23 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -2212,11 +2649,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "1.9.3" @@ -2240,6 +2704,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -2278,6 +2755,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2313,6 +2801,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2491,6 +2988,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2530,6 +3033,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2562,6 +3075,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2589,18 +3108,49 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "markup5ever" version = "0.14.1" @@ -2647,6 +3197,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "mcpmux" version = "0.5.0" @@ -2727,6 +3297,7 @@ dependencies = [ "chrono", "dashmap", "dirs 5.0.1", + "fastembed", "futures", "hmac", "http", @@ -2743,6 +3314,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "strsim", "subtle", "thiserror 1.0.69", "tokio", @@ -2750,10 +3322,12 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-test", "url", "urlencoding", "uuid", "which", + "yaml_serde", "zeroize", ] @@ -2832,6 +3406,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.4" @@ -2860,6 +3440,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -2891,6 +3493,38 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.5.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2939,12 +3573,46 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify" version = "7.0.0" @@ -3004,12 +3672,32 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -3019,6 +3707,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3060,6 +3759,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oauth2" version = "5.0.0" @@ -3321,6 +4026,28 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.10.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "open" version = "5.3.3" @@ -3333,12 +4060,49 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3365,6 +4129,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq 3.3.0", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq 3.3.0", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3443,6 +4231,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.1" @@ -3465,6 +4265,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3687,6 +4496,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3819,6 +4643,25 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -3828,6 +4671,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3994,52 +4852,139 @@ dependencies = [ ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" dependencies = [ - "getrandom 0.2.17", + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "raw-window-handle" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "rand_core" -version = "0.10.0" +name = "rawpointer" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] -name = "rand_hc" -version = "0.2.0" +name = "rayon" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "rand_core 0.5.1", + "either", + "rayon-core", ] [[package]] -name = "rand_pcg" -version = "0.2.1" +name = "rayon-cond" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ - "rand_core 0.5.1", + "either", + "itertools", + "rayon", ] [[package]] -name = "raw-window-handle" -version = "0.6.2" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] [[package]] name = "redox_syscall" @@ -4138,15 +5083,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", + "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4157,15 +5108,18 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -4208,7 +5162,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -4236,6 +5190,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -4265,7 +5225,7 @@ dependencies = [ "http-body", "http-body-util", "oauth2", - "pastey", + "pastey 0.2.1", "pin-project-lite", "process-wrap", "rand 0.10.0", @@ -4357,6 +5317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4438,6 +5399,17 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4821,6 +5793,15 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -4867,6 +5848,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4915,6 +5907,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -4934,6 +5938,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -5622,6 +6632,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + [[package]] name = "time" version = "0.3.51" @@ -5686,6 +6710,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.49.0" @@ -5714,6 +6771,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5975,6 +7042,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "tray-icon" version = "0.21.3" @@ -6079,24 +7167,95 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6134,6 +7293,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6152,6 +7317,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6326,6 +7502,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -6424,6 +7613,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -6469,6 +7667,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "7.0.3" @@ -7290,6 +8494,25 @@ dependencies = [ "rustix", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yansi" version = "1.0.1" @@ -7492,6 +8715,45 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/Cargo.toml b/Cargo.toml index 7e40695d..ca3c4db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ futures = "0.3" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } +serde_yaml = { package = "yaml_serde", version = "0.10" } # Error handling anyhow = "1.0" diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index bd2f67b4..08ba87a8 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -248,26 +248,6 @@ pub(crate) async fn attach_approval_publisher( approval_broker: &Arc, app_handle: tauri::AppHandle, ) { - // Restore the persisted "require approval" switch onto the broker (which is - // recreated on every gateway start). Default ON when unset. This is the - // single chokepoint both start paths (auto-start + start_gateway command) - // funnel through, so the setting always survives a restart. - { - use tauri::Manager; - let required = match app_handle.try_state::() { - Some(app_state) => app_state - .settings_repository - .get("meta_tools.require_approval") - .await - .ok() - .flatten() - .map(|v| v != "false") - .unwrap_or(true), - None => true, - }; - approval_broker.set_require_approval(required); - } - let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = Arc::new(move |req| { let app_handle = app_handle.clone(); Box::pin(async move { diff --git a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs index 1ba5c278..ef740be3 100644 --- a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs +++ b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs @@ -142,7 +142,6 @@ pub async fn get_meta_tools_require_approval( pub async fn set_meta_tools_require_approval( required: bool, app_state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, ) -> Result { app_state .settings_repository @@ -150,13 +149,13 @@ pub async fn set_meta_tools_require_approval( .await .map_err(|e| e.to_string())?; - let broker = { - let state = gateway_state.read().await; - state.approval_broker.clone() - }; - if let Some(broker) = broker { - broker.set_require_approval(required); - } - warn!(required, "[meta-tool] require-approval switch updated"); + // ponytail: the split-module ApprovalBroker dropped the global bypass — + // its only write tool (`mcpmux_bind_current_workspace`) always prompts. + // The setting is still persisted for the Settings UI, but no longer toggles + // a live broker. Full removal of this toggle is deferred to the UI phase. + warn!( + required, + "[meta-tool] require-approval preference persisted" + ); Ok(required) } diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 393b20ac..78cd9a76 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -67,12 +67,24 @@ impl WorkspaceBinding { workspace_root: impl Into, space_id: Uuid, feature_set_ids: Vec, + ) -> Self { + Self::new_scoped_multi(workspace_root, space_id, None, feature_set_ids) + } + + /// Construct a binding optionally scoped to an OAuth `client_id`. A `None` + /// scope is a global binding; `Some(client_id)` restricts the binding to + /// that client during resolution. + pub fn new_scoped_multi( + workspace_root: impl Into, + space_id: Uuid, + client_id: Option, + feature_set_ids: Vec, ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), workspace_root: workspace_root.into(), - client_id: None, + client_id, label: None, space_id, feature_set_ids, diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index e5d22884..cf5e6118 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -7,9 +7,9 @@ use async_trait::async_trait; use uuid::Uuid; use crate::domain::{ - Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode, - OutboundOAuthRegistration, ServerFeature, Space, SpaceBaseDir, WorkspaceAppearance, - WorkspaceBinding, + path_is_within, Client, Credential, CredentialType, FeatureSet, FeatureSetMember, + InstalledServer, MemberMode, OutboundOAuthRegistration, ServerFeature, Space, SpaceBaseDir, + WorkspaceAppearance, WorkspaceBinding, }; /// Result type for repository operations @@ -277,6 +277,56 @@ pub trait WorkspaceBindingRepository: Send + Sync { &self, candidate_roots: &[String], ) -> RepoResult>; + + /// Resolve which binding applies for a set of candidate workspace roots by + /// longest-prefix containment. + /// + /// Returns the binding whose `workspace_root` is the longest prefix of (or + /// equals) any candidate. Every candidate MUST already be normalized. When + /// `client_id` is `Some`, the client's own scoped bindings are considered + /// alongside global (`client_id` unset) bindings — a scoped binding wins a + /// same-path tie so a client override shadows the global default. When + /// `client_id` is `None`, only global bindings are considered. + /// + /// Default impl scans `list_for_space` and matches in Rust on path-segment + /// boundaries — used by `mcpmux_bind_current_workspace` dedup. + async fn find_longest_prefix_match( + &self, + space_id: &Uuid, + client_id: Option<&str>, + candidate_roots: &[String], + ) -> RepoResult> { + let bindings = self.list_for_space(space_id).await?; + let mut best: Option = None; + for binding in bindings { + let applies = match client_id { + Some(cid) => { + binding.client_id.is_none() || binding.client_id.as_deref() == Some(cid) + } + None => binding.client_id.is_none(), + }; + if !applies { + continue; + } + let contains_candidate = candidate_roots + .iter() + .any(|root| path_is_within(root, &binding.workspace_root)); + if !contains_candidate { + continue; + } + let better = match &best { + Some(current) if binding.workspace_root.len() == current.workspace_root.len() => { + binding.client_id.is_some() && current.client_id.is_none() + } + Some(current) => binding.workspace_root.len() > current.workspace_root.len(), + None => true, + }; + if better { + best = Some(binding); + } + } + Ok(best) + } } /// Credential repository trait (local-only, never synced) diff --git a/crates/mcpmux-gateway/Cargo.toml b/crates/mcpmux-gateway/Cargo.toml index 4ce4d774..e5813466 100644 --- a/crates/mcpmux-gateway/Cargo.toml +++ b/crates/mcpmux-gateway/Cargo.toml @@ -6,6 +6,10 @@ license.workspace = true publish = false description = "McpMux Gateway - MCP proxy server with OAuth and aggregation" +[features] +default = [] +test-utils = [] + [dependencies] # Async runtime tokio.workspace = true @@ -29,6 +33,7 @@ reqwest.workspace = true # Serialization serde.workspace = true serde_json.workspace = true +serde_yaml.workspace = true # Error handling anyhow.workspace = true @@ -38,6 +43,8 @@ thiserror.workspace = true tracing.workspace = true # Utilities +fastembed = "5" +strsim = "0.11" uuid.workspace = true chrono.workspace = true dashmap = "6.1" @@ -67,6 +74,4 @@ mcpmux-storage.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } - -[features] -test-utils = [] +tracing-test = "0.2" diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/read.rs b/crates/mcpmux-gateway/src/admin/command_bridge/read.rs index 7c3e9dc9..4696792c 100644 --- a/crates/mcpmux-gateway/src/admin/command_bridge/read.rs +++ b/crates/mcpmux-gateway/src/admin/command_bridge/read.rs @@ -349,15 +349,10 @@ pub async fn get_workspace_effective_features( .await? .ok_or_else(|| anyhow!("No default Space configured"))?; - // ponytail: find_longest_prefix_match lands in Phase 5; inline prefix search - let all_bindings = ctx + let binding = ctx .workspace_binding_repository - .list_for_space(&default_space.id) + .find_longest_prefix_match(&default_space.id, None, std::slice::from_ref(&normalized)) .await?; - let binding = all_bindings - .into_iter() - .filter(|b| normalized.starts_with(b.workspace_root.as_str())) - .max_by_key(|b| b.workspace_root.len()); let (source, binding_id, space_id, feature_set_ids) = match binding { Some(binding) => ( diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 1a37dc36..6f417ca2 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -26,7 +26,7 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use crate::pool::FeatureService; -use crate::services::FeatureSetResolverService; +use crate::services::{EmbeddingWarmer, FeatureSetResolverService}; /// MCP Notifier — sends `list_changed` notifications to connected sessions. /// @@ -72,6 +72,9 @@ pub struct MCPNotifier { /// When a send is throttled we schedule exactly one retry for the end /// of the window; this set dedupes concurrent schedulers. pending_retries: Arc>>, + /// Optional background embedding warmer, triggered on server connect / + /// feature-refresh events so `mcpmux_search_tools` has warm vectors. + embedding_warmer: Arc>>>, } /// Type of list_changed notification for throttling @@ -126,9 +129,16 @@ impl MCPNotifier { throttle_tracker: Arc::new(RwLock::new(HashMap::new())), state_hashes: Arc::new(RwLock::new(HashMap::new())), pending_retries: Arc::new(Mutex::new(HashSet::new())), + embedding_warmer: Arc::new(RwLock::new(None)), } } + /// Attach an embedding warmer that runs on server connect / feature-refresh + /// events so the hybrid `mcpmux_search_tools` ranking has warm vectors. + pub fn set_embedding_warmer(&self, warmer: Arc) { + *self.embedding_warmer.write() = Some(warmer); + } + /// Calculate hash of all available features of a given type in a space /// Used for content-based deduping async fn calculate_feature_hash(&self, space_id: Uuid, feature_type: FeatureType) -> u64 { @@ -633,6 +643,13 @@ impl MCPNotifier { "[MCPNotifier] ServerStatusChanged - transient state, no notify" ); } + + if matches!(status, ConnectionStatus::Connected) { + let warmer = self.embedding_warmer.read().clone(); + if let Some(warmer) = warmer { + warmer.warm_server(space_id, server_id.clone()); + } + } } DomainEvent::ServerFeaturesRefreshed { @@ -651,6 +668,10 @@ impl MCPNotifier { "[MCPNotifier] ServerFeaturesRefreshed" ); self.notify_all_list_changed(space_id, false).await; + let warmer = self.embedding_warmer.read().clone(); + if let Some(warmer) = warmer { + warmer.warm_server(space_id, server_id.clone()); + } } // Other events that affect MCP capabilities are handled above diff --git a/crates/mcpmux-gateway/src/lib.rs b/crates/mcpmux-gateway/src/lib.rs index deabccc8..c9526c0e 100644 --- a/crates/mcpmux-gateway/src/lib.rs +++ b/crates/mcpmux-gateway/src/lib.rs @@ -80,7 +80,9 @@ pub use pool::{ }; // Services module -pub use services::{EventEmitter, GrantService, PrefixCacheService}; +pub use services::{ + routing_as_invoke_backend, EventEmitter, GrantService, InvokeToolBackend, PrefixCacheService, +}; // MCP module (rmcp-based implementation) pub use mcp::McpMuxGatewayHandler; diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 8258ab80..62902d54 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -758,16 +758,13 @@ impl ServerHandler for McpMuxGatewayHandler { }) .collect(); - // Append the resolved Space's built-in `mcpmux_*` (Tool Optimization) - // tools. The set is empty when that built-in server is disabled for the - // Space, and any individual tools the Space has turned off are filtered - // out — all configured per Space via the Built-in Servers tab. - mcp_tools.extend( - self.services - .meta_tool_registry - .list_as_tools_for_space(&space_id) - .await, - ); + // Append the built-in `mcpmux_*` meta tools (introspection + self- + // management). Only the small advertised core set is listed; the + // remainder stay callable but hidden. Gated by the global + // `gateway.meta_tools_enabled` master switch. + if self.services.meta_tool_registry.is_enabled().await { + mcp_tools.extend(self.services.meta_tool_registry.list_as_tools()); + } // Log tool names at DEBUG level for visibility let tool_names: Vec = mcp_tools.iter().map(|t| t.name.to_string()).collect(); @@ -822,11 +819,7 @@ impl ServerHandler for McpMuxGatewayHandler { // normal "not found" error. if crate::services::is_meta_tool(¶ms.name) && self.services.meta_tool_registry.contains(¶ms.name) - && self - .services - .meta_tool_registry - .is_tool_enabled_for_space(&space_id, ¶ms.name) - .await + && self.services.meta_tool_registry.is_enabled().await { // Note: client_id is the OAuth client identity (a URL for DCR- // registered clients like Claude, a UUID for others). The meta- diff --git a/crates/mcpmux-gateway/src/pool/features/facade.rs b/crates/mcpmux-gateway/src/pool/features/facade.rs index ad0914c3..031a1677 100644 --- a/crates/mcpmux-gateway/src/pool/features/facade.rs +++ b/crates/mcpmux-gateway/src/pool/features/facade.rs @@ -1,6 +1,7 @@ //! Feature Service Facade - Unified API delegating to specialized services use anyhow::Result; +use std::collections::HashSet; use std::sync::Arc; use crate::pool::instance::McpClient; @@ -11,6 +12,8 @@ use super::{ CachedFeatures, FeatureDiscoveryService, FeatureResolutionService, FeatureRoutingService, }; +pub use super::resolution::InactiveDiscoveryEntry; + /// Unified facade providing all feature operations (Facade pattern) pub struct FeatureService { discovery: Arc, @@ -97,6 +100,96 @@ impl FeatureService { .await } + /// Resolve granted feature sets to tools invokable via search/invoke ACL. + /// Alias of [`Self::get_tools_for_grants`] surfaced for meta-tool discovery. + pub async fn get_invokable_tools_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + ) -> Result> { + self.get_tools_for_grants(space_id, feature_set_ids).await + } + + /// Tools promoted into client `tools/list` (surfaced backend tools only). + pub async fn get_advertised_tools_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + ) -> Result> { + if feature_set_ids.is_empty() { + return Ok(Vec::new()); + } + + let invokable = self + .get_invokable_tools_for_grants(space_id, feature_set_ids) + .await?; + let surfaced_ids = self + .resolution + .resolve_surfaced_feature_ids(feature_set_ids) + .await?; + + Ok(invokable + .into_iter() + .filter(|f| surfaced_ids.contains(&f.id.to_string())) + .collect()) + } + + /// Resolve granted feature sets to resources readable via search/read ACL. + pub async fn get_readable_resources_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + ) -> Result> { + self.get_resources_for_grants(space_id, feature_set_ids) + .await + } + + /// Resolve granted feature sets to prompts fetchable via search/fetch ACL. + pub async fn get_fetchable_prompts_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + ) -> Result> { + self.get_prompts_for_grants(space_id, feature_set_ids).await + } + + /// Catalog tools in the Space that require binding a FeatureSet before invoke. + pub async fn list_inactive_discovery_tools( + &self, + space_id: &str, + feature_set_ids: &[String], + query_id: Option<&str>, + ) -> Result> { + let invokable = self + .get_invokable_tools_for_grants(space_id, feature_set_ids) + .await?; + let invokable_keys: HashSet<(String, String)> = invokable + .iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| (f.server_id.clone(), f.feature_name.clone())) + .collect(); + + self.resolution + .list_inactive_tools_for_discovery(space_id, &invokable_keys, query_id) + .await + } + + /// Resolve the owning server for `uri` among grant-visible readable resources. + /// + /// Clone servers can expose the same URI as their parent; grant-scoped + /// resolution ensures reads route to the bound clone, not an inactive parent. + pub fn resolve_resource_server_from_grants( + readable: &[ServerFeature], + uri: &str, + ) -> Option { + readable + .iter() + .find(|f| { + f.feature_type == FeatureType::Resource && f.feature_name == uri && f.is_available + }) + .map(|f| f.server_id.clone()) + } + pub async fn get_prompts_for_grants( &self, space_id: &str, diff --git a/crates/mcpmux-gateway/src/pool/features/mod.rs b/crates/mcpmux-gateway/src/pool/features/mod.rs index ff06a090..3f0dd532 100644 --- a/crates/mcpmux-gateway/src/pool/features/mod.rs +++ b/crates/mcpmux-gateway/src/pool/features/mod.rs @@ -11,7 +11,7 @@ mod routing; // Re-export public types pub use conversion::{convert_to_feature, resource_to_feature}; pub use discovery::FeatureDiscoveryService; -pub use facade::FeatureService; +pub use facade::{FeatureService, InactiveDiscoveryEntry}; pub use resolution::FeatureResolutionService; pub use routing::FeatureRoutingService; diff --git a/crates/mcpmux-gateway/src/pool/features/resolution.rs b/crates/mcpmux-gateway/src/pool/features/resolution.rs index 45e0c19e..6de00a75 100644 --- a/crates/mcpmux-gateway/src/pool/features/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/features/resolution.rs @@ -1,8 +1,9 @@ //! Feature Resolution Service - SRP: Feature set resolution & permissions use anyhow::Result; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Instant; use tracing::{debug, warn}; use crate::services::PrefixCacheService; @@ -11,6 +12,13 @@ use mcpmux_core::{ ServerFeatureRepository, }; +/// A catalog tool visible in discovery but not invokable until its FeatureSet is bound. +#[derive(Debug, Clone)] +pub struct InactiveDiscoveryEntry { + pub feature: ServerFeature, + pub bindable_feature_set_id: String, +} + /// Helper to apply include/exclude mode (DRY) fn apply_mode_to_set( mode: MemberMode, @@ -174,6 +182,201 @@ impl FeatureResolutionService { Ok(result) } + /// Resolve the set of feature IDs marked `surfaced` (promoted into the + /// client's `tools/list`) across the given FeatureSets, recursing into + /// nested sets. + pub async fn resolve_surfaced_feature_ids( + &self, + feature_set_ids: &[String], + ) -> Result> { + let mut surfaced = HashSet::new(); + for fs_id in feature_set_ids { + let Some(feature_set) = self.feature_set_repo.get_with_members(fs_id).await? else { + continue; + }; + self.collect_surfaced_members(&feature_set, &mut surfaced) + .await?; + } + Ok(surfaced) + } + + async fn collect_surfaced_members( + &self, + feature_set: &FeatureSet, + surfaced: &mut HashSet, + ) -> Result<()> { + for member in &feature_set.members { + match member.member_type { + MemberType::Feature => { + if member.mode == MemberMode::Include && member.surfaced { + surfaced.insert(member.member_id.clone()); + } + } + MemberType::FeatureSet => { + if let Some(nested_fs) = self + .feature_set_repo + .get_with_members(&member.member_id) + .await? + { + Box::pin(self.collect_surfaced_members(&nested_fs, surfaced)).await?; + } + } + } + } + Ok(()) + } + + /// Tools granted by some FeatureSet in the Space but not in `invokable_keys`. + /// + /// Used by meta-tool discovery (`include_inactive`); first matching FeatureSet + /// wins when multiple bundles contain the same tool. + pub async fn list_inactive_tools_for_discovery( + &self, + space_id: &str, + invokable_keys: &HashSet<(String, String)>, + query_id: Option<&str>, + ) -> Result> { + let started = Instant::now(); + + let all_features = self.feature_repo.list_for_space(space_id).await?; + let features_by_id: HashMap = all_features + .iter() + .map(|feature| (feature.id.to_string(), feature.clone())) + .collect(); + + let sets = self.feature_set_repo.list_by_space(space_id).await?; + let mut sets: Vec<_> = sets.into_iter().filter(|fs| !fs.is_deleted).collect(); + // Prefer custom bundles over the auto-seeded Default when both grant the same tool. + sets.sort_by(|a, b| { + a.is_builtin + .cmp(&b.is_builtin) + .then_with(|| a.name.cmp(&b.name)) + }); + + let mut by_key: HashMap<(String, String), InactiveDiscoveryEntry> = HashMap::new(); + + // Pass 1: flat `feature` include members (hot path — equivalent to the JOIN scan). + for fs in &sets { + Self::collect_inactive_from_flat_includes( + &mut by_key, + &features_by_id, + fs, + invokable_keys, + ); + } + + // Pass 2: nested FeatureSet members and exclude rules (rare composed bundles). + for fs in &sets { + if !Self::feature_set_needs_resolution_pass(fs) { + continue; + } + let mut allowed_feature_ids: HashSet = HashSet::new(); + let mut excluded_feature_ids: HashSet = HashSet::new(); + let mut visited: HashSet = HashSet::new(); + self.resolve_members( + fs, + &all_features, + &mut allowed_feature_ids, + &mut excluded_feature_ids, + &mut visited, + ) + .await?; + Self::merge_inactive_from_feature_ids( + &mut by_key, + &features_by_id, + &allowed_feature_ids, + &excluded_feature_ids, + &fs.id, + invokable_keys, + ); + } + + let mut entries: Vec<_> = by_key.into_values().collect(); + for entry in &mut entries { + let prefix = self + .prefix_cache + .get_prefix_for_server(space_id, &entry.feature.server_id) + .await; + entry.feature.server_alias = Some(prefix); + } + + debug!( + query_id, + inactive_entries = entries.len(), + total_ms = started.elapsed().as_millis() as u64, + "[search] inactive scan complete" + ); + + entries.sort_by_key(|entry| entry.feature.qualified_name()); + Ok(entries) + } + + /// Whether a FeatureSet needs the second-pass member-resolution walk. + fn feature_set_needs_resolution_pass(feature_set: &FeatureSet) -> bool { + feature_set.members.iter().any(|member| { + member.member_type == MemberType::FeatureSet + || (member.member_type == MemberType::Feature && member.mode == MemberMode::Exclude) + }) + } + + /// Collect inactive tools from flat `feature` include members on one FeatureSet. + fn collect_inactive_from_flat_includes( + by_key: &mut HashMap<(String, String), InactiveDiscoveryEntry>, + features_by_id: &HashMap, + feature_set: &FeatureSet, + invokable_keys: &HashSet<(String, String)>, + ) { + for member in &feature_set.members { + if member.member_type != MemberType::Feature || member.mode != MemberMode::Include { + continue; + } + let Some(feature) = features_by_id.get(&member.member_id) else { + continue; + }; + if !feature.is_available || feature.feature_type != FeatureType::Tool { + continue; + } + let key = (feature.server_id.clone(), feature.feature_name.clone()); + if invokable_keys.contains(&key) { + continue; + } + by_key.entry(key).or_insert_with(|| InactiveDiscoveryEntry { + feature: feature.clone(), + bindable_feature_set_id: feature_set.id.clone(), + }); + } + } + + /// Merge inactive tools from resolved feature IDs; first FeatureSet row wins. + fn merge_inactive_from_feature_ids( + by_key: &mut HashMap<(String, String), InactiveDiscoveryEntry>, + features_by_id: &HashMap, + allowed_feature_ids: &HashSet, + excluded_feature_ids: &HashSet, + bindable_feature_set_id: &str, + invokable_keys: &HashSet<(String, String)>, + ) { + for feature_id in allowed_feature_ids { + if excluded_feature_ids.contains(feature_id) { + continue; + } + let Some(feature) = features_by_id.get(feature_id) else { + continue; + }; + if !feature.is_available || feature.feature_type != FeatureType::Tool { + continue; + } + let key = (feature.server_id.clone(), feature.feature_name.clone()); + if invokable_keys.contains(&key) { + continue; + } + by_key.entry(key).or_insert_with(|| InactiveDiscoveryEntry { + feature: feature.clone(), + bindable_feature_set_id: bindable_feature_set_id.to_string(), + }); + } + } + async fn resolve_members( &self, feature_set: &FeatureSet, diff --git a/crates/mcpmux-gateway/src/pool/mod.rs b/crates/mcpmux-gateway/src/pool/mod.rs index 3a4b366a..25ac6729 100644 --- a/crates/mcpmux-gateway/src/pool/mod.rs +++ b/crates/mcpmux-gateway/src/pool/mod.rs @@ -41,8 +41,12 @@ pub use oauth::{ // SOLID Services pub use connection::{ConnectionResult, ConnectionService}; -pub use features::{CachedFeatures, FeatureService}; -pub use routing::{RoutedPrompt, RoutedResource, RoutedTool, RoutingService}; +pub use features::{CachedFeatures, FeatureService, InactiveDiscoveryEntry}; +pub use routing::{ + format_invoke_permission_denied, format_server_bound_offline_error, + format_server_inactive_error, format_server_not_in_binding_error, RoutedPrompt, RoutedResource, + RoutedTool, RoutingService, ToolCallResult, +}; pub use service::{InstalledServerInfo, PoolService, PoolStats, ReconnectResult}; pub use token::TokenService; pub use transport::{ResolvedTransport, Transport, TransportConnectResult, TransportFactory}; diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 7a081a33..0e68ba75 100644 --- a/crates/mcpmux-gateway/src/pool/routing.rs +++ b/crates/mcpmux-gateway/src/pool/routing.rs @@ -48,12 +48,55 @@ pub struct RoutedResource { } /// Result of a tool call -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ToolCallResult { pub content: Vec, + pub structured_content: Option, pub is_error: bool, } +/// Actionable error when a server is not in the effective enable set. +pub fn format_server_inactive_error(server_id: &str) -> String { + format!( + "server '{server_id}' is inactive → mcpmux_bind_current_workspace with a FeatureSet that includes this server" + ) +} + +/// Actionable error when a bound server is not connected. +pub fn format_server_bound_offline_error(server_id: &str) -> String { + format!( + "Server '{server_id}' is bound but not connected. Run mcpmux_diagnose_server to see why." + ) +} + +/// Actionable error when invoke targets a tool outside the permission set. +pub fn format_invoke_permission_denied( + qualified_name: &str, + server_id: &str, + tool_name: &str, + suggestions: &[String], +) -> String { + if suggestions.is_empty() { + format!( + "tool '{qualified_name}' is not invokable with current grants (server_id='{server_id}', tool='{tool_name}')" + ) + } else { + format!( + "tool '{qualified_name}' is not invokable — did you mean {}?", + suggestions.join(", ") + ) + } +} + +/// Actionable error when a server is not in the binding FeatureSet ACL. +pub fn format_server_not_in_binding_error(server_id: &str) -> String { + format!( + "server '{server_id}' has no readable/fetchable features with current FeatureSet grants — \ + create a FeatureSet bundle in the McpMux desktop or web UI, then bind it with \ + mcpmux_bind_current_workspace" + ) +} + /// Default timeout for MCP tool calls (60 seconds) const TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(60); @@ -292,6 +335,7 @@ impl RoutingService { Ok(ToolCallResult { content, + structured_content: res.structured_content, is_error: res.is_error.unwrap_or(false), }) } diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 0ce5a4da..d1763acf 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -120,6 +120,11 @@ impl ServiceContainer { // by the Tauri layer; until then, writes return `approval_required`. let approval_broker = Arc::new(ApprovalBroker::new()); + // Persistent embedding cache backing the hybrid `search_tools` ranking. + let embedding_repo: Arc = Arc::new( + mcpmux_storage::SqliteEmbeddingRepository::new(deps.database.clone()), + ); + // Registry of built-in `mcpmux_*` meta tools (introspection + self- // management). Each write tool is gated by the broker above. let meta_tool_registry = meta_tools::build_default_registry( @@ -128,13 +133,25 @@ impl ServiceContainer { deps.feature_set_repo.clone(), deps.workspace_binding_repo.clone(), deps.feature_repo.clone(), + deps.installed_server_repo.clone(), feature_set_resolver.clone(), pool_services.feature_service.clone(), + Some(meta_tools::routing_as_invoke_backend( + pool_services.routing_service.clone(), + )), + Some(meta_tools::pool_as_disclosure_backend( + pool_services.pool_service.clone(), + )), session_roots.clone(), approval_broker.clone(), domain_event_tx.clone(), deps.settings_repo.clone(), - Some(deps.builtin_config_repo.clone()), + server_manager.clone(), + deps.log_manager.clone(), + deps.state_dir + .clone() + .unwrap_or_else(|| std::env::temp_dir().join("mcpmux")), + embedding_repo, ); // Space resolver — currently just exposes the active Space, but diff --git a/crates/mcpmux-gateway/src/services/discovery_rank.rs b/crates/mcpmux-gateway/src/services/discovery_rank.rs new file mode 100644 index 00000000..c2ec8176 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/discovery_rank.rs @@ -0,0 +1,484 @@ +//! Shared ranking and fuzzy-match helpers for discovery indexes. + +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use tracing::debug; + +/// Boost applied when every query token appears in the document haystack. +const AND_MATCH_BOOST: f64 = 1.0; + +/// Common stop tokens dropped from lexical matching on both query and document sides. +const STOPWORDS: &[&str] = &["a", "an", "the", "on", "in", "for", "of", "to", "with"]; + +/// Query-side synonym groups for intent phrasing variants (tools, resources, prompts). +const SYNONYM_MAP: &[(&str, &[&str])] = &[ + ("ticket", &["issue"]), + ("tickets", &["issues"]), + ("jira", &["atlassian"]), + ("fetch", &["get"]), + ("find", &["search", "get"]), + ("retrieve", &["get"]), + ("create", &["add", "post"]), + ("make", &["create", "add"]), + ("delete", &["remove"]), + ("remove", &["delete"]), +]; + +/// Optional tracing context for tool search ranking. +pub struct RankTraceContext<'a> { + pub query_id: &'a str, +} + +/// Tokenize text for TF-IDF scoring. +pub(crate) fn tokenize(text: &str) -> Vec { + text.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|token| !token.is_empty() && !STOPWORDS.contains(token)) + .map(String::from) + .collect() +} + +/// Expand query tokens with synonym variants while preserving first-seen order. +pub(crate) fn expand_query_tokens(tokens: Vec) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut expanded: Vec = Vec::with_capacity(tokens.len() * 2); + + for token in tokens { + if seen.insert(token.clone()) { + expanded.push(token.clone()); + } + for (key, synonyms) in SYNONYM_MAP { + if token == *key { + for syn in *synonyms { + let synonym = syn.to_string(); + if seen.insert(synonym.clone()) { + expanded.push(synonym); + } + } + } + } + } + + expanded +} + +/// Tokenize and expand a search query for lexical and hybrid ranking. +pub(crate) fn prepare_query_tokens(query: &str) -> Vec { + expand_query_tokens(tokenize(query)) +} + +/// Return true when at least one query token appears in `haystack`. +fn matches_token_overlap(query_tokens: &[String], haystack: &str) -> bool { + if query_tokens.is_empty() { + return true; + } + let doc_tokens: HashSet = tokenize(haystack).into_iter().collect(); + query_tokens.iter().any(|token| doc_tokens.contains(token)) +} + +/// Return true when every query token appears in `haystack`. +fn all_tokens_present(query_tokens: &[String], haystack: &str) -> bool { + if query_tokens.is_empty() { + return false; + } + let doc_tokens: HashSet = tokenize(haystack).into_iter().collect(); + query_tokens.iter().all(|token| doc_tokens.contains(token)) +} + +/// Build a corpus-level document-frequency map from a slice of haystack strings. +/// +/// Returns `(corpus_size, doc_freq)` where `doc_freq[token]` is the number of documents +/// containing that token at least once. Amortises tokenization to O(N) so callers avoid +/// repeating it O(N log N) times inside a sort comparator. +pub(crate) fn build_corpus_doc_freq(corpus: &[String]) -> (usize, HashMap) { + let corpus_size = corpus.len(); + let mut doc_freq: HashMap = HashMap::new(); + for doc in corpus { + let tokens: HashSet = tokenize(doc).into_iter().collect(); + for token in tokens { + *doc_freq.entry(token).or_default() += 1; + } + } + (corpus_size, doc_freq) +} + +/// TF-IDF score from precomputed corpus statistics and a pre-tokenized document. +/// +/// Separating the precomputed path from the corpus-building step lets +/// `filter_and_rank_inner` call this once per candidate rather than rebuilding +/// corpus statistics on every comparator invocation. +fn tf_idf_score_precomputed( + query_tokens: &[String], + doc_tokens: &[String], + corpus_size: usize, + corpus_doc_freq: &HashMap, +) -> f64 { + if query_tokens.is_empty() || doc_tokens.is_empty() { + return 0.0; + } + + let doc_len = doc_tokens.len() as f64; + let corpus_size_f = corpus_size.max(1) as f64; + + let mut doc_term_freq: HashMap = HashMap::new(); + for token in doc_tokens { + *doc_term_freq.entry(token.clone()).or_default() += 1; + } + + let mut idf_cache: HashMap = HashMap::new(); + let mut score = 0.0; + + for token in query_tokens { + let tf = doc_term_freq.get(token).copied().unwrap_or(0) as f64 / doc_len; + if tf == 0.0 { + continue; + } + + let idf = *idf_cache.entry(token.clone()).or_insert_with(|| { + let docs_with_term = corpus_doc_freq.get(token).copied().unwrap_or(0) as f64; + ((corpus_size_f + 1.0) / (docs_with_term + 1.0)).ln() + 1.0 + }); + + score += tf * idf; + } + + score +} + +/// Lexical relevance score (TF-IDF + AND-match boost) from precomputed corpus statistics. +pub(crate) fn lexical_score_precomputed( + query_tokens: &[String], + doc_tokens: &[String], + corpus_size: usize, + corpus_doc_freq: &HashMap, +) -> f64 { + let base = tf_idf_score_precomputed(query_tokens, doc_tokens, corpus_size, corpus_doc_freq); + let doc_token_set: HashSet<&str> = doc_tokens.iter().map(String::as_str).collect(); + let all_present = !query_tokens.is_empty() + && query_tokens + .iter() + .all(|t| doc_token_set.contains(t.as_str())); + if all_present { + base + AND_MATCH_BOOST + } else { + base + } +} + +/// Filter haystacks by optional token-overlap query and optional server id, then rank. +pub fn filter_and_rank<'a, T, FServer, FHaystack>( + entries: &'a [T], + query: Option<&str>, + server_id: Option<&str>, + server_id_fn: FServer, + haystack_fn: FHaystack, +) -> Vec<&'a T> +where + FServer: Fn(&T) -> &str, + FHaystack: Fn(&T) -> String, +{ + filter_and_rank_inner(entries, query, server_id, server_id_fn, haystack_fn, None).0 +} + +/// Like [`filter_and_rank`] but emits a lexical-pass `[search]` trace event. +pub(crate) fn filter_and_rank_traced<'a, T, FServer, FHaystack>( + entries: &'a [T], + query: Option<&str>, + server_id: Option<&str>, + server_id_fn: FServer, + haystack_fn: FHaystack, + trace: &RankTraceContext<'_>, +) -> (Vec<&'a T>, Option) +where + FServer: Fn(&T) -> &str, + FHaystack: Fn(&T) -> String, +{ + filter_and_rank_inner( + entries, + query, + server_id, + server_id_fn, + haystack_fn, + Some(trace), + ) +} + +/// Shared filter-and-rank implementation with optional lexical-pass tracing. +fn filter_and_rank_inner<'a, T, FServer, FHaystack>( + entries: &'a [T], + query: Option<&str>, + server_id: Option<&str>, + server_id_fn: FServer, + haystack_fn: FHaystack, + trace: Option<&RankTraceContext<'_>>, +) -> (Vec<&'a T>, Option) +where + FServer: Fn(&T) -> &str, + FHaystack: Fn(&T) -> String, +{ + let query_tokens = query.map(prepare_query_tokens).unwrap_or_default(); + let mut and_boost_hits = 0usize; + let index_entries = entries.len(); + let filter_started = Instant::now(); + + let mut matched: Vec<&T> = entries + .iter() + .filter(|entry| { + if let Some(sid) = server_id { + if server_id_fn(entry) != sid { + return false; + } + } + if !query_tokens.is_empty() { + let haystack = haystack_fn(entry); + if !matches_token_overlap(&query_tokens, &haystack) { + return false; + } + if all_tokens_present(&query_tokens, &haystack) { + and_boost_hits += 1; + } + } + true + }) + .collect(); + let filter_ms = filter_started.elapsed().as_millis() as u64; + + let rank_started = Instant::now(); + let top_lexical_score = if query.is_some() { + let corpus: Vec = matched.iter().map(|entry| haystack_fn(entry)).collect(); + let (corpus_size, corpus_doc_freq) = build_corpus_doc_freq(&corpus); + + // Precompute (entry, haystack, score) once per candidate so sort_by compares + // cached scores instead of re-tokenizing the whole corpus O(N log N) times. + let mut scored: Vec<(&T, String, f64)> = matched + .iter() + .map(|entry| { + let haystack = haystack_fn(entry); + let doc_tokens = tokenize(&haystack); + let score = lexical_score_precomputed( + &query_tokens, + &doc_tokens, + corpus_size, + &corpus_doc_freq, + ); + (*entry, haystack, score) + }) + .collect(); + + scored.sort_by(|(_, hay_a, score_a), (_, hay_b, score_b)| { + score_b + .partial_cmp(score_a) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| hay_a.cmp(hay_b)) + }); + + let top_score = scored.first().map(|(_, _, score)| *score); + matched = scored.into_iter().map(|(entry, _, _)| entry).collect(); + top_score + } else { + matched.sort_by_key(|a| haystack_fn(a)); + None + }; + let rank_ms = rank_started.elapsed().as_millis() as u64; + + if let Some(trace_ctx) = trace { + if query.is_some() { + debug!( + query_id = trace_ctx.query_id, + index_entries, + tokens = ?query_tokens, + candidates_after_filter = matched.len(), + and_boost_hits, + filter_ms, + rank_ms, + lexical_total_ms = filter_ms + rank_ms, + "[search] lexical pass" + ); + } + } + + (matched, top_lexical_score) +} + +/// Return up to `limit` candidates closest to `query` by Levenshtein distance. +pub fn levenshtein_suggestions(query: &str, candidates: &[String], limit: usize) -> Vec { + if query.is_empty() || candidates.is_empty() || limit == 0 { + return Vec::new(); + } + + let query_lower = query.to_lowercase(); + let mut scored: Vec<(String, usize)> = candidates + .iter() + .map(|candidate| { + ( + candidate.clone(), + strsim::levenshtein(&query_lower, &candidate.to_lowercase()), + ) + }) + .collect(); + + scored.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + scored + .into_iter() + .take(limit) + .map(|(name, _)| name) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestEntry { + qualified_name: String, + haystack: String, + } + + fn test_haystack(entry: &TestEntry) -> String { + entry.haystack.clone() + } + + fn test_server_id(_entry: &TestEntry) -> &str { + "test" + } + + #[test] + fn tf_idf_ranks_closer_match_first() { + let entries = ["github_list_issues", "github_get_me", "jira_list_issues"]; + let corpus: Vec = entries.iter().map(|e| e.to_string()).collect(); + let (corpus_size, corpus_doc_freq) = build_corpus_doc_freq(&corpus); + let query_tokens = tokenize("list issues"); + let score_list = tf_idf_score_precomputed( + &query_tokens, + &tokenize("github_list_issues List issues"), + corpus_size, + &corpus_doc_freq, + ); + let score_get = tf_idf_score_precomputed( + &query_tokens, + &tokenize("github_get_me Get current user"), + corpus_size, + &corpus_doc_freq, + ); + assert!(score_list > score_get); + } + + #[test] + fn levenshtein_suggests_near_match() { + let candidates = vec![ + "github_list_issues".to_string(), + "github_get_me".to_string(), + ]; + let suggestions = levenshtein_suggestions("list_isses", &candidates, 2); + assert_eq!( + suggestions.first().map(String::as_str), + Some("github_list_issues") + ); + } + + #[test] + fn token_overlap_matches_hyphenated_tool_name() { + let entries = vec![TestEntry { + qualified_name: "canva_list-folder-items".to_string(), + haystack: "canva_list-folder-items list-folder-items List folder items".to_string(), + }]; + let matched = filter_and_rank( + &entries, + Some("list folder"), + None, + |_| "test", + test_haystack, + ); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].qualified_name, "canva_list-folder-items"); + } + + #[test] + fn token_overlap_returns_zero_for_nonsense_query() { + let entries = vec![TestEntry { + qualified_name: "canva_list-folder-items".to_string(), + haystack: "canva_list-folder-items list-folder-items List folder items".to_string(), + }]; + let matched = filter_and_rank( + &entries, + Some("xyznotreal"), + None, + |_| "test", + test_haystack, + ); + assert!(matched.is_empty()); + } + + #[test] + fn multi_token_ranking_favors_all_tokens_present() { + let entries = vec![ + TestEntry { + qualified_name: "partial_list".to_string(), + haystack: "partial_list list something".to_string(), + }, + TestEntry { + qualified_name: "full_list_folder".to_string(), + haystack: "full_list_folder list folder items".to_string(), + }, + ]; + let matched = filter_and_rank( + &entries, + Some("list folder"), + None, + test_server_id, + test_haystack, + ); + assert_eq!(matched.len(), 2); + assert_eq!(matched[0].qualified_name, "full_list_folder"); + } + + #[test] + fn and_boost_increases_lexical_score() { + let corpus = vec![ + "partial list something".to_string(), + "full list folder items".to_string(), + ]; + let (corpus_size, corpus_doc_freq) = build_corpus_doc_freq(&corpus); + let query_tokens = tokenize("list folder"); + let partial = lexical_score_precomputed( + &query_tokens, + &tokenize("partial list something"), + corpus_size, + &corpus_doc_freq, + ); + let full = lexical_score_precomputed( + &query_tokens, + &tokenize("full list folder items"), + corpus_size, + &corpus_doc_freq, + ); + assert!(full > partial); + } + + #[test] + fn stopwords_filtered_from_tokens() { + let tokens = tokenize("post a comment on a jira issue"); + assert!(!tokens.contains(&"a".to_string())); + assert!(!tokens.contains(&"on".to_string())); + assert!(tokens.contains(&"jira".to_string())); + assert!(tokens.contains(&"issue".to_string())); + } + + #[test] + fn synonym_expansion_jira_ticket_matches_issue_tools() { + let entries = vec![TestEntry { + qualified_name: "atlassian_getJiraIssue".to_string(), + haystack: "getJiraIssue atlassian_getJiraIssue Get a Jira issue".to_string(), + }]; + let matched = filter_and_rank( + &entries, + Some("jira ticket"), + None, + test_server_id, + test_haystack, + ); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].qualified_name, "atlassian_getJiraIssue"); + } +} diff --git a/crates/mcpmux-gateway/src/services/embedding.rs b/crates/mcpmux-gateway/src/services/embedding.rs new file mode 100644 index 00000000..7b35b259 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/embedding.rs @@ -0,0 +1,531 @@ +//! Local ONNX embedding service for hybrid tool search ranking. +//! +//! Downloads `bge-small-en-v1.5` on first use into the app data directory and +//! exposes non-blocking state so callers can fall back to lexical-only search. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use fastembed::{EmbeddingModel, InitOptions, TextEmbedding}; +use mcpmux_storage::hash_embedding_content; +use parking_lot::{Mutex, RwLock}; +use tracing::{info, warn}; + +#[cfg(any(test, feature = "test-utils"))] +use std::collections::HashMap; + +/// BGE retrieval prefix for user queries. +const QUERY_PREFIX: &str = "query: "; + +/// BGE retrieval prefix for document/passage text. +const PASSAGE_PREFIX: &str = "passage: "; + +/// Default embedding model — CPU ONNX, downloaded on first use (~67 MB). +const DEFAULT_MODEL: EmbeddingModel = EmbeddingModel::BGESmallENV15; + +/// Lifecycle state of the embedding model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmbeddingState { + /// Model has not been requested yet. + NotDownloaded, + /// Model download / ONNX init is in progress. + Downloading, + /// Model is loaded and ready for inference. + Ready, + /// Download or init failed; lexical-only fallback applies. + Failed { + /// Sanitized error message (no secrets). + error: String, + }, +} + +/// Local embedding inference with lazy model download. +pub struct EmbeddingService { + cache_dir: PathBuf, + model_name: &'static str, + state: Arc>, + model: Arc>>, + init_started: Arc, + /// Deterministic vectors for CI relevance eval (no model download). + #[cfg(any(test, feature = "test-utils"))] + test_vectors: Arc>>>, +} + +impl EmbeddingService { + /// Create a service that stores models under `{data_dir}/embeddings`. + pub fn new(data_dir: PathBuf) -> Self { + let cache_dir = data_dir.join("embeddings"); + Self { + cache_dir, + model_name: "bge-small-en-v1.5", + state: Arc::new(RwLock::new(EmbeddingState::NotDownloaded)), + model: Arc::new(Mutex::new(None)), + init_started: Arc::new(AtomicBool::new(false)), + #[cfg(any(test, feature = "test-utils"))] + test_vectors: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Install deterministic embedding vectors and mark the model ready (CI / integration tests). + #[cfg(any(test, feature = "test-utils"))] + pub fn install_test_vectors(&self, vectors: HashMap>) { + *self.test_vectors.write() = vectors; + *self.state.write() = EmbeddingState::Ready; + } + + /// Return the current model lifecycle state without blocking. + pub fn state(&self) -> EmbeddingState { + self.state.read().clone() + } + + /// Cache directory passed to fastembed for model artifacts. + pub fn cache_dir(&self) -> &Path { + &self.cache_dir + } + + /// Stable model version used for persisted embedding keys. + pub fn model_version(&self) -> &'static str { + self.model_name + } + + /// Build alias-free text used for embedding and content hashing. + pub fn embedding_haystack(feature_name: &str, description: Option<&str>) -> String { + match description { + Some(description) if !description.is_empty() => { + format!("{feature_name} {description}") + } + _ => feature_name.to_string(), + } + } + + /// Stable content hash for alias-free embedding text. + pub fn content_hash(feature_name: &str, description: Option<&str>) -> String { + let haystack = Self::embedding_haystack(feature_name, description); + hash_embedding_content(&haystack) + } + + /// Start background model download/init when still `NotDownloaded`. + /// + /// Idempotent — subsequent calls are no-ops while downloading or after terminal states. + pub fn ensure_init_started(&self) { + if !matches!(self.state(), EmbeddingState::NotDownloaded) { + return; + } + + if self.init_started.swap(true, Ordering::SeqCst) { + return; + } + + let mut state = self.state.write(); + if !matches!(*state, EmbeddingState::NotDownloaded) { + return; + } + + *state = EmbeddingState::Downloading; + drop(state); + + info!( + target: "embed", + "[embed] model = {}, state = Downloading", + self.model_name + ); + + let cache_dir = self.cache_dir.clone(); + let model_name = self.model_name; + let state = Arc::clone(&self.state); + let model_slot = Arc::clone(&self.model); + + std::thread::spawn(move || { + let started = Instant::now(); + match load_text_embedding(&cache_dir) { + Ok(embedding) => { + let download_ms = started.elapsed().as_millis() as u64; + *model_slot.lock() = Some(embedding); + *state.write() = EmbeddingState::Ready; + info!( + target: "embed", + "[embed] model = {}, state = Ready, download_ms = {}", + model_name, + download_ms + ); + } + Err(error) => { + let download_ms = started.elapsed().as_millis() as u64; + let message = error.to_string(); + *state.write() = EmbeddingState::Failed { + error: message.clone(), + }; + info!( + target: "embed", + "[embed] model = {}, state = Failed, download_ms = {}, error = {}", + model_name, + download_ms, + message + ); + } + } + }); + } + + /// Embed a search query. Returns `None` when the model is not `Ready` (never blocks on download). + pub fn embed_query(&self, query: &str, query_id: Option<&str>) -> Option> { + self.embed_prefixed(&format!("{QUERY_PREFIX}{query}"), query_id, 1) + } + + /// Embed document texts for ranking. Returns `None` when the model is not `Ready`. + pub fn embed_documents( + &self, + documents: &[String], + query_id: Option<&str>, + ) -> Option>> { + if documents.is_empty() { + return Some(Vec::new()); + } + + let prefixed: Vec = documents + .iter() + .map(|doc| format!("{PASSAGE_PREFIX}{doc}")) + .collect(); + let refs: Vec<&str> = prefixed.iter().map(String::as_str).collect(); + self.embed_prefixed_batch(&refs, query_id, documents.len()) + } + + /// Cosine similarity between two equal-length embedding vectors. + pub fn cosine(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + + let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if norm_a == 0.0 || norm_b == 0.0 { + return 0.0; + } + + dot / (norm_a * norm_b) + } + + fn embed_prefixed( + &self, + text: &str, + query_id: Option<&str>, + docs_embedded: usize, + ) -> Option> { + let vectors = self.embed_prefixed_batch(&[text], query_id, docs_embedded)?; + vectors.into_iter().next() + } + + fn embed_prefixed_batch( + &self, + texts: &[&str], + query_id: Option<&str>, + docs_embedded: usize, + ) -> Option>> { + #[cfg(any(test, feature = "test-utils"))] + { + let stub = self.test_vectors.read(); + if !stub.is_empty() { + let vectors: Vec> = texts + .iter() + .map(|text| { + stub.get(*text) + .cloned() + .unwrap_or_else(|| panic!("missing test embedding vector for `{text}`")) + }) + .collect(); + self.log_embedding_state(query_id, "ready", docs_embedded, Some(0)); + return Some(vectors); + } + } + + let state = self.state(); + let model_state = model_state_label(&state); + + if !matches!(state, EmbeddingState::Ready) { + self.ensure_init_started(); + self.log_embedding_state(query_id, model_state, docs_embedded, None); + return None; + } + + let started = Instant::now(); + let vectors = self.embed_with_spawn_blocking(texts)?; + let embed_ms = started.elapsed().as_millis() as u64; + self.log_embedding_state(query_id, "ready", docs_embedded, Some(embed_ms)); + Some(vectors) + } + + fn embed_with_spawn_blocking(&self, texts: &[&str]) -> Option>> { + let model_slot = Arc::clone(&self.model); + let inputs: Vec = texts.iter().map(|text| (*text).to_string()).collect(); + let result = run_spawn_blocking(move || { + let mut guard = model_slot.lock(); + let embedding = guard.as_mut()?; + let refs: Vec<&str> = inputs.iter().map(String::as_str).collect(); + match embedding.embed(&refs, None) { + Ok(raw) => Some(raw.into_iter().map(to_f32_vector).collect()), + Err(_) => None, + } + }); + result + } + + fn log_embedding_state( + &self, + query_id: Option<&str>, + model_state: &'static str, + docs_embedded: usize, + embed_ms: Option, + ) { + match (query_id, embed_ms) { + (Some(query_id), Some(embed_ms)) => { + info!( + target: "embed", + "[embed] query_id = {}, model_state = {}, docs_embedded = {}, embed_ms = {}", + query_id, + model_state, + docs_embedded, + embed_ms + ); + } + (Some(query_id), None) => { + info!( + target: "embed", + "[embed] query_id = {}, model_state = {}, docs_embedded = {}", + query_id, + model_state, + docs_embedded + ); + } + (None, Some(embed_ms)) => { + info!( + target: "embed", + "[embed] model_state = {}, docs_embedded = {}, embed_ms = {}", + model_state, + docs_embedded, + embed_ms + ); + } + (None, None) => { + info!( + target: "embed", + "[embed] model_state = {}, docs_embedded = {}", + model_state, + docs_embedded + ); + } + } + } +} + +fn load_text_embedding(cache_dir: &Path) -> anyhow::Result { + std::fs::create_dir_all(cache_dir)?; + let options = InitOptions::new(DEFAULT_MODEL) + .with_cache_dir(cache_dir.to_path_buf()) + .with_show_download_progress(false); + TextEmbedding::try_new(options) +} + +fn model_state_label(state: &EmbeddingState) -> &'static str { + match state { + EmbeddingState::NotDownloaded => "absent", + EmbeddingState::Downloading => "downloading", + EmbeddingState::Ready => "ready", + EmbeddingState::Failed { .. } => "failed", + } +} + +fn to_f32_vector(embedding: fastembed::Embedding) -> Vec { + embedding +} + +fn panic_payload_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_string() + } +} + +fn log_spawn_blocking_join_error(context: &'static str, error: tokio::task::JoinError) { + if error.is_panic() { + let message = panic_payload_message(error.into_panic()); + warn!( + target: "embed", + context, + panic = %message, + "[embed] spawn_blocking panicked" + ); + return; + } + if error.is_cancelled() { + warn!(target: "embed", context, "[embed] spawn_blocking cancelled"); + return; + } + warn!( + target: "embed", + context, + error = %error, + "[embed] spawn_blocking join failed" + ); +} + +fn await_spawn_blocking(handle: tokio::runtime::Handle, task: F) -> Option +where + T: Send + 'static, + F: FnOnce() -> Option + Send + 'static, +{ + match handle.block_on(tokio::task::spawn_blocking(task)) { + Ok(value) => value, + Err(error) => { + log_spawn_blocking_join_error("spawn_blocking", error); + None + } + } +} + +/// Run a blocking embed `task` without stalling the async scheduler. +/// +/// Inside a Tokio context this uses `block_in_place`, which **requires the +/// multi-thread runtime** — it panics on a `current_thread` runtime. The +/// gateway runs on Tokio's multi-thread scheduler (Axum/Tauri), so that +/// invariant holds in production. Outside any runtime (e.g. some unit +/// tests) it spins up a temporary multi-thread runtime instead. +fn run_spawn_blocking(task: F) -> Option +where + T: Send + 'static, + F: FnOnce() -> Option + Send + 'static, +{ + if let Ok(handle) = tokio::runtime::Handle::try_current() { + return tokio::task::block_in_place(|| await_spawn_blocking(handle, task)); + } + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .ok()?; + await_spawn_blocking(runtime.handle().clone(), task) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cosine_identical_unit_vectors_is_one() { + let a = vec![1.0_f32, 0.0, 0.0]; + let b = vec![1.0_f32, 0.0, 0.0]; + assert!((EmbeddingService::cosine(&a, &b) - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn cosine_orthogonal_vectors_is_zero() { + let a = vec![1.0_f32, 0.0]; + let b = vec![0.0_f32, 1.0]; + assert!(EmbeddingService::cosine(&a, &b).abs() < f32::EPSILON); + } + + #[test] + fn cosine_opposite_vectors_is_negative_one() { + let a = vec![1.0_f32, 0.0]; + let b = vec![-1.0_f32, 0.0]; + assert!((EmbeddingService::cosine(&a, &b) + 1.0).abs() < f32::EPSILON); + } + + #[test] + fn cosine_mismatched_lengths_returns_zero() { + let a = vec![1.0_f32, 0.0]; + let b = vec![1.0_f32]; + assert_eq!(EmbeddingService::cosine(&a, &b), 0.0); + } + + #[test] + fn cosine_known_vectors_matches_hand_computed_score() { + let a = vec![3.0_f32, 4.0]; + let b = vec![4.0_f32, 3.0]; + let expected = 24.0_f32 / (5.0 * 5.0); + assert!((EmbeddingService::cosine(&a, &b) - expected).abs() < 1e-6); + } + + #[test] + fn embed_query_returns_none_while_model_not_ready() { + let service = EmbeddingService::new(std::env::temp_dir().join("mcpmux-embed-test")); + assert_eq!(service.state(), EmbeddingState::NotDownloaded); + assert!(service.embed_query("hello", Some("q-test")).is_none()); + assert!(matches!( + service.state(), + EmbeddingState::NotDownloaded | EmbeddingState::Downloading + )); + } + + #[test] + fn ensure_init_started_is_idempotent() { + let service = EmbeddingService::new(std::env::temp_dir().join("mcpmux-embed-idempotent")); + service.ensure_init_started(); + service.ensure_init_started(); + assert!(matches!( + service.state(), + EmbeddingState::Downloading | EmbeddingState::Ready | EmbeddingState::Failed { .. } + )); + } + + #[test] + fn content_hash_changes_when_description_changes() { + let hash_before = EmbeddingService::content_hash("search_issues", Some("Find Jira issues")); + let hash_after = + EmbeddingService::content_hash("search_issues", Some("Find open Jira issues")); + assert_ne!(hash_before, hash_after); + } + + /// Requires network + ~67 MB model download; run locally with `cargo test -- --ignored`. + #[test] + #[ignore = "downloads bge-small-en-v1.5 from HuggingFace"] + fn semantic_matching_doc_scores_higher_than_unrelated() { + let service = EmbeddingService::new(std::env::temp_dir().join("mcpmux-embed-semantic")); + service.ensure_init_started(); + + let deadline = Instant::now() + std::time::Duration::from_secs(120); + while !matches!( + service.state(), + EmbeddingState::Ready | EmbeddingState::Failed { .. } + ) { + assert!( + Instant::now() < deadline, + "timed out waiting for embedding model" + ); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + + if let EmbeddingState::Failed { error } = service.state() { + panic!("model init failed: {error}"); + } + + let query = service + .embed_query("post a comment on an issue", None) + .expect("query embedding"); + let matching = service + .embed_documents( + &["create_issue_comment Create a comment on a Jira issue".to_string()], + None, + ) + .expect("matching doc embedding"); + let unrelated = service + .embed_documents( + &["list_calendar_events List upcoming calendar events".to_string()], + None, + ) + .expect("unrelated doc embedding"); + + let matching_score = EmbeddingService::cosine(&query, &matching[0]); + let unrelated_score = EmbeddingService::cosine(&query, &unrelated[0]); + assert!( + matching_score > unrelated_score, + "matching={matching_score}, unrelated={unrelated_score}" + ); + } +} diff --git a/crates/mcpmux-gateway/src/services/embedding_warmer.rs b/crates/mcpmux-gateway/src/services/embedding_warmer.rs new file mode 100644 index 00000000..03b47423 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/embedding_warmer.rs @@ -0,0 +1,239 @@ +//! Background embedding warmer for per-server tool catalogs. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use dashmap::{DashMap, DashSet}; +use mcpmux_core::{EmbeddingRecord, EmbeddingRepository, FeatureType, ServerFeatureRepository}; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use crate::services::{EmbeddingService, EmbeddingState}; + +/// Event-driven embedding warm worker. +/// +/// On server connect/feature-discovery, it embeds the full server tool catalog, +/// skipping vectors already present in storage or memory. +#[derive(Clone)] +pub struct EmbeddingWarmer { + feature_repo: Arc, + embedding_repo: Arc, + embedding_store: Arc>>, + embeddings: Arc, + in_flight: Arc>, +} + +impl EmbeddingWarmer { + /// Build a warmer. + pub fn new( + feature_repo: Arc, + embedding_repo: Arc, + embedding_store: Arc>>, + embeddings: Arc, + ) -> Self { + Self { + feature_repo, + embedding_repo, + embedding_store, + embeddings, + in_flight: Arc::new(DashSet::new()), + } + } + + /// Poll until the embedding model is `Ready` or a bounded budget elapses. + /// + /// On a cold install the connect-triggered warm fires while the ~67 MB + /// model is still downloading. Without waiting, every `embed_documents` + /// returns `None`, the persistent cache stays empty, and nothing + /// re-warms until some later connect event happens to land after the + /// model is `Ready`. Polling here lets the existing single warm task + /// populate vectors once the download finishes. Returns `true` when + /// ready, `false` if the model `Failed` or the budget ran out. + async fn await_model_ready(&self) -> bool { + const BUDGET: Duration = Duration::from_secs(120); + const POLL: Duration = Duration::from_millis(250); + self.embeddings.ensure_init_started(); + let deadline = Instant::now() + BUDGET; + loop { + match self.embeddings.state() { + EmbeddingState::Ready => return true, + EmbeddingState::Failed { .. } => return false, + _ => {} + } + if Instant::now() >= deadline { + return false; + } + tokio::time::sleep(POLL).await; + } + } + + /// Enqueue warmup for one connected server. + pub fn warm_server(&self, space_id: Uuid, server_id: String) { + let key = (space_id, server_id); + if !self.in_flight.insert(key.clone()) { + return; + } + + let warmer = self.clone(); + tokio::spawn(async move { + if let Err(error) = warmer.warm_server_inner(key.0, &key.1).await { + warn!( + space_id = %key.0, + server_id = %key.1, + error = %error, + "[embed] warmer failed" + ); + } + warmer.in_flight.remove(&key); + }); + } + + async fn warm_server_inner(&self, space_id: Uuid, server_id: &str) -> anyhow::Result<()> { + let tools = self + .feature_repo + .list_for_space(&space_id.to_string()) + .await? + .into_iter() + .filter(|feature| { + feature.feature_type == FeatureType::Tool && feature.server_id.as_str() == server_id + }) + .collect::>(); + + if tools.is_empty() { + return Ok(()); + } + + // Kick model load now so the model is ready by the time a search arrives, + // even when the store is already fully warm and no new embeddings are needed. + self.embeddings.ensure_init_started(); + + let mut haystacks_by_hash: HashMap = HashMap::new(); + for tool in tools { + let haystack = EmbeddingService::embedding_haystack( + tool.feature_name.as_str(), + tool.description.as_deref(), + ); + let content_hash = EmbeddingService::content_hash( + tool.feature_name.as_str(), + tool.description.as_deref(), + ); + haystacks_by_hash.entry(content_hash).or_insert(haystack); + } + + let catalog_tools = haystacks_by_hash.len(); + let mut missing_hashes = haystacks_by_hash + .keys() + .filter(|content_hash| !self.embedding_store.contains_key(*content_hash)) + .cloned() + .collect::>(); + + let existing = self + .embedding_repo + .get_many(&missing_hashes, self.embeddings.model_version()) + .await?; + let existing_hashes: HashSet = existing + .iter() + .map(|record| record.content_hash.clone()) + .collect(); + + for record in existing { + self.embedding_store + .insert(record.content_hash, record.vector); + } + + missing_hashes.retain(|content_hash| !existing_hashes.contains(content_hash)); + let missing = missing_hashes.len(); + let skipped_present = catalog_tools.saturating_sub(missing); + debug!( + space_id = %space_id, + server_id, + catalog_tools, + missing, + "[embed] warm enqueue" + ); + + if missing_hashes.is_empty() { + info!( + space_id = %space_id, + server_id, + embedded = 0, + skipped_present, + embed_ms = 0_u64, + model_version = self.embeddings.model_version(), + model_state = ?self.embeddings.state(), + "[embed] warm batch done" + ); + return Ok(()); + } + + if !self.await_model_ready().await { + info!( + space_id = %space_id, + server_id, + embedded = 0, + skipped_present, + missing, + model_state = ?self.embeddings.state(), + "[embed] warm batch skipped (model not ready within budget)" + ); + return Ok(()); + } + + let mut records = Vec::new(); + let embed_started = Instant::now(); + for content_hash in missing_hashes { + let Some(haystack) = haystacks_by_hash.get(&content_hash).cloned() else { + continue; + }; + + let Some(vectors) = self.embeddings.embed_documents(&[haystack], None) else { + continue; + }; + let Some(vector) = vectors.into_iter().next() else { + continue; + }; + + records.push(EmbeddingRecord { + content_hash: content_hash.clone(), + model_version: self.embeddings.model_version().to_string(), + vector: vector.clone(), + }); + self.embedding_store.insert(content_hash, vector); + } + + if records.is_empty() { + info!( + space_id = %space_id, + server_id, + embedded = 0, + skipped_present, + missing, + embed_ms = embed_started.elapsed().as_millis() as u64, + model_version = self.embeddings.model_version(), + model_state = ?self.embeddings.state(), + "[embed] warm batch done" + ); + return Ok(()); + } + + debug!( + space_id = %space_id, + server_id, + embedded = records.len(), + "[embed] warmer upserting records" + ); + self.embedding_repo.upsert_many(&records).await?; + info!( + space_id = %space_id, + server_id, + embedded = records.len(), + skipped_present, + embed_ms = embed_started.elapsed().as_millis() as u64, + model_version = self.embeddings.model_version(), + "[embed] warm batch done" + ); + Ok(()) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs index 81af40d0..dde54055 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/approval.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs @@ -1,559 +1,16 @@ //! Native-dialog approval broker for meta-tool writes. //! -//! When an LLM calls a write meta tool (e.g. `mcpmux_pin_this_session`), -//! the gateway needs human sign-off before mutating state. The broker -//! bridges that: the tool calls [`ApprovalBroker::request_approval`], which -//! emits a Tauri event the desktop app listens for, awaits a response on a -//! oneshot channel, and returns [`ApprovalDecision`] — Allow (once/always) -//! or Deny (user-denied / timeout / rate-limited / no-desktop). -//! -//! Two non-obvious bits: -//! -//! * If no desktop is attached (headless CLI, tests without the subscriber -//! wired), [`ApprovalBroker::request_approval`] returns -//! [`MetaToolError::ApprovalRequiredNoDesktop`] immediately — a write -//! without an approver is a silent deny, which is the safe failure mode. -//! -//! * "Always allow" entries are **session-only** (in-memory `DashMap`, -//! not persisted). A gateway restart re-prompts. This is a deliberate -//! security default — auto-approved writes deserve a fresh nod on every -//! launch. Users can still tick the checkbox once per session. -//! -//! Client identity is treated as an opaque `String` (the OAuth client_id -//! from the JWT — a UUID for the legacy preset-clients path, a -//! client_metadata URL for DCR-registered clients like Claude Code). The -//! broker doesn't parse it; equality + hashing is enough. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use dashmap::DashMap; -use serde::{Deserialize, Serialize}; -use tokio::sync::{oneshot, Mutex}; -use tracing::{debug, warn}; -use uuid::Uuid; - -use super::MetaToolError; - -/// Default timeout for a single approval prompt. -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); - -/// Rate limit: max pending approvals per (client_id) within the window. -const RATE_LIMIT_MAX_PENDING: usize = 10; -const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); - -/// User's decision on an approval prompt. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ApprovalDecision { - AllowOnce, - /// Allow this (client, tool) pair for the rest of the gateway session. - AlwaysForThisSessionAndClient, - Deny, -} - -/// Scope of an "always allow" grant. Session-only for now; `Persisted` is -/// reserved for a future settings-backed opt-in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ApprovalScope { - Once, - SessionClient, - #[allow(dead_code)] - Persisted, -} - -/// Payload delivered to the desktop UI so it can render a meaningful dialog. -/// -/// Keep this narrow and JSON-serializable — it crosses the Tauri boundary. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApprovalPayload { - pub tool_name: String, - /// Human summary the dialog puts above the diff. e.g. - /// "Pin this connection to FeatureSet 'android-dev' (12 tools)". - pub summary: String, - /// Name of the Space this write targets, surfaced as a labeled chip so the - /// user can see (and reject) a change aimed at a Space other than the one - /// they expect — important now that a client may pass any `space_id`. - /// `None` for writes with no single target Space. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub space_name: Option, - /// Tool-list diff the dialog shows to make the change concrete. - /// Optional because some writes (e.g. create_feature_set without - /// activation) don't shift the caller's resolved toolset. - pub diff: Option, - /// Raw arguments the LLM supplied; shown verbatim for auditability. - pub raw_args: serde_json::Value, - /// Does this change affect clients other than the caller? Dictates - /// whether the dialog shows the "also affects other connections" warning. - pub affects_other_clients: bool, -} - -/// Data the broker hands to whoever listens for approval requests. -#[derive(Debug, Clone, Serialize)] -pub struct ApprovalRequest { - pub request_id: String, - pub client_id: String, - pub payload: ApprovalPayload, - /// UNIX seconds at which this request will time out if no response. - pub expires_at_unix_secs: u64, -} - -/// Subscribe-once handler the desktop layer attaches so broker requests -/// reach the Tauri event bus. -/// -/// `respond` closure returns `true` when the listener accepted delivery, -/// `false` when no desktop was attached — which the broker treats as -/// "headless gateway, deny". -pub type ApprovalPublisher = Arc< - dyn Fn(ApprovalRequest) -> futures::future::BoxFuture<'static, bool> + Send + Sync + 'static, ->; - -/// The broker itself. -pub struct ApprovalBroker { - /// Pending oneshot senders keyed by request_id — the Tauri command - /// `respond_to_meta_tool_approval` resolves these. - pending: DashMap>, - /// Session-scoped always-allow grants, keyed by (client_id, tool_name). - /// `client_id` is opaque (UUID for preset clients, URL for DCR clients); - /// the broker only does equality lookups. - always_allow: DashMap<(String, String), ()>, - /// (client_id) -> Vec for rate limiting. - rate_limit: DashMap>, - /// Published to the desktop layer; `None` means headless. - publisher: Mutex>, - timeout: Duration, - /// Whether write meta-tools require human approval at all. Default `true` - /// (every write prompts). A user can turn this OFF in Settings to trust a - /// local machine — then writes are auto-approved without a dialog. The - /// authoritative value is **persisted** in app settings - /// (`meta_tools.require_approval`); this in-memory flag is restored from - /// there on every gateway start (the broker is recreated per start). - require_approval: AtomicBool, -} - -impl Default for ApprovalBroker { - fn default() -> Self { - Self::new() - } -} - -impl ApprovalBroker { - pub fn new() -> Self { - Self { - pending: DashMap::new(), - always_allow: DashMap::new(), - rate_limit: DashMap::new(), - publisher: Mutex::new(None), - timeout: DEFAULT_TIMEOUT, - require_approval: AtomicBool::new(true), - } - } - - pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } - - /// Set whether write meta-tools require approval. `false` = auto-approve - /// every write (no dialog) — the user's explicit "trust this machine" - /// choice. Persisted by the caller; applied to the broker here. - pub fn set_require_approval(&self, required: bool) { - self.require_approval.store(required, Ordering::Relaxed); - if !required { - warn!( - "[ApprovalBroker] approval requirement DISABLED — meta-tool writes auto-approved" - ); - } - } - - /// Whether write meta-tools currently require approval (default `true`). - pub fn require_approval_enabled(&self) -> bool { - self.require_approval.load(Ordering::Relaxed) - } - - /// Attach the desktop subscriber. Call once at app startup. - pub async fn set_publisher(&self, publisher: ApprovalPublisher) { - *self.publisher.lock().await = Some(publisher); - } - - /// For tests / headless scenarios: pre-approve everything from a - /// specific client. - #[cfg(test)] - pub fn insert_always_allow(&self, client_id: &str, tool_name: &str) { - self.always_allow - .insert((client_id.to_string(), tool_name.to_string()), ()); - } - - /// Resolve a pending approval. Called from Tauri command when the user - /// clicks a dialog button. `scope` converts "allow" into an optional - /// always-allow entry. - pub fn respond( - &self, - request_id: &str, - client_id: &str, - tool_name: &str, - decision: ApprovalDecision, - ) -> bool { - // Persist always-allow before firing the waiter so a racing second - // call from the same client sees it. - if matches!(decision, ApprovalDecision::AlwaysForThisSessionAndClient) { - self.always_allow - .insert((client_id.to_string(), tool_name.to_string()), ()); - } - if let Some((_, tx)) = self.pending.remove(request_id) { - tx.send(decision).is_ok() - } else { - warn!( - %request_id, - "[ApprovalBroker] respond() for unknown/expired request", - ); - false - } - } - - /// List currently pending (unresolved) approvals. Useful for UI recovery - /// when the dialog is closed mid-request. - pub fn list_pending_ids(&self) -> Vec { - self.pending.iter().map(|e| e.key().clone()).collect() - } - - /// List always-allow grants (for the UI to display + revoke). - pub fn list_always_allow(&self) -> Vec<(String, String)> { - self.always_allow.iter().map(|e| e.key().clone()).collect() - } - - /// Revoke an always-allow entry. - pub fn revoke_always_allow(&self, client_id: &str, tool_name: &str) -> bool { - self.always_allow - .remove(&(client_id.to_string(), tool_name.to_string())) - .is_some() - } - - /// Core entry point for write meta tools. - /// - /// Order of checks: - /// 0. Approval requirement disabled (user opt-out) → `AllowOnce`. - /// 1. Always-allow hit → immediate `AllowOnce` (no dialog). - /// 2. Rate limit overflow → `RateLimited`. - /// 3. No publisher attached → `ApprovalRequiredNoDesktop`. - /// 4. Emit + wait → Allow / Deny / Timeout. - pub async fn request_approval( - &self, - client_id: &str, - tool_name: &str, - payload: ApprovalPayload, - ) -> Result { - // 0. Global "require approval" switch OFF — the user has opted to - // auto-approve every write on this (trusted, local) machine. - if !self.require_approval.load(Ordering::Relaxed) { - debug!( - %client_id, - tool = tool_name, - "[ApprovalBroker] approval requirement disabled; approving without dialog", - ); - return Ok(ApprovalDecision::AllowOnce); - } - - // 1. Always-allow short-circuit. - if self - .always_allow - .contains_key(&(client_id.to_string(), tool_name.to_string())) - { - debug!( - %client_id, - tool = tool_name, - "[ApprovalBroker] always-allow hit; approving without dialog", - ); - return Ok(ApprovalDecision::AllowOnce); - } - - // 2. Rate limit. - self.prune_rate_limit(client_id); - let pending_for_client = self - .rate_limit - .get(client_id) - .map(|e| e.value().len()) - .unwrap_or(0); - if pending_for_client >= RATE_LIMIT_MAX_PENDING { - warn!( - %client_id, - tool = tool_name, - pending = pending_for_client, - "[ApprovalBroker] rate-limited", - ); - return Err(MetaToolError::RateLimited); - } - self.rate_limit - .entry(client_id.to_string()) - .or_default() - .push(Instant::now()); - - // 3. Require an attached publisher. - let publisher = match self.publisher.lock().await.clone() { - Some(p) => p, - None => { - warn!( - %client_id, - tool = tool_name, - "[ApprovalBroker] no publisher attached; failing approval", - ); - return Err(MetaToolError::ApprovalRequiredNoDesktop); - } - }; - - // 4. Emit + wait on oneshot. - let request_id = Uuid::new_v4().to_string(); - let expires_at = chrono::Utc::now() + chrono::Duration::from_std(self.timeout).unwrap(); - let request = ApprovalRequest { - request_id: request_id.clone(), - client_id: client_id.to_string(), - payload, - expires_at_unix_secs: expires_at.timestamp() as u64, - }; - - let (tx, rx) = oneshot::channel(); - self.pending.insert(request_id.clone(), tx); - - let delivered = publisher(request.clone()).await; - if !delivered { - // Publisher disavowed delivery — treat like "no desktop". - self.pending.remove(&request_id); - return Err(MetaToolError::ApprovalRequiredNoDesktop); - } - - match tokio::time::timeout(self.timeout, rx).await { - Ok(Ok(decision)) => match decision { - ApprovalDecision::Deny => Err(MetaToolError::ApprovalDenied), - other => Ok(other), - }, - Ok(Err(_)) => { - // Sender dropped without deciding — treat as deny. - Err(MetaToolError::ApprovalDenied) - } - Err(_) => { - self.pending.remove(&request_id); - Err(MetaToolError::ApprovalTimedOut) - } - } - } - - fn prune_rate_limit(&self, client_id: &str) { - if let Some(mut entry) = self.rate_limit.get_mut(client_id) { - let cutoff = Instant::now() - RATE_LIMIT_WINDOW; - entry.retain(|t| *t > cutoff); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::FutureExt; - - fn make_payload() -> ApprovalPayload { - ApprovalPayload { - tool_name: "mcpmux_pin_this_session".into(), - summary: "test".into(), - space_name: None, - diff: None, - raw_args: serde_json::json!({}), - affects_other_clients: false, - } - } - - #[tokio::test] - async fn no_publisher_returns_no_desktop_error() { - let broker = ApprovalBroker::new(); - let err = broker - .request_approval( - &Uuid::new_v4().to_string(), - "mcpmux_pin_this_session", - make_payload(), - ) - .await - .unwrap_err(); - assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop)); - } - - #[tokio::test] - async fn always_allow_short_circuits() { - let broker = ApprovalBroker::new(); - let client_id = Uuid::new_v4().to_string(); - broker.insert_always_allow(&client_id, "mcpmux_pin_this_session"); - let d = broker - .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) - .await - .unwrap(); - assert_eq!(d, ApprovalDecision::AllowOnce); - } - - #[tokio::test] - async fn require_approval_off_auto_approves_without_publisher() { - // Default is ON (require approval). - let broker = ApprovalBroker::new(); - assert!(broker.require_approval_enabled()); - - // OFF → writes auto-approve even with no desktop attached (which would - // otherwise be ApprovalRequiredNoDesktop). - broker.set_require_approval(false); - assert!(!broker.require_approval_enabled()); - let d = broker - .request_approval( - &Uuid::new_v4().to_string(), - "mcpmux_manage_feature_set", - make_payload(), - ) - .await - .unwrap(); - assert_eq!(d, ApprovalDecision::AllowOnce); - - // Back ON → no publisher → safe headless deny again. - broker.set_require_approval(true); - let err = broker - .request_approval( - &Uuid::new_v4().to_string(), - "mcpmux_manage_feature_set", - make_payload(), - ) - .await - .unwrap_err(); - assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop)); - } - - #[tokio::test] - async fn url_client_id_works() { - // Regression for the bug where DCR-registered clients (which use - // a client_metadata URL as their client_id) couldn't get past the - // approval flow because we tried to parse the URL as a UUID. - let broker = ApprovalBroker::new(); - let url_client_id = "https://claude.ai/oauth/claude-code-client-metadata"; - broker.insert_always_allow(url_client_id, "mcpmux_pin_this_session"); - let d = broker - .request_approval(url_client_id, "mcpmux_pin_this_session", make_payload()) - .await - .unwrap(); - assert_eq!(d, ApprovalDecision::AllowOnce); - } - - #[tokio::test] - async fn publisher_allow_resolves() { - let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); - let broker_clone = broker.clone(); - let client_id = Uuid::new_v4().to_string(); - - // Publisher responds asynchronously with Allow. - let publisher: ApprovalPublisher = Arc::new(move |req| { - let b = broker_clone.clone(); - async move { - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(10)).await; - b.respond( - &req.request_id, - &req.client_id, - &req.payload.tool_name, - ApprovalDecision::AllowOnce, - ); - }); - true - } - .boxed() - }); - broker.set_publisher(publisher).await; - - let decision = broker - .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) - .await - .unwrap(); - assert_eq!(decision, ApprovalDecision::AllowOnce); - } - - #[tokio::test] - async fn publisher_deny_returns_denied_error() { - let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); - let broker_clone = broker.clone(); - let client_id = Uuid::new_v4().to_string(); - - let publisher: ApprovalPublisher = Arc::new(move |req| { - let b = broker_clone.clone(); - async move { - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(10)).await; - b.respond( - &req.request_id, - &req.client_id, - &req.payload.tool_name, - ApprovalDecision::Deny, - ); - }); - true - } - .boxed() - }); - broker.set_publisher(publisher).await; - - let err = broker - .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) - .await - .unwrap_err(); - assert!(matches!(err, MetaToolError::ApprovalDenied)); - } - - #[tokio::test] - async fn publisher_timeout() { - let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(50))); - // Publisher accepts delivery but never responds. - let publisher: ApprovalPublisher = Arc::new(move |_req| async move { true }.boxed()); - broker.set_publisher(publisher).await; - - let err = broker - .request_approval( - &Uuid::new_v4().to_string(), - "mcpmux_pin_this_session", - make_payload(), - ) - .await - .unwrap_err(); - assert!(matches!(err, MetaToolError::ApprovalTimedOut)); - } - - #[tokio::test] - async fn always_scope_persists_across_calls() { - let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); - let broker_clone = broker.clone(); - let client_id = Uuid::new_v4().to_string(); - - let publisher: ApprovalPublisher = Arc::new(move |req| { - let b = broker_clone.clone(); - async move { - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(10)).await; - b.respond( - &req.request_id, - &req.client_id, - &req.payload.tool_name, - ApprovalDecision::AlwaysForThisSessionAndClient, - ); - }); - true - } - .boxed() - }); - broker.set_publisher(publisher).await; - - // First call → dialog, returns AlwaysForThisSessionAndClient. - let d1 = broker - .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) - .await - .unwrap(); - assert_eq!(d1, ApprovalDecision::AlwaysForThisSessionAndClient); - - // Second call → short-circuits via always-allow entry. - let d2 = broker - .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) - .await - .unwrap(); - assert_eq!(d2, ApprovalDecision::AllowOnce); - } -} +//! Facade module: types live in [`approval_types`], broker logic in +//! [`approval_broker`]. External callers import via `meta_tools::approval::` +//! or the `mod.rs` re-exports — unchanged from before the Phase 8 split. + +#[path = "approval_broker.rs"] +mod approval_broker; +#[path = "approval_types.rs"] +mod approval_types; + +pub use approval_broker::{ + ApprovalBroker, ApprovalDecision, ApprovalPublisher, ApprovalScope, ResolutionNotifier, + META_TOOL_APPROVAL_EVENT, META_TOOL_APPROVAL_RESOLVED_EVENT, +}; +pub use approval_types::{ApprovalPayload, ApprovalRequest}; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval_broker.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval_broker.rs new file mode 100644 index 00000000..b0f24ddf --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval_broker.rs @@ -0,0 +1,332 @@ +//! Native-dialog approval broker for meta-tool writes. +//! +//! When an LLM calls a write meta tool (e.g. `mcpmux_pin_this_session`), +//! the gateway needs human sign-off before mutating state. The broker +//! bridges that: the tool calls [`ApprovalBroker::request_approval`], which +//! emits a Tauri event the desktop app listens for, awaits a response on a +//! oneshot channel, and returns [`ApprovalDecision`] — Allow (once/always) +//! or Deny (user-denied / timeout / rate-limited / no-desktop). +//! +//! Two non-obvious bits: +//! +//! * If no desktop is attached (headless CLI, tests without the subscriber +//! wired), [`ApprovalBroker::request_approval`] returns +//! [`MetaToolError::ApprovalRequiredNoDesktop`] immediately — a write +//! without an approver is a silent deny, which is the safe failure mode. +//! +//! * "Always allow" entries are **session-only** (in-memory `DashMap`, +//! not persisted). A gateway restart re-prompts. This is a deliberate +//! security default — auto-approved writes deserve a fresh nod on every +//! launch. Users can still tick the checkbox once per session. +//! +//! Client identity is treated as an opaque `String` (the OAuth client_id +//! from the JWT — a UUID for the legacy preset-clients path, a +//! client_metadata URL for DCR-registered clients like Claude Code). The +//! broker doesn't parse it; equality + hashing is enough. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tokio::sync::{oneshot, Mutex}; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::super::MetaToolError; +use super::approval_types::{ApprovalPayload, ApprovalRequest}; + +/// Tauri / admin SSE channel for pending meta-tool approval dialogs. +pub const META_TOOL_APPROVAL_EVENT: &str = "meta-tool-approval-request"; + +/// Tauri / admin SSE channel emitted when an approval is resolved (approved +/// or denied) from any surface. Both Tauri and browser dialogs listen for +/// this to auto-dismiss when the other surface acts first. +pub const META_TOOL_APPROVAL_RESOLVED_EVENT: &str = "meta-tool-approval-resolved"; + +/// Callback invoked by the broker whenever an approval is resolved. +/// +/// Receives `(request_id, decision)`. Used to broadcast the resolution to +/// all attached surfaces so orphaned dialogs can self-dismiss. +pub type ResolutionNotifier = Arc< + dyn Fn(String, ApprovalDecision) -> futures::future::BoxFuture<'static, ()> + + Send + + Sync + + 'static, +>; + +/// Default timeout for a single approval prompt. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Rate limit: max approval *requests* per `client_id` within +/// `RATE_LIMIT_WINDOW` (a sliding request-rate window, not a count of +/// currently-pending dialogs). +const RATE_LIMIT_MAX_PENDING: usize = 10; +const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); + +/// User's decision on an approval prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + AllowOnce, + /// Allow this (client, tool) pair for the rest of the gateway session. + AlwaysForThisSessionAndClient, + Deny, +} + +/// Scope of an "always allow" grant. Session-only for now; `Persisted` is +/// reserved for a future settings-backed opt-in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalScope { + Once, + SessionClient, + #[allow(dead_code)] + Persisted, +} + +/// Subscribe-once handler the desktop layer attaches so broker requests +/// reach the Tauri event bus. +/// +/// `respond` closure returns `true` when the listener accepted delivery, +/// `false` when no desktop was attached — which the broker treats as +/// "headless gateway, deny". +pub type ApprovalPublisher = Arc< + dyn Fn(ApprovalRequest) -> futures::future::BoxFuture<'static, bool> + Send + Sync + 'static, +>; + +/// The broker itself. +pub struct ApprovalBroker { + /// Pending oneshot senders keyed by request_id — the Tauri command + /// `respond_to_meta_tool_approval` resolves these. + pending: DashMap>, + /// Session-scoped always-allow grants, keyed by (client_id, tool_name). + /// `client_id` is opaque (UUID for preset clients, URL for DCR clients); + /// the broker only does equality lookups. + always_allow: DashMap<(String, String), ()>, + /// (client_id) -> Vec for rate limiting. + rate_limit: DashMap>, + /// Published to the desktop layer; `None` means headless. + publisher: Mutex>, + /// Called after every `respond()` so all surfaces (Tauri + browser) can + /// dismiss orphaned dialogs for the resolved request_id. + resolution_notifier: Mutex>, + timeout: Duration, +} + +impl Default for ApprovalBroker { + fn default() -> Self { + Self::new() + } +} + +impl ApprovalBroker { + pub fn new() -> Self { + Self { + pending: DashMap::new(), + always_allow: DashMap::new(), + rate_limit: DashMap::new(), + publisher: Mutex::new(None), + resolution_notifier: Mutex::new(None), + timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Attach the desktop subscriber. Call once at app startup. + pub async fn set_publisher(&self, publisher: ApprovalPublisher) { + *self.publisher.lock().await = Some(publisher); + } + + /// Attach the resolution notifier. Call once alongside `set_publisher`. + /// + /// The notifier is called after every `respond()` so all surfaces (Tauri + /// and browser SSE) can dismiss orphaned dialogs for the resolved + /// `request_id`. + pub async fn set_resolution_notifier(&self, notifier: ResolutionNotifier) { + *self.resolution_notifier.lock().await = Some(notifier); + } + + /// For tests / headless scenarios: pre-approve everything from a + /// specific client. + #[cfg(test)] + pub fn insert_always_allow(&self, client_id: &str, tool_name: &str) { + self.always_allow + .insert((client_id.to_string(), tool_name.to_string()), ()); + } + + /// Resolve a pending approval. Called from Tauri command or admin HTTP + /// handler when the user clicks a dialog button. `scope` converts "allow" + /// into an optional always-allow entry. + /// + /// After resolving the oneshot, fires the `resolution_notifier` so every + /// attached surface (Tauri + browser SSE) can dismiss its dialog for this + /// `request_id`, preventing orphaned dialogs when both surfaces are open. + pub fn respond( + &self, + request_id: &str, + client_id: &str, + tool_name: &str, + decision: ApprovalDecision, + ) -> bool { + // Persist always-allow before firing the waiter so a racing second + // call from the same client sees it. + if matches!(decision, ApprovalDecision::AlwaysForThisSessionAndClient) { + self.always_allow + .insert((client_id.to_string(), tool_name.to_string()), ()); + } + let resolved = if let Some((_, tx)) = self.pending.remove(request_id) { + tx.send(decision).is_ok() + } else { + warn!( + %request_id, + "[ApprovalBroker] respond() for unknown/expired request", + ); + false + }; + + // Notify all surfaces so orphaned dialogs can self-dismiss. + // Fire-and-forget: clone the notifier out of the Mutex synchronously + // (try_lock) to avoid async in a sync fn. If the lock is contended + // the notification is skipped — the dialog will close on timeout. + if resolved { + if let Ok(guard) = self.resolution_notifier.try_lock() { + if let Some(ref notifier) = *guard { + let fut = notifier(request_id.to_string(), decision); + tokio::spawn(fut); + } + } + } + + resolved + } + + /// List currently pending (unresolved) approvals. Useful for UI recovery + /// when the dialog is closed mid-request. + pub fn list_pending_ids(&self) -> Vec { + self.pending.iter().map(|e| e.key().clone()).collect() + } + + /// List always-allow grants (for the UI to display + revoke). + pub fn list_always_allow(&self) -> Vec<(String, String)> { + self.always_allow.iter().map(|e| e.key().clone()).collect() + } + + /// Revoke an always-allow entry. + pub fn revoke_always_allow(&self, client_id: &str, tool_name: &str) -> bool { + self.always_allow + .remove(&(client_id.to_string(), tool_name.to_string())) + .is_some() + } + + /// Core entry point for write meta tools. + /// + /// Order of checks: + /// 1. Always-allow hit → immediate `AllowOnce` (no dialog). + /// 2. Rate limit overflow → `RateLimited`. + /// 3. No publisher attached → `ApprovalRequiredNoDesktop`. + /// 4. Emit + wait → Allow / Deny / Timeout. + pub async fn request_approval( + &self, + client_id: &str, + tool_name: &str, + payload: ApprovalPayload, + ) -> Result { + // 1. Always-allow short-circuit. + if self + .always_allow + .contains_key(&(client_id.to_string(), tool_name.to_string())) + { + debug!( + %client_id, + tool = tool_name, + "[ApprovalBroker] always-allow hit; approving without dialog", + ); + return Ok(ApprovalDecision::AllowOnce); + } + + // 2. Rate limit. + self.prune_rate_limit(client_id); + let pending_for_client = self + .rate_limit + .get(client_id) + .map(|e| e.value().len()) + .unwrap_or(0); + if pending_for_client >= RATE_LIMIT_MAX_PENDING { + warn!( + %client_id, + tool = tool_name, + pending = pending_for_client, + "[ApprovalBroker] rate-limited", + ); + return Err(MetaToolError::RateLimited); + } + self.rate_limit + .entry(client_id.to_string()) + .or_default() + .push(Instant::now()); + + // 3. Require an attached publisher. + let publisher = match self.publisher.lock().await.clone() { + Some(p) => p, + None => { + warn!( + %client_id, + tool = tool_name, + "[ApprovalBroker] no publisher attached; failing approval", + ); + return Err(MetaToolError::ApprovalRequiredNoDesktop); + } + }; + + // 4. Emit + wait on oneshot. + let request_id = Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::from_std(self.timeout).unwrap(); + let request = ApprovalRequest { + request_id: request_id.clone(), + client_id: client_id.to_string(), + payload, + expires_at_unix_secs: expires_at.timestamp() as u64, + }; + + let (tx, rx) = oneshot::channel(); + self.pending.insert(request_id.clone(), tx); + + let delivered = publisher(request.clone()).await; + if !delivered { + // Publisher disavowed delivery — treat like "no desktop". + self.pending.remove(&request_id); + return Err(MetaToolError::ApprovalRequiredNoDesktop); + } + + match tokio::time::timeout(self.timeout, rx).await { + Ok(Ok(decision)) => match decision { + ApprovalDecision::Deny => Err(MetaToolError::ApprovalDenied), + other => Ok(other), + }, + Ok(Err(_)) => { + // Sender dropped without deciding — treat as deny. + Err(MetaToolError::ApprovalDenied) + } + Err(_) => { + self.pending.remove(&request_id); + Err(MetaToolError::ApprovalTimedOut) + } + } + } + + fn prune_rate_limit(&self, client_id: &str) { + if let Some(mut entry) = self.rate_limit.get_mut(client_id) { + let cutoff = Instant::now() - RATE_LIMIT_WINDOW; + entry.retain(|t| *t > cutoff); + } + } +} + +#[cfg(test)] +#[path = "approval_broker_tests.rs"] +mod tests; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval_broker_tests.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval_broker_tests.rs new file mode 100644 index 00000000..14f1f2cc --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval_broker_tests.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt; +use uuid::Uuid; + +use super::super::super::MetaToolError; +use super::super::approval_types::ApprovalPayload; +use super::{ApprovalBroker, ApprovalDecision, ApprovalPublisher}; + +fn make_payload() -> ApprovalPayload { + ApprovalPayload { + tool_name: "mcpmux_pin_this_session".into(), + summary: "test".into(), + diff: None, + raw_args: serde_json::json!({}), + affects_other_clients: false, + } +} + +#[tokio::test] +async fn no_publisher_returns_no_desktop_error() { + let broker = ApprovalBroker::new(); + let err = broker + .request_approval( + &Uuid::new_v4().to_string(), + "mcpmux_pin_this_session", + make_payload(), + ) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop)); +} + +#[tokio::test] +async fn always_allow_short_circuits() { + let broker = ApprovalBroker::new(); + let client_id = Uuid::new_v4().to_string(); + broker.insert_always_allow(&client_id, "mcpmux_pin_this_session"); + let d = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d, ApprovalDecision::AllowOnce); +} + +#[tokio::test] +async fn url_client_id_works() { + // Regression for the bug where DCR-registered clients (which use + // a client_metadata URL as their client_id) couldn't get past the + // approval flow because we tried to parse the URL as a UUID. + let broker = ApprovalBroker::new(); + let url_client_id = "https://claude.ai/oauth/claude-code-client-metadata"; + broker.insert_always_allow(url_client_id, "mcpmux_pin_this_session"); + let d = broker + .request_approval(url_client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d, ApprovalDecision::AllowOnce); +} + +#[tokio::test] +async fn publisher_allow_resolves() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4().to_string(); + + // Publisher responds asynchronously with Allow. + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + ApprovalDecision::AllowOnce, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + let decision = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(decision, ApprovalDecision::AllowOnce); +} + +#[tokio::test] +async fn publisher_deny_returns_denied_error() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4().to_string(); + + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + ApprovalDecision::Deny, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + let err = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalDenied)); +} + +#[tokio::test] +async fn publisher_timeout() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(50))); + // Publisher accepts delivery but never responds. + let publisher: ApprovalPublisher = Arc::new(move |_req| async move { true }.boxed()); + broker.set_publisher(publisher).await; + + let err = broker + .request_approval( + &Uuid::new_v4().to_string(), + "mcpmux_pin_this_session", + make_payload(), + ) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalTimedOut)); +} + +#[tokio::test] +async fn always_scope_persists_across_calls() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4().to_string(); + + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + ApprovalDecision::AlwaysForThisSessionAndClient, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + // First call → dialog, returns AlwaysForThisSessionAndClient. + let d1 = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d1, ApprovalDecision::AlwaysForThisSessionAndClient); + + // Second call → short-circuits via always-allow entry. + let d2 = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d2, ApprovalDecision::AllowOnce); +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval_types.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval_types.rs new file mode 100644 index 00000000..af4948ad --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval_types.rs @@ -0,0 +1,38 @@ +//! Serializable approval payload types for meta-tool write dialogs. + +use serde::{Deserialize, Serialize}; + +/// Payload delivered to the desktop UI so it can render a meaningful dialog. +/// +/// Keep this narrow and JSON-serializable — it crosses the Tauri boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalPayload { + pub tool_name: String, + /// Human summary the dialog puts above the diff. e.g. + /// "Pin this connection to FeatureSet 'android-dev' (12 tools)". + pub summary: String, + /// Tool-list diff the dialog shows to make the change concrete. + /// Optional because some writes (e.g. create_feature_set without + /// activation) don't shift the caller's resolved toolset. + pub diff: Option, + /// Raw arguments the LLM supplied; shown verbatim for auditability. + /// + /// TODO(redaction): no write meta tool accepts credential-bearing args + /// today, so rendering this unredacted in the dialog / SSE is safe. If a + /// future write tool takes secrets, add a per-tool redaction allowlist + /// before this payload crosses the Tauri / admin-SSE boundary. + pub raw_args: serde_json::Value, + /// Does this change affect clients other than the caller? Dictates + /// whether the dialog shows the "also affects other connections" warning. + pub affects_other_clients: bool, +} + +/// Data the broker hands to whoever listens for approval requests. +#[derive(Debug, Clone, Serialize)] +pub struct ApprovalRequest { + pub request_id: String, + pub client_id: String, + pub payload: ApprovalPayload, + /// UNIX seconds at which this request will time out if no response. + pub expires_at_unix_secs: u64, +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs b/crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs new file mode 100644 index 00000000..947faa3e --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs @@ -0,0 +1,176 @@ +//! `mcpmux_bind_current_workspace` — persistently layer a FeatureSet onto a workspace binding. + +use async_trait::async_trait; +use mcpmux_core::{normalize_workspace_root, WorkspaceBinding}; +use rmcp::model::CallToolResult; +use serde_json::{json, Value}; +use tracing::info; + +use super::meta_tool_common::{ + caller_space_id, emit_tools_list_changed, emit_workspace_binding_changed, parse_uuid_arg, + text_result, with_approval, +}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; + +pub struct BindCurrentWorkspaceTool; + +#[async_trait] +impl MetaTool for BindCurrentWorkspaceTool { + fn name(&self) -> &'static str { + "mcpmux_bind_current_workspace" + } + + fn description(&self) -> &'static str { + "Canonical activation path: persistently append an existing FeatureSet \ + onto the caller's workspace binding (layers with existing bundles, \ + deduped). Use after mcpmux_search_tools (include_inactive: true) or \ + mcpmux_list_feature_sets to obtain feature_set_id. Every future \ + connection reporting the same root inherits the binding. Requires \ + approval; the client MUST have declared MCP roots." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["feature_set_id"], + "properties": { + "feature_set_id": { "type": "string" } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; + + let space_id = caller_space_id(&call).await?; + let roots = call + .session_id + .and_then(|sid| call.ctx.session_roots.get(sid)) + .unwrap_or_default(); + let root = roots.into_iter().next().ok_or_else(|| { + MetaToolError::InvalidArgument( + "caller did not report any MCP roots; cannot bind — \ + call mcpmux_set_workspace_root first to declare your workspace path, \ + then retry mcpmux_bind_current_workspace" + .into(), + ) + })?; + let normalized = normalize_workspace_root(&root); + + let fs_name = call + .ctx + .feature_set_repo + .get(&fs_id.to_string()) + .await? + .map(|fs| fs.name) + .unwrap_or_else(|| fs_id.to_string()); + + let binding_repo = call.ctx.binding_repo.clone(); + let fs_id_str = fs_id.to_string(); + let caller_client_id = call.client_id.to_string(); + + // Dedup before consent: repeat binds must not re-prompt the user. + if let Some(existing) = binding_repo + .find_longest_prefix_match( + &space_id, + Some(&caller_client_id), + std::slice::from_ref(&normalized), + ) + .await? + { + if existing.feature_set_ids.iter().any(|id| id == &fs_id_str) { + return Ok(text_result(json!({ + "ok": true, + "binding_id": existing.id, + "workspace_root": normalized, + "feature_set_id": fs_id, + "feature_set_ids": existing.feature_set_ids, + "already_bound": true, + }))); + } + } + + let summary = format!( + "Append FeatureSet '{fs_name}' to workspace '{normalized}' binding \ + for client '{caller_client_id}' (existing bundles preserved)." + ); + + let event_tx = call.ctx.domain_event_tx.clone(); + with_approval( + &call, + "mcpmux_bind_current_workspace", + summary, + None, + true, + call.args.clone(), + || async move { + let fs_id_str = fs_id.to_string(); + let existing = binding_repo + .find_longest_prefix_match( + &space_id, + Some(&caller_client_id), + std::slice::from_ref(&normalized), + ) + .await?; + + let (binding_id, feature_set_ids, already_bound) = if let Some(mut binding) = + existing + { + binding.space_id = space_id; + let already_bound = binding.feature_set_ids.iter().any(|id| id == &fs_id_str); + if !already_bound { + binding.feature_set_ids.push(fs_id_str.clone()); + binding.updated_at = chrono::Utc::now(); + binding_repo.update(&binding).await?; + emit_workspace_binding_changed(&event_tx, space_id, &normalized); + } + info!( + %space_id, + client_id = %caller_client_id, + binding_id = %binding.id, + workspace_root = %normalized, + feature_set_id = %fs_id, + already_bound, + feature_set_count = binding.feature_set_ids.len(), + "[meta_tools] bind_current_workspace updated existing scoped binding", + ); + (binding.id, binding.feature_set_ids.clone(), already_bound) + } else { + let binding = WorkspaceBinding::new_scoped_multi( + normalized.clone(), + space_id, + Some(caller_client_id.clone()), + vec![fs_id_str.clone()], + ); + let binding_id = binding.id; + let feature_set_ids = binding.feature_set_ids.clone(); + binding_repo.create(&binding).await?; + info!( + %space_id, + client_id = %caller_client_id, + binding_id = %binding_id, + workspace_root = %normalized, + feature_set_id = %fs_id, + "[meta_tools] bind_current_workspace created scoped binding", + ); + (binding_id, feature_set_ids, false) + }; + + emit_tools_list_changed(&event_tx, space_id); + Ok(text_result(json!({ + "ok": true, + "binding_id": binding_id, + "workspace_root": normalized, + "feature_set_id": fs_id, + "feature_set_ids": feature_set_ids, + "already_bound": already_bound, + }))) + }, + ) + .await + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diagnose_server.rs b/crates/mcpmux-gateway/src/services/meta_tools/diagnose_server.rs new file mode 100644 index 00000000..e34dd61e --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/diagnose_server.rs @@ -0,0 +1,303 @@ +//! `mcpmux_diagnose_server` meta tool and diagnose helper functions. +//! +//! Logic ported from [`dashboard.helpers.ts`](../../../../apps/desktop/src/features/dashboard/dashboard.helpers.ts): +//! missing required inputs, health buckets, and server diagnosis assembly. + +use std::collections::HashMap; + +use async_trait::async_trait; +#[allow(unused_imports)] +use mcpmux_core::{FeatureType, InstalledServer, LogLevel, ServerDefinition, TransportConfig}; +use rmcp::model::CallToolResult; +use serde_json::{json, Value}; +use uuid::Uuid; + +use super::diagnose_view::{ + build_config_view_from_definition, build_runtime_view, parse_diagnose_args, DiagnoseArgs, +}; + +pub use super::diagnose_view::{ConfigView, ServerHealth}; +use super::meta_tool_common::{caller_space_id, text_result}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use crate::pool::ConnectionStatus; + +/// Returns IDs of required transport inputs that have no user value. +/// +/// Mirrors `hasMissingRequiredInputs` in the dashboard: uses `cached_definition` +/// transport metadata and treats empty strings as missing. Invalid JSON yields +/// an empty list (same as the TS `catch` path). +pub fn parse_missing_required_inputs(installed: &InstalledServer) -> Vec { + let Some(definition) = installed.get_definition() else { + return Vec::new(); + }; + + let values = &installed.input_values; + let mut missing = Vec::new(); + + for input in &definition.transport.metadata().inputs { + if !input.required { + continue; + } + let has_value = values.get(&input.id).is_some_and(|v| !v.is_empty()); + if !has_value { + missing.push(input.id.clone()); + } + } + + missing.sort(); + missing +} + +/// Whether any required input is unset (see [`parse_missing_required_inputs`]). +#[allow(dead_code)] +pub fn has_missing_required_inputs(installed: &InstalledServer) -> bool { + !parse_missing_required_inputs(installed).is_empty() +} + +/// Map runtime connection status and setup state to a diagnose health bucket. +/// +/// Priority matches the dashboard attention panel: missing inputs win over +/// runtime status; then error, then OAuth required, then disconnected. +pub fn classify_health(status: ConnectionStatus, has_missing_inputs: bool) -> ServerHealth { + if has_missing_inputs { + return ServerHealth::NeedsSetup; + } + + match status { + ConnectionStatus::Error => ServerHealth::Error, + ConnectionStatus::AuthRequired => ServerHealth::AuthRequired, + ConnectionStatus::Disconnected => ServerHealth::Disconnected, + ConnectionStatus::Connected + | ConnectionStatus::Connecting + | ConnectionStatus::Refreshing + | ConnectionStatus::Authenticating => ServerHealth::Healthy, + } +} + +/// Build a redacted config view from the installed server's cached definition. +/// +/// Secret input values are never included; only transport shape and key names. +pub fn build_config_view(installed: &InstalledServer) -> ConfigView { + let Some(definition) = installed.get_definition() else { + return ConfigView::default(); + }; + + build_config_view_from_definition(&definition) +} + +/// Re-export for sibling modules that imported `diagnose::connection_status_label`. +pub(crate) use super::diagnose_view::connection_status_label; + +/// Count installed tool features per server in a Space. +async fn tool_counts_for_space( + call: &MetaToolCall<'_>, + space_id: &Uuid, +) -> Result, MetaToolError> { + let features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + let mut counts = HashMap::new(); + for feature in features { + if feature.feature_type != FeatureType::Tool { + continue; + } + *counts.entry(feature.server_id.clone()).or_insert(0) += 1; + } + Ok(counts) +} + +/// Read and serialize the log tail for one server when requested. +async fn build_logs_view( + call: &MetaToolCall<'_>, + space_id: &Uuid, + server_id: &str, + include_logs: bool, + log_limit: usize, + log_level_filter: Option, +) -> Result, MetaToolError> { + if !include_logs { + return Ok(None); + } + + let entries = call + .ctx + .log_manager + .read_logs( + &space_id.to_string(), + server_id, + log_limit, + log_level_filter, + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + Ok(Some(json!({ + "count": entries.len(), + "level_filter": log_level_filter.map(|level| level.as_str()), + "entries": entries, + }))) +} + +/// Build one server entry for the diagnose response payload. +async fn build_server_diagnosis( + call: &MetaToolCall<'_>, + space_id: &Uuid, + installed: &InstalledServer, + runtime: (ConnectionStatus, u64, bool, Option), + tool_count: usize, + args: &DiagnoseArgs, +) -> Result { + let missing = parse_missing_required_inputs(installed); + let has_missing = !missing.is_empty(); + let (status, flow_id, has_connected_before, message) = runtime; + let health = classify_health(status, has_missing); + + let mut entry = json!({ + "server_id": installed.server_id, + "display_name": installed.display_name(), + "health": health, + "runtime": build_runtime_view(status, flow_id, has_connected_before, message), + "config": build_config_view(installed), + "missing_required_inputs": missing, + "tool_count": tool_count, + }); + + if let Some(logs) = build_logs_view( + call, + space_id, + &installed.server_id, + args.include_logs, + args.log_limit, + args.log_level_filter, + ) + .await? + { + entry["logs"] = logs; + } + + Ok(entry) +} + +/// Read-only combo diagnostic for MCP servers in the caller's resolved Space. +pub struct DiagnoseServerTool; + +#[async_trait] +impl MetaTool for DiagnoseServerTool { + fn name(&self) -> &'static str { + "mcpmux_diagnose_server" + } + + fn description(&self) -> &'static str { + "Operator diagnostic: return runtime status, redacted transport config, \ + missing required inputs, and a recent log tail for MCP servers in the \ + caller's resolved Space. Omit server_id to list only unhealthy servers; \ + pass server_id to inspect one server regardless of health." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server_id": { + "type": "string", + "description": "Optional. When omitted, only unhealthy servers are returned" + }, + "include_logs": { + "type": "boolean", + "default": true, + "description": "Set false to omit the logs block" + }, + "log_limit": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50, + "description": "Maximum number of log entries to return" + }, + "log_level_filter": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error"], + "description": "Minimum log level to include (inclusive)" + } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let space_id = caller_space_id(&call).await?; + let args = parse_diagnose_args(&call.args)?; + + let installed = call + .ctx + .installed_server_repo + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + if let Some(ref target) = args.server_id { + if !installed.iter().any(|s| &s.server_id == target) { + return Err(MetaToolError::InvalidArgument(format!( + "unknown server_id '{target}' in this Space" + ))); + } + } + + let statuses = call.ctx.server_manager.get_all_statuses(space_id).await; + let tool_counts = tool_counts_for_space(&call, &space_id).await?; + + let mut servers: Vec = Vec::new(); + for server in &installed { + if args + .server_id + .as_ref() + .is_some_and(|target| &server.server_id != target) + { + continue; + } + + let runtime = statuses.get(&server.server_id).cloned().unwrap_or(( + ConnectionStatus::Disconnected, + 0_u64, + false, + None::, + )); + + let missing = parse_missing_required_inputs(server); + let health = classify_health(runtime.0, !missing.is_empty()); + + if args.server_id.is_none() && !health.is_unhealthy() { + continue; + } + + let tool_count = tool_counts.get(&server.server_id).copied().unwrap_or(0); + servers.push( + build_server_diagnosis(&call, &space_id, server, runtime, tool_count, &args) + .await?, + ); + } + + servers.sort_by(|a, b| { + a.get("server_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(b.get("server_id").and_then(|v| v.as_str()).unwrap_or("")) + }); + + let total_unhealthy = servers + .iter() + .filter(|entry| entry.get("health").and_then(|v| v.as_str()) != Some("healthy")) + .count(); + + Ok(text_result(json!({ + "space_id": space_id, + "servers": servers, + "total_unhealthy": total_unhealthy, + }))) + } +} + +#[cfg(test)] +#[path = "diagnose_tests.rs"] +mod tests; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diagnose_tests.rs b/crates/mcpmux-gateway/src/services/meta_tools/diagnose_tests.rs new file mode 100644 index 00000000..43efe834 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/diagnose_tests.rs @@ -0,0 +1,220 @@ +use super::*; +use mcpmux_core::{InputDefinition, TransportMetadata}; + +fn stdio_definition(inputs: Vec) -> ServerDefinition { + ServerDefinition { + id: "test.server".to_string(), + name: "Test".to_string(), + description: None, + alias: None, + auth: None, + icon: None, + transport: TransportConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "pkg".to_string()], + env: [("GITHUB_TOKEN".to_string(), "${input:token}".to_string())] + .into_iter() + .collect(), + metadata: TransportMetadata { inputs }, + }, + categories: vec![], + publisher: None, + source: Default::default(), + badges: vec![], + hosting_type: Default::default(), + license: None, + license_url: None, + installation: None, + capabilities: None, + sponsored: None, + media: None, + changelog_url: None, + } +} + +fn installed_with_definition( + definition: &ServerDefinition, + input_values: &[(&str, &str)], +) -> InstalledServer { + let mut server = InstalledServer::new("space", "test.server").with_definition(definition); + for (key, value) in input_values { + server = server.with_input(*key, *value); + } + server +} + +fn required_input(id: &str) -> InputDefinition { + InputDefinition { + id: id.to_string(), + label: id.to_string(), + r#type: "text".to_string(), + required: true, + secret: true, + description: None, + default: None, + placeholder: None, + obtain_url: None, + obtain_instructions: None, + } +} + +fn optional_input(id: &str) -> InputDefinition { + InputDefinition { + required: false, + secret: false, + ..required_input(id) + } +} + +#[test] +fn parse_missing_returns_required_ids_without_values() { + let def = stdio_definition(vec![ + required_input("github_token"), + optional_input("optional_flag"), + required_input("api_key"), + ]); + let installed = installed_with_definition(&def, &[]); + + assert_eq!( + parse_missing_required_inputs(&installed), + vec!["api_key".to_string(), "github_token".to_string()] + ); +} + +#[test] +fn parse_missing_treats_empty_string_as_missing() { + let def = stdio_definition(vec![required_input("token")]); + let installed = installed_with_definition(&def, &[("token", "")]); + + assert_eq!( + parse_missing_required_inputs(&installed), + vec!["token".to_string()] + ); +} + +#[test] +fn parse_missing_empty_when_all_required_filled() { + let def = stdio_definition(vec![required_input("token")]); + let installed = installed_with_definition(&def, &[("token", "secret")]); + + assert!(parse_missing_required_inputs(&installed).is_empty()); +} + +#[test] +fn parse_missing_empty_without_cached_definition() { + let installed = InstalledServer::new("space", "bare"); + + assert!(parse_missing_required_inputs(&installed).is_empty()); +} + +#[test] +fn parse_missing_empty_on_invalid_cached_json() { + let mut installed = InstalledServer::new("space", "bad"); + installed.cached_definition = Some("{not json".to_string()); + + assert!(parse_missing_required_inputs(&installed).is_empty()); +} + +#[test] +fn classify_health_missing_inputs_beats_error_status() { + assert_eq!( + classify_health(ConnectionStatus::Error, true), + ServerHealth::NeedsSetup + ); +} + +#[test] +fn classify_health_error_and_auth_and_disconnected() { + assert_eq!( + classify_health(ConnectionStatus::Error, false), + ServerHealth::Error + ); + assert_eq!( + classify_health(ConnectionStatus::AuthRequired, false), + ServerHealth::AuthRequired + ); + assert_eq!( + classify_health(ConnectionStatus::Disconnected, false), + ServerHealth::Disconnected + ); +} + +#[test] +fn classify_health_connected_and_in_progress_are_healthy() { + for status in [ + ConnectionStatus::Connected, + ConnectionStatus::Connecting, + ConnectionStatus::Refreshing, + ConnectionStatus::Authenticating, + ] { + assert_eq!( + classify_health(status, false), + ServerHealth::Healthy, + "expected healthy for {status:?}" + ); + } +} + +#[test] +fn build_config_view_stdio_redacts_values() { + let def = stdio_definition(vec![ + required_input("github_token"), + optional_input("extra"), + ]); + let installed = installed_with_definition(&def, &[("github_token", "ghp_secret")]); + + let view = build_config_view(&installed); + + assert_eq!(view.transport_type.as_deref(), Some("stdio")); + assert_eq!(view.command.as_deref(), Some("npx")); + assert_eq!(view.args, vec!["-y", "pkg"]); + assert_eq!(view.env_keys, vec!["GITHUB_TOKEN"]); + assert_eq!(view.input_keys, vec!["extra", "github_token"]); + let json = serde_json::to_string(&view).expect("serialize"); + assert!(!json.contains("ghp_secret")); + assert!(!json.contains("${input:token}")); +} + +#[test] +fn build_config_view_http_includes_url_and_header_keys() { + let definition = ServerDefinition { + id: "remote".to_string(), + name: "Remote".to_string(), + description: None, + alias: None, + auth: None, + icon: None, + transport: TransportConfig::Http { + url: "https://mcp.example.com".to_string(), + headers: [("Authorization".to_string(), "Bearer x".to_string())] + .into_iter() + .collect(), + metadata: TransportMetadata { + inputs: vec![required_input("api_key")], + }, + }, + categories: vec![], + publisher: None, + source: Default::default(), + badges: vec![], + hosting_type: Default::default(), + license: None, + license_url: None, + installation: None, + capabilities: None, + sponsored: None, + media: None, + changelog_url: None, + }; + let installed = installed_with_definition(&definition, &[("api_key", "sk_live_secret")]); + let view = build_config_view(&installed); + + assert_eq!(view.transport_type.as_deref(), Some("http")); + assert_eq!(view.url.as_deref(), Some("https://mcp.example.com")); + assert!(view.command.is_none()); + assert_eq!(view.header_keys, vec!["Authorization"]); + assert_eq!(view.input_keys, vec!["api_key"]); + let json = serde_json::to_string(&view).expect("serialize"); + assert!(!json.contains("Bearer x")); + assert!(!json.contains("sk_live_secret")); +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diagnose_view.rs b/crates/mcpmux-gateway/src/services/meta_tools/diagnose_view.rs new file mode 100644 index 00000000..cb6863b9 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/diagnose_view.rs @@ -0,0 +1,167 @@ +//! Health types, config views, and runtime view builders for `mcpmux_diagnose_server`. +//! +//! Logic ported from [`dashboard.helpers.ts`](../../../../apps/desktop/src/features/dashboard/dashboard.helpers.ts): +//! redacted transport config views and runtime status serialization. + +use mcpmux_core::{LogLevel, ServerDefinition, TransportConfig}; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::registry::MetaToolError; +use crate::pool::ConnectionStatus; + +/// Operator-facing health bucket for a single installed server. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerHealth { + Healthy, + Error, + AuthRequired, + NeedsSetup, + Disconnected, +} + +impl ServerHealth { + /// Whether this bucket counts as unhealthy for no-arg diagnose filtering. + pub fn is_unhealthy(self) -> bool { + !matches!(self, Self::Healthy) + } +} + +/// Redacted transport configuration (keys only for secrets; no input values). +#[derive(Debug, Clone, Serialize, Default)] +pub struct ConfigView { + #[serde(skip_serializing_if = "Option::is_none")] + pub transport_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub env_keys: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub header_keys: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub input_keys: Vec, +} + +/// Build a redacted config view from a server definition (no installed input values). +pub(crate) fn build_config_view_from_definition(definition: &ServerDefinition) -> ConfigView { + let metadata = definition.transport.metadata(); + let mut input_keys: Vec = metadata.inputs.iter().map(|i| i.id.clone()).collect(); + input_keys.sort(); + input_keys.dedup(); + + match &definition.transport { + TransportConfig::Stdio { + command, args, env, .. + } => { + let mut env_keys: Vec = env.keys().cloned().collect(); + env_keys.sort(); + + ConfigView { + transport_type: Some("stdio".to_string()), + command: Some(command.clone()), + url: None, + args: args.clone(), + env_keys, + header_keys: Vec::new(), + input_keys, + } + } + TransportConfig::Http { url, headers, .. } => { + let mut header_keys: Vec = headers.keys().cloned().collect(); + header_keys.sort(); + + ConfigView { + transport_type: Some("http".to_string()), + command: None, + url: Some(url.clone()), + args: Vec::new(), + env_keys: Vec::new(), + header_keys, + input_keys, + } + } + } +} + +/// Serialize a pool [`ConnectionStatus`] as the diagnose runtime status string. +pub(crate) fn connection_status_label(status: ConnectionStatus) -> &'static str { + match status { + ConnectionStatus::Disconnected => "disconnected", + ConnectionStatus::Connecting => "connecting", + ConnectionStatus::Connected => "connected", + ConnectionStatus::Refreshing => "refreshing", + ConnectionStatus::AuthRequired => "auth_required", + ConnectionStatus::Authenticating => "authenticating", + ConnectionStatus::Error => "error", + } +} + +/// Parsed arguments for [`super::diagnose_server::DiagnoseServerTool`]. +pub(crate) struct DiagnoseArgs { + pub(crate) server_id: Option, + pub(crate) include_logs: bool, + pub(crate) log_limit: usize, + pub(crate) log_level_filter: Option, +} + +/// Parse and validate `mcpmux_diagnose_server` call arguments. +pub(crate) fn parse_diagnose_args(args: &Value) -> Result { + let server_id = args + .get("server_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let include_logs = args + .get("include_logs") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let log_limit = args + .get("log_limit") + .and_then(|v| v.as_u64()) + .unwrap_or(50) + .min(500) as usize; + + let log_level_filter = match args.get("log_level_filter") { + None | Some(Value::Null) => None, + Some(v) => { + let Some(raw) = v.as_str() else { + return Err(MetaToolError::InvalidArgument( + "`log_level_filter` must be a string".into(), + )); + }; + Some(LogLevel::parse(raw).ok_or_else(|| { + MetaToolError::InvalidArgument(format!( + "invalid log_level_filter '{raw}'; expected trace, debug, info, warn, or error" + )) + })?) + } + }; + + Ok(DiagnoseArgs { + server_id, + include_logs, + log_limit, + log_level_filter, + }) +} + +/// Build the runtime sub-object for one diagnosed server. +pub(crate) fn build_runtime_view( + status: ConnectionStatus, + flow_id: u64, + has_connected_before: bool, + message: Option, +) -> Value { + json!({ + "status": connection_status_label(status), + "flow_id": flow_id, + "has_connected_before": has_connected_before, + "message": message, + }) +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/disclosure_backend.rs b/crates/mcpmux-gateway/src/services/meta_tools/disclosure_backend.rs new file mode 100644 index 00000000..88899a7b --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/disclosure_backend.rs @@ -0,0 +1,54 @@ +//! Pluggable backend for meta-gateway resource read and prompt fetch. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::pool::PoolService; + +/// Reads resources and fetches prompts from backend MCP servers. +#[async_trait] +pub trait DisclosureBackend: Send + Sync { + /// Read a backend resource URI and return MCP content blocks as JSON values. + async fn read_resource(&self, space_id: Uuid, server_id: &str, uri: &str) + -> Result>; + + /// Fetch a backend prompt and return the serialized MCP result. + async fn fetch_prompt( + &self, + space_id: Uuid, + server_id: &str, + prompt_name: &str, + arguments: Option>, + ) -> Result; +} + +#[async_trait] +impl DisclosureBackend for PoolService { + async fn read_resource( + &self, + space_id: Uuid, + server_id: &str, + uri: &str, + ) -> Result> { + PoolService::read_resource(self, space_id, server_id, uri).await + } + + async fn fetch_prompt( + &self, + space_id: Uuid, + server_id: &str, + prompt_name: &str, + arguments: Option>, + ) -> Result { + PoolService::get_prompt(self, space_id, server_id, prompt_name, arguments).await + } +} + +/// Wrap a [`PoolService`] as a [`DisclosureBackend`] trait object. +pub fn pool_as_disclosure_backend(pool: Arc) -> Arc { + pool +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/disclosure_read.rs b/crates/mcpmux-gateway/src/services/meta_tools/disclosure_read.rs new file mode 100644 index 00000000..c6b9b810 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/disclosure_read.rs @@ -0,0 +1,245 @@ +//! Meta tools for reading resources and fetching prompts (progressive disclosure). + +use std::collections::HashSet; + +use async_trait::async_trait; +use rmcp::model::{CallToolResult, Content}; +use serde_json::{json, Value}; + +use super::meta_tool_common::{caller_resolution, caller_space_id, text_result}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use crate::pool::{format_server_inactive_error, FeatureService}; +use crate::services::levenshtein_suggestions; + +/// Returns whether `server_id` is active via the caller's binding. +fn is_server_active(server_id: &str, binding_servers: &HashSet) -> bool { + binding_servers.contains(server_id) +} + +/// Collect binding server ids for the caller's resolved FeatureSets. +async fn binding_servers_for_call( + call: &MetaToolCall<'_>, +) -> Result, MetaToolError> { + let resolved = caller_resolution(call).await?; + let space_id = caller_space_id(call).await?; + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + Ok(binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect()) +} + +fn disclosure_error(message: String) -> CallToolResult { + CallToolResult::error(vec![Content::text( + json!({ "error": "disclosure_denied", "message": message }).to_string(), + )]) +} + +// --------------------------------------------------------------------------- +// mcpmux_read_resource — read +// --------------------------------------------------------------------------- + +pub struct ReadResourceTool; + +#[async_trait] +impl MetaTool for ReadResourceTool { + fn name(&self) -> &'static str { + "mcpmux_read_resource" + } + + fn description(&self) -> &'static str { + "Read a backend resource URI after grant checks. Use mcpmux_search_resources \ + to discover readable URIs." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["uri"], + "properties": { + "uri": { "type": "string" } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let uri = call + .args + .get("uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `uri`".into()))? + .to_string(); + + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + + let readable = call + .ctx + .feature_service + .get_readable_resources_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let server_id = match FeatureService::resolve_resource_server_from_grants(&readable, &uri) { + Some(server_id) => server_id, + None => { + let index = call + .ctx + .resource_discovery + .build_index(&space_id.to_string(), &readable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let candidates: Vec = index.iter().map(|e| e.uri.clone()).collect(); + let suggestions = levenshtein_suggestions(&uri, &candidates, 3); + let message = if suggestions.is_empty() { + format!("resource '{uri}' is not readable with current grants") + } else { + format!( + "resource '{uri}' is not readable — did you mean {}?", + suggestions.join(", ") + ) + }; + return Ok(disclosure_error(message)); + } + }; + + let binding_servers = binding_servers_for_call(&call).await?; + + if !is_server_active(&server_id, &binding_servers) { + return Ok(disclosure_error(format_server_inactive_error(&server_id))); + } + + let backend = + call.ctx.disclosure_backend.as_ref().ok_or_else(|| { + MetaToolError::Internal("disclosure routing not configured".into()) + })?; + + let contents = backend + .read_resource(space_id, &server_id, &uri) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + Ok(text_result(json!({ "uri": uri, "contents": contents }))) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_fetch_prompt — read +// --------------------------------------------------------------------------- + +pub struct FetchPromptTool; + +#[async_trait] +impl MetaTool for FetchPromptTool { + fn name(&self) -> &'static str { + "mcpmux_fetch_prompt" + } + + fn description(&self) -> &'static str { + "Fetch a backend prompt after grant checks. Use mcpmux_search_prompts \ + to discover fetchable prompt names." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["server_id", "prompt"], + "properties": { + "server_id": { "type": "string" }, + "prompt": { "type": "string" }, + "args": { "type": "object" } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let server_id = call + .args + .get("server_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `server_id`".into()))? + .to_string(); + let prompt_name = call + .args + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `prompt`".into()))? + .to_string(); + let args = call.args.get("args").cloned().unwrap_or_else(|| json!({})); + + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + + let binding_servers = binding_servers_for_call(&call).await?; + + if !is_server_active(&server_id, &binding_servers) { + return Ok(disclosure_error(format_server_inactive_error(&server_id))); + } + + let fetchable = call + .ctx + .feature_service + .get_fetchable_prompts_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let qualified_name = fetchable + .iter() + .find(|f| f.server_id == server_id && f.feature_name == prompt_name) + .map(|f| f.qualified_name()) + .unwrap_or_else(|| format!("{server_id}_{prompt_name}")); + + let is_fetchable = fetchable + .iter() + .any(|f| f.server_id == server_id && f.feature_name == prompt_name && f.is_available); + + if !is_fetchable { + let index = call + .ctx + .prompt_discovery + .build_index(&space_id.to_string(), &fetchable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let candidates: Vec = index + .iter() + .filter(|e| e.server_id == server_id) + .map(|e| e.feature_name.clone()) + .collect(); + let suggestions = levenshtein_suggestions(&prompt_name, &candidates, 5); + let message = if suggestions.is_empty() { + format!( + "prompt '{qualified_name}' is not fetchable with current grants (server_id='{server_id}', prompt='{prompt_name}')" + ) + } else { + format!( + "prompt '{qualified_name}' is not fetchable — did you mean {}?", + suggestions.join(", ") + ) + }; + return Ok(disclosure_error(message)); + } + + let backend = + call.ctx.disclosure_backend.as_ref().ok_or_else(|| { + MetaToolError::Internal("disclosure routing not configured".into()) + })?; + + let arguments = args.as_object().cloned(); + let result = backend + .fetch_prompt(space_id, &server_id, &prompt_name, arguments) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + Ok(text_result(json!({ + "server_id": server_id, + "prompt": prompt_name, + "qualified_name": qualified_name, + "result": result, + }))) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/disclosure_search.rs b/crates/mcpmux-gateway/src/services/meta_tools/disclosure_search.rs new file mode 100644 index 00000000..bd82ce83 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/disclosure_search.rs @@ -0,0 +1,274 @@ +//! Meta tools for searching resources and prompts (progressive disclosure). + +use std::collections::HashSet; + +use async_trait::async_trait; +use rmcp::model::{CallToolResult, Content}; +use serde_json::{json, Value}; + +use super::meta_tool_common::{caller_resolution, caller_space_id, text_result}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use crate::pool::{format_server_inactive_error, format_server_not_in_binding_error}; +use crate::services::{ + PromptDetailLevel, PromptDiscoveryService, ResourceDetailLevel, ResourceDiscoveryService, +}; + +/// Returns whether `server_id` is active via the caller's binding. +fn is_server_active(server_id: &str, binding_servers: &HashSet) -> bool { + binding_servers.contains(server_id) +} + +/// Collect binding server ids for the caller's resolved FeatureSets. +async fn binding_servers_for_call( + call: &MetaToolCall<'_>, +) -> Result, MetaToolError> { + let resolved = caller_resolution(call).await?; + let space_id = caller_space_id(call).await?; + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + Ok(binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect()) +} + +/// Validate optional `server_id` filter against binding state. +async fn validate_server_filter( + call: &MetaToolCall<'_>, + server_id: Option<&str>, + readable_count_for_server: impl FnOnce() -> usize, +) -> Result, MetaToolError> { + let Some(server_id) = server_id else { + return Ok(None); + }; + + let binding_servers = binding_servers_for_call(call).await?; + + if !is_server_active(server_id, &binding_servers) { + return Ok(Some(format_server_inactive_error(server_id))); + } + + if readable_count_for_server() == 0 { + return Ok(Some(format_server_not_in_binding_error(server_id))); + } + + Ok(None) +} + +fn disclosure_error(message: String) -> CallToolResult { + CallToolResult::error(vec![Content::text( + json!({ "error": "disclosure_denied", "message": message }).to_string(), + )]) +} + +// --------------------------------------------------------------------------- +// mcpmux_search_resources — read +// --------------------------------------------------------------------------- + +pub struct SearchResourcesTool; + +#[async_trait] +impl MetaTool for SearchResourcesTool { + fn name(&self) -> &'static str { + "mcpmux_search_resources" + } + + fn description(&self) -> &'static str { + "Search readable backend resources in the caller's resolved Space. \ + Supports query substring match, optional server_id filter, \ + detail_level (name | description | full), and pagination." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "server_id": { "type": "string" }, + "detail_level": { + "type": "string", + "enum": ["name", "description", "full"], + "default": "description" + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, + "cursor": { "type": "string" } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + let server_filter = call.args.get("server_id").and_then(|v| v.as_str()); + + let detail_level = call + .args + .get("detail_level") + .and_then(|v| v.as_str()) + .and_then(ResourceDetailLevel::parse) + .unwrap_or(ResourceDetailLevel::Description); + + let limit = call + .args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) as usize; + + let readable = call + .ctx + .feature_service + .get_readable_resources_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + if let Some(message) = validate_server_filter(&call, server_filter, || { + readable + .iter() + .filter(|f| server_filter.is_none_or(|sid| f.server_id == sid)) + .count() + }) + .await? + { + return Ok(disclosure_error(message)); + } + + let index = call + .ctx + .resource_discovery + .build_index(&space_id.to_string(), &readable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let result = ResourceDiscoveryService::search( + &index, + call.args.get("query").and_then(|v| v.as_str()), + server_filter, + detail_level, + limit, + call.args.get("cursor").and_then(|v| v.as_str()), + ); + + let mut payload = json!({ + "resources": result.resources, + "next_cursor": result.next_cursor, + "total": result.total, + }); + + if result.total == 0 { + payload["hint"] = json!( + "No readable resources matched. Verify FeatureSet grants include resource members, \ + or use mcpmux_bind_current_workspace when the server is inactive." + ); + } + + Ok(text_result(payload)) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_search_prompts — read +// --------------------------------------------------------------------------- + +pub struct SearchPromptsTool; + +#[async_trait] +impl MetaTool for SearchPromptsTool { + fn name(&self) -> &'static str { + "mcpmux_search_prompts" + } + + fn description(&self) -> &'static str { + "Search fetchable backend prompts in the caller's resolved Space. \ + Supports query substring match, optional server_id filter, \ + detail_level (name | description | full), and pagination." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "server_id": { "type": "string" }, + "detail_level": { + "type": "string", + "enum": ["name", "description", "full"], + "default": "description" + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, + "cursor": { "type": "string" } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + let server_filter = call.args.get("server_id").and_then(|v| v.as_str()); + + let detail_level = call + .args + .get("detail_level") + .and_then(|v| v.as_str()) + .and_then(PromptDetailLevel::parse) + .unwrap_or(PromptDetailLevel::Description); + + let limit = call + .args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) as usize; + + let fetchable = call + .ctx + .feature_service + .get_fetchable_prompts_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + if let Some(message) = validate_server_filter(&call, server_filter, || { + fetchable + .iter() + .filter(|f| server_filter.is_none_or(|sid| f.server_id == sid)) + .count() + }) + .await? + { + return Ok(disclosure_error(message)); + } + + let index = call + .ctx + .prompt_discovery + .build_index(&space_id.to_string(), &fetchable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let result = PromptDiscoveryService::search( + &index, + call.args.get("query").and_then(|v| v.as_str()), + server_filter, + detail_level, + limit, + call.args.get("cursor").and_then(|v| v.as_str()), + ); + + let mut payload = json!({ + "prompts": result.prompts, + "next_cursor": result.next_cursor, + "total": result.total, + }); + + if result.total == 0 { + payload["hint"] = json!( + "No fetchable prompts matched. Verify FeatureSet grants include prompt members, \ + or use mcpmux_bind_current_workspace when the server is inactive." + ); + } + + Ok(text_result(payload)) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/feature_set_tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/feature_set_tools.rs new file mode 100644 index 00000000..feebee06 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/feature_set_tools.rs @@ -0,0 +1,252 @@ +//! `mcpmux_list_feature_sets` and `mcpmux_get_tool_schema` — read-only discovery helpers. + +use async_trait::async_trait; +use rmcp::model::CallToolResult; +use serde_json::{json, Value}; +use std::collections::HashSet; + +use super::meta_tool_common::{caller_resolution, caller_space_id, text_result}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; + +// --------------------------------------------------------------------------- +// mcpmux_list_feature_sets — read +// --------------------------------------------------------------------------- + +pub struct ListFeatureSetsTool; + +#[async_trait] +impl MetaTool for ListFeatureSetsTool { + fn name(&self) -> &'static str { + "mcpmux_list_feature_sets" + } + + fn description(&self) -> &'static str { + "List every FeatureSet defined in the caller's resolved Space — \ + built-ins and custom. Each entry carries `id`, `name`, `description`, \ + `type`, `is_builtin`, and `status` (`active` when bound to this \ + workspace, `inactive` when available to bind). To activate capability, \ + call mcpmux_bind_current_workspace with an inactive entry's `id`." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + let space = call + .ctx + .space_repo + .get(&space_id) + .await? + .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; + let bound_ids: HashSet = resolved.feature_set_ids.iter().cloned().collect(); + let sets = call + .ctx + .feature_set_repo + .list_by_space(&space_id.to_string()) + .await?; + let sets: Vec<_> = sets + .iter() + .filter(|fs| !fs.is_deleted) + .map(|fs| { + let status = if bound_ids.contains(&fs.id) { + "active" + } else { + "inactive" + }; + json!({ + "id": fs.id, + "name": fs.name, + "description": fs.description, + "type": fs.feature_set_type, + "is_builtin": fs.is_builtin, + "status": status, + }) + }) + .collect(); + Ok(text_result( + json!({ "space_id": space.id, "feature_sets": sets }), + )) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_get_tool_schema — read +// --------------------------------------------------------------------------- + +/// Parsed `tools` argument for schema lookup, retaining invalid entries for `missing`. +struct ToolSchemaNameRequest { + valid_names: Vec, + invalid_entries: Vec, +} + +/// Parse the `tools` argument from `mcpmux_get_tool_schema` call args. +/// +/// Accepts a qualified name string, a string array, or a JSON-encoded array +/// string (common when agents double-serialize through MCP clients). +fn parse_tool_schema_names(value: Option<&Value>) -> Result { + let Some(value) = value else { + return Err(MetaToolError::InvalidArgument( + "missing or invalid `tools` — expected string or string array".into(), + )); + }; + + match value { + Value::String(s) => { + if let Ok(Value::Array(arr)) = serde_json::from_str(s) { + return names_from_json_array(&arr); + } + Ok(ToolSchemaNameRequest { + valid_names: vec![s.clone()], + invalid_entries: Vec::new(), + }) + } + Value::Array(arr) => names_from_json_array(arr), + _ => Err(MetaToolError::InvalidArgument( + "missing or invalid `tools` — expected string or string array".into(), + )), + } +} + +/// Split a JSON string array into valid qualified names and invalid entries (e.g. empty strings). +fn names_from_json_array(arr: &[Value]) -> Result { + let mut valid_names = Vec::new(); + let mut invalid_entries = Vec::new(); + + for value in arr { + match value.as_str() { + Some(name) if name.trim().is_empty() => invalid_entries.push(name.to_string()), + Some(name) => valid_names.push(name.trim().to_string()), + None => invalid_entries.push(value.to_string()), + } + } + + if valid_names.is_empty() && invalid_entries.is_empty() { + return Err(MetaToolError::InvalidArgument( + "`tools` must contain at least one qualified name".into(), + )); + } + + Ok(ToolSchemaNameRequest { + valid_names, + invalid_entries, + }) +} + +pub struct GetToolSchemaTool; + +#[async_trait] +impl MetaTool for GetToolSchemaTool { + fn name(&self) -> &'static str { + "mcpmux_get_tool_schema" + } + + fn description(&self) -> &'static str { + "Load input schemas for one or more qualified tool names before \ + invoking via mcpmux_invoke_tool. Pass tools as a single qualified \ + name string or a string array (e.g. [\"github_list_issues\"]). \ + Set compact: true to omit descriptions. Tools must be invokable \ + with current grants — use mcpmux_search_tools to discover names." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["tools"], + "properties": { + "tools": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "tool_name": { + "type": "string", + "description": "Alias for tools (single qualified name)" + }, + "tool": { + "type": "string", + "description": "Alias for tools (single qualified name)" + }, + "compact": { "type": "boolean", "default": false } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + + let tools_value = call + .args + .get("tools") + .or_else(|| call.args.get("tool_name")) + .or_else(|| call.args.get("tool")); + let schema_request = parse_tool_schema_names(tools_value)?; + let tool_names = schema_request.valid_names; + + let compact = call + .args + .get("compact") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let index = call + .ctx + .tool_discovery + .build_index(&space_id.to_string(), &invokable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let schemas = crate::services::tool_discovery::ToolDiscoveryService::get_schemas( + &index, + &tool_names, + compact, + ); + + let found_names: HashSet = schemas + .iter() + .flat_map(|s| { + [ + s.get("qualified_name") + .and_then(|v| v.as_str()) + .map(str::to_string), + s.get("feature_name") + .and_then(|v| v.as_str()) + .map(str::to_string), + ] + .into_iter() + .flatten() + }) + .collect(); + let mut missing: Vec = tool_names + .iter() + .filter(|name| !found_names.contains(*name)) + .cloned() + .collect(); + missing.extend(schema_request.invalid_entries); + + if missing.is_empty() { + return Ok(text_result(json!({ "schemas": schemas }))); + } + + let missing_list: Vec<&str> = missing.iter().map(String::as_str).collect(); + Ok(text_result(json!({ + "schemas": schemas, + "missing": missing_list, + "message": format!( + "{} tool(s) not invokable or unknown with current grants → use mcpmux_search_tools to discover allowed names", + missing.len() + ), + }))) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_alias.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_alias.rs new file mode 100644 index 00000000..e905d0be --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_alias.rs @@ -0,0 +1,62 @@ +//! Invoke argument alias resolution for `mcpmux_invoke_tool`. + +use serde_json::{json, Value}; + +/// Strip repeated `{server_id}_` prefixes when agents pass a qualified name from search. +pub fn normalize_invoke_tool_name(server_id: &str, tool: &str) -> String { + let prefix = format!("{server_id}_"); + let mut bare = tool; + while let Some(stripped) = bare.strip_prefix(&prefix) { + bare = stripped; + } + bare.to_string() +} + +/// First non-empty string value for any of `keys` on a JSON object (agent alias resolution). +fn first_nonempty_str(args: &Value, keys: &[&str]) -> Option { + let obj = args.as_object()?; + for key in keys { + let Some(value) = obj.get(*key) else { + continue; + }; + let Some(text) = value.as_str() else { + continue; + }; + if !text.is_empty() { + return Some(text.to_string()); + } + } + None +} + +/// Resolve `server_id` from invoke call args (`server_id`, alias `serverId`, alias `server`). +pub fn resolve_invoke_server_id(args: &Value) -> Option { + first_nonempty_str(args, &["server_id", "serverId", "server"]) +} + +/// Resolve `tool` from invoke call args (`tool`, alias `tool_name`). +pub fn resolve_invoke_tool(args: &Value) -> Option { + first_nonempty_str(args, &["tool", "tool_name"]) +} + +/// Whether an invokable feature matches the caller's `tool` (bare or qualified). +pub(crate) fn feature_matches_tool_name( + feature_name: &str, + qualified_name: &str, + tool_input: &str, + bare: &str, +) -> bool { + feature_name == bare || qualified_name == tool_input +} + +/// Resolve backend tool arguments from `mcpmux_invoke_tool` call args. +/// +/// Prefers `args`, then `params`, then `arguments`, then `tool_arguments` (common agent/UI aliases). +pub fn resolve_invoke_tool_args(args: &Value) -> Value { + args.get("args") + .or_else(|| args.get("params")) + .or_else(|| args.get("arguments")) + .or_else(|| args.get("tool_arguments")) + .cloned() + .unwrap_or_else(|| json!({})) +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs new file mode 100644 index 00000000..447dda81 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs @@ -0,0 +1,41 @@ +//! Pluggable backend for `mcpmux_invoke_tool` routing. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; +use uuid::Uuid; + +use crate::pool::{RoutingService, ToolCallResult}; + +/// Dispatches permission-checked tool calls to a backend MCP server. +#[async_trait] +pub trait InvokeToolBackend: Send + Sync { + /// Invoke a qualified backend tool and return raw MCP content. + async fn call_tool( + &self, + space_id: Uuid, + feature_set_ids: &[String], + qualified_name: &str, + arguments: Value, + ) -> Result; +} + +#[async_trait] +impl InvokeToolBackend for RoutingService { + async fn call_tool( + &self, + space_id: Uuid, + feature_set_ids: &[String], + qualified_name: &str, + arguments: Value, + ) -> Result { + RoutingService::call_tool(self, space_id, feature_set_ids, qualified_name, arguments).await + } +} + +/// Wrap a [`RoutingService`] as an [`InvokeToolBackend`] trait object. +pub fn routing_as_invoke_backend(routing: Arc) -> Arc { + routing +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_payload_parse.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_payload_parse.rs new file mode 100644 index 00000000..3ea0d4aa --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_payload_parse.rs @@ -0,0 +1,175 @@ +//! Text and structured-content parsing helpers for invoke result filtering. + +use serde_json::{Map, Value}; + +/// Object keys that commonly hold large list payloads from backend tools. +pub(crate) const HEAVY_ARRAY_KEYS: &[&str] = &[ + "items", "data", "results", "rows", "records", "issues", "entries", "values", "list", + "insights", +]; + +/// Merge structuredContent and JSON text blocks into one payload for filtering. +pub(crate) fn coalesce_structured_payload( + content: &[Value], + structured: Option, +) -> Option { + if let Some(value) = structured.filter(|v| !v.is_null()) { + return Some(value); + } + + let parsed_blocks = collect_parsed_json_blocks(content); + if parsed_blocks.len() >= 2 { + let rows: Vec = parsed_blocks + .into_iter() + .flat_map(normalize_json_rows) + .collect(); + if rows.len() >= 2 { + return Some(Value::Array(rows)); + } + if rows.len() == 1 { + return Some(rows[0].clone()); + } + return None; + } + + parsed_blocks.into_iter().next() +} + +/// Extract human-readable payload text from an MCP content block value. +pub(crate) fn content_block_text(block: &Value) -> Option<&str> { + if let Some(text) = block.get("text").and_then(|v| v.as_str()) { + return Some(text); + } + + block + .get("resource") + .and_then(|resource| resource.get("text")) + .and_then(|v| v.as_str()) +} + +/// Parse structured payloads from plain text (JSON first, then YAML). +pub(crate) fn parse_structured_payload_from_text(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(parsed) = serde_json::from_str::(trimmed) { + return normalize_parsed_payload(parsed); + } + + if let Some(fenced) = extract_markdown_json_fence(trimmed) { + if let Ok(parsed) = serde_json::from_str::(&fenced) { + return normalize_parsed_payload(parsed); + } + } + + if let Ok(parsed) = serde_yaml::from_str::(trimmed) { + if parsed.is_object() || parsed.is_array() { + return normalize_parsed_payload(parsed); + } + } + + if let Some(candidate) = extract_json_object_substring(trimmed) { + if let Ok(parsed) = serde_json::from_str::(&candidate) { + return normalize_parsed_payload(parsed); + } + } + + None +} + +/// If a backend double-encodes JSON as a string, parse one more level. +fn normalize_parsed_payload(value: Value) -> Option { + if let Value::String(nested) = value { + let trimmed = nested.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return parse_structured_payload_from_text(&nested); + } + return Some(Value::String(nested)); + } + + if let Value::Object(map) = value { + return Some(Value::Object(normalize_bracketed_array_keys(map))); + } + + Some(value) +} + +/// Normalize YAML keys like `results[16]` to `results` for list shaping. +fn normalize_bracketed_array_keys(mut map: Map) -> Map { + let keys: Vec = map.keys().cloned().collect(); + for key in keys { + let Some(normalized) = bracketed_array_key_base(&key) else { + continue; + }; + if normalized == key { + continue; + } + if let Some(value) = map.remove(&key) { + map.insert(normalized, value); + } + } + map +} + +/// Return the heavy-array base name when `key` looks like `results[16]`. +pub(crate) fn bracketed_array_key_base(key: &str) -> Option { + for base in HEAVY_ARRAY_KEYS { + let prefix = format!("{base}["); + if !key.starts_with(&prefix) || !key.ends_with(']') { + continue; + } + let index = &key[prefix.len()..key.len() - 1]; + if index.chars().all(|c| c.is_ascii_digit()) { + return Some(base.to_string()); + } + } + None +} + +/// Extract JSON from ```json fenced blocks when backends wrap payloads in markdown. +fn extract_markdown_json_fence(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + let start = lower.find("```json")?; + let after_start = &text[start + "```json".len()..]; + let end = after_start.find("```")?; + Some(after_start[..end].trim().to_string()) +} + +/// Best-effort extraction of the first top-level JSON object/array substring. +fn extract_json_object_substring(text: &str) -> Option { + let start = text.find(['{', '['])?; + let slice = &text[start..]; + let end = slice.rfind(if slice.starts_with('{') { '}' } else { ']' })?; + Some(slice[..=end].to_string()) +} + +/// Parse one JSON payload per content block (text or resource). +pub(crate) fn collect_parsed_json_blocks(blocks: &[Value]) -> Vec { + blocks + .iter() + .filter_map(|block| content_block_text(block).and_then(parse_structured_payload_from_text)) + .collect() +} + +/// Flatten a parsed JSON value into individual row objects for list shaping. +pub(crate) fn normalize_json_rows(value: Value) -> Vec { + match value { + Value::Array(items) => items, + Value::Object(map) => { + for key in HEAVY_ARRAY_KEYS { + if let Some(Value::Array(items)) = map.get(*key) { + return items.clone(); + } + } + for value in map.values() { + if let Value::Array(items) = value { + return items.clone(); + } + } + vec![Value::Object(map)] + } + other => vec![other], + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter.rs new file mode 100644 index 00000000..e5397f4a --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter.rs @@ -0,0 +1,85 @@ +//! Post-processing filters for `mcpmux_invoke_tool` backend payloads. + +use serde_json::{json, Value}; + +pub use super::invoke_result_shaping::shape_json_value; + +use super::invoke_payload_parse::coalesce_structured_payload; +use super::invoke_result_shaping::shape_content_blocks; + +/// Optional post-processing controls for invoke results. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InvokeResultFilter { + pub max_rows: Option, + pub max_bytes: Option, + pub fields: Option>, + pub format: Option, +} + +/// Parse the optional `filter` object from `mcpmux_invoke_tool` arguments. +pub fn parse_invoke_filter(value: Option<&Value>) -> Option { + let filter = value?; + if !filter.is_object() { + return None; + } + + Some(InvokeResultFilter { + max_rows: filter + .get("max_rows") + .and_then(|v| v.as_u64()) + .map(|n| n as usize), + max_bytes: filter + .get("max_bytes") + .and_then(|v| v.as_u64()) + .map(|n| n as usize), + fields: filter.get("fields").and_then(|v| { + v.as_array().map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + }), + format: filter + .get("format") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) +} + +impl InvokeResultFilter { + pub(crate) fn is_summary(&self) -> bool { + self.format.as_deref() == Some("summary") + } + + /// Whether any shaping limit is set (row/byte/field caps). + pub fn has_effect(&self) -> bool { + self.max_rows.is_some() || self.max_bytes.is_some() || self.fields.is_some() + } +} + +/// Post-process routed tool output before returning it to the MCP client. +pub fn apply_invoke_result_filter( + content: Vec, + structured_content: Option, + filter: &InvokeResultFilter, +) -> (Vec, Option) { + if !filter.has_effect() { + return (content, structured_content); + } + + let Some(payload) = coalesce_structured_payload(&content, structured_content) else { + let shaped_content = shape_content_blocks(content, filter); + return (shaped_content, None); + }; + + let shaped = shape_json_value(payload, filter); + let shaped_content = vec![json!({ + "type": "text", + "text": shaped.to_string(), + })]; + (shaped_content, Some(shaped)) +} + +#[cfg(test)] +#[path = "invoke_result_filter_tests.rs"] +mod tests; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter_tests.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter_tests.rs new file mode 100644 index 00000000..e6a2ed4e --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_filter_tests.rs @@ -0,0 +1,440 @@ +use super::super::invoke_payload_parse::{ + bracketed_array_key_base, parse_structured_payload_from_text, +}; +use super::super::invoke_result_shaping::{byte_truncation_envelope, shape_content_block}; +use super::*; +use serde_json::{json, Value}; + +fn issue_rows(count: usize) -> Vec { + (0..count) + .map(|i| { + json!({ + "id": i, + "title": format!("issue-{i}"), + "body": format!("body-{i}") + }) + }) + .collect() +} + +#[test] +fn no_filter_passes_through_large_array() { + let items: Vec = (0..100) + .map(|i| json!({ "id": i, "name": format!("n{i}") })) + .collect(); + let shaped = shape_json_value(Value::Array(items.clone()), &InvokeResultFilter::default()); + assert_eq!(shaped, Value::Array(items)); +} + +#[test] +fn explicit_max_rows_truncates_top_level_array() { + let items: Vec = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(3), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3))); + assert_eq!(shaped.get("total"), Some(&json!(20))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 3); +} + +#[test] +fn explicit_max_rows_truncates_nested_issues_key() { + let issues = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(3), + ..Default::default() + }; + let shaped = shape_json_value(json!({ "issues": issues }), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3))); + assert_eq!(shaped.get("total"), Some(&json!(20))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("issues").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 3); +} + +#[test] +fn json_in_text_block_truncates_with_metadata() { + let rows: Vec = (0..80).map(|i| json!({ "n": i })).collect(); + let content = vec![json!({ + "type": "text", + "text": json!({ "results": rows }).to_string(), + })]; + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 10 }))).unwrap(); + + let (shaped_content, _) = apply_invoke_result_filter(content, None, &filter); + let text = shaped_content[0] + .get("text") + .and_then(|t| t.as_str()) + .unwrap(); + let parsed: Value = serde_json::from_str(text).unwrap(); + + assert_eq!(parsed.get("returned"), Some(&json!(10))); + assert_eq!(parsed.get("total"), Some(&json!(80))); + assert_eq!(parsed.get("truncated"), Some(&json!(true))); +} + +#[test] +fn structured_content_and_text_both_shaped() { + let items = issue_rows(20); + let structured = json!({ "items": items }); + let content = vec![json!({ + "type": "text", + "text": structured.to_string(), + })]; + let filter = InvokeResultFilter { + max_rows: Some(5), + fields: Some(vec!["id".into(), "title".into()]), + ..Default::default() + }; + + let (shaped_content, shaped_structured) = + apply_invoke_result_filter(content, Some(structured), &filter); + + let parsed_text: Value = serde_json::from_str( + shaped_content[0] + .get("text") + .and_then(|t| t.as_str()) + .unwrap(), + ) + .unwrap(); + assert_eq!(parsed_text.get("returned"), Some(&json!(5))); + assert_eq!(parsed_text.get("total"), Some(&json!(20))); + + let shaped = shaped_structured.unwrap(); + let structured_sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(structured_sample.len(), 5); + assert_eq!(structured_sample[0], json!({ "id": 0, "title": "issue-0" })); +} + +#[test] +fn fields_filter_keeps_only_requested_columns() { + let items = vec![ + json!({ "id": 1, "name": "a", "secret": "x" }), + json!({ "id": 2, "name": "b", "secret": "y" }), + ]; + let filter = InvokeResultFilter { + fields: Some(vec!["id".into(), "name".into()]), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + let kept = shaped.as_array().unwrap(); + assert_eq!(kept[0], json!({ "id": 1, "name": "a" })); + assert_eq!(kept[1], json!({ "id": 2, "name": "b" })); +} + +#[test] +fn max_rows_and_fields_together() { + let items: Vec = (0..30) + .map(|i| json!({ "id": i, "label": format!("row-{i}") })) + .collect(); + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 5, "fields": ["id"] }))).unwrap(); + let shaped = shape_json_value(Value::Array(items), &filter); + + assert_eq!(shaped.get("returned"), Some(&json!(5))); + assert_eq!(shaped.get("total"), Some(&json!(30))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 5); + assert_eq!(sample[0], json!({ "id": 0 })); +} + +#[test] +fn summary_format_no_op_when_max_rows_at_most_five() { + let items = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(3), + format: Some("summary".into()), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3))); +} + +#[test] +fn summary_format_caps_sample_at_five() { + let items = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(10), + format: Some("summary".into()), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(5))); + assert_eq!(shaped.get("total"), Some(&json!(20))); +} + +#[test] +fn full_format_returns_up_to_max_rows() { + let items = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(10), + format: Some("full".into()), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(10))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 10); +} + +#[test] +fn parse_invoke_filter_ignores_invalid_types() { + let filter = parse_invoke_filter(Some(&json!({ + "max_rows": "not-a-number", + "max_bytes": true, + "fields": "id", + "format": 123 + }))) + .unwrap(); + assert_eq!(filter.max_rows, None); + assert_eq!(filter.max_bytes, None); + assert_eq!(filter.fields, None); + assert_eq!(filter.format, None); +} + +#[test] +fn parse_invoke_filter_accepts_partial_objects() { + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 3 }))).unwrap(); + assert_eq!(filter.max_rows, Some(3)); + assert_eq!(filter.max_bytes, None); +} + +#[test] +fn max_bytes_only_truncates_top_level_json_array() { + let items: Vec = (0..50) + .map(|i| json!({ "id": i, "label": format!("row-{i}-padding") })) + .collect(); + let filter = InvokeResultFilter { + max_bytes: Some(512), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + assert!(shaped.get("total").and_then(|v| v.as_u64()).unwrap_or(0) > 512); +} + +#[test] +fn posthog_paginated_results_truncates_from_content_json() { + let results: Vec = (0..16) + .map(|i| { + json!({ + "name": format!("Insight {i}"), + "short_id": format!("ins-{i}"), + "description": "noise" + }) + }) + .collect(); + let payload = json!({ + "count": 16, + "next": null, + "previous": null, + "results": results, + }); + let content = vec![json!({ + "type": "text", + "text": payload.to_string(), + })]; + let filter = parse_invoke_filter(Some(&json!({ + "max_rows": 3, + "fields": ["name", "short_id"] + }))) + .unwrap(); + + let (shaped_content, shaped_structured) = apply_invoke_result_filter(content, None, &filter); + + let structured = shaped_structured.expect("structured shaped from content JSON"); + assert_eq!(structured.get("returned"), Some(&json!(3))); + assert_eq!(structured.get("total"), Some(&json!(16))); + assert_eq!(structured.get("truncated"), Some(&json!(true))); + let sample = structured + .get("results") + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!(sample.len(), 3); + assert_eq!( + sample[0], + json!({ "name": "Insight 0", "short_id": "ins-0" }) + ); + + let text = shaped_content[0] + .get("text") + .and_then(|t| t.as_str()) + .unwrap(); + let parsed_text: Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed_text.get("returned"), Some(&json!(3))); +} + +#[test] +fn yaml_payload_parses_posthog_insights_list_shape() { + let mut yaml = String::from("count: 16\nnext: null\nprevious: null\nresults[16]:\n"); + for i in 0..16 { + yaml.push_str(&format!( + " - id: {i}\n short_id: ins-{i}\n name: Insight {i}\n description: noise\n" + )); + } + let parsed = parse_structured_payload_from_text(&yaml).expect("yaml parses"); + let results = parsed + .get("results") + .and_then(|v| v.as_array()) + .expect("results array"); + assert_eq!(results.len(), 16); + + let filter = parse_invoke_filter(Some(&json!({ + "max_rows": 3, + "fields": ["name", "short_id"] + }))) + .unwrap(); + let shaped = shape_json_value(parsed, &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3)), "shaped: {shaped}"); +} + +#[test] +fn posthog_paginated_results_truncates_from_content_yaml() { + let mut yaml = String::from("count: 16\nnext: null\nprevious: null\nresults[16]:\n"); + for i in 0..16 { + yaml.push_str(&format!( + " - id: {i}\n short_id: ins-{i}\n name: Insight {i}\n description: noise\n" + )); + } + let content = vec![json!({ + "type": "text", + "text": yaml, + })]; + let filter = parse_invoke_filter(Some(&json!({ + "max_rows": 3, + "fields": ["name", "short_id"] + }))) + .unwrap(); + + let (shaped_content, shaped_structured) = apply_invoke_result_filter(content, None, &filter); + + let structured = shaped_structured.expect("structured shaped from content YAML"); + assert_eq!(structured.get("returned"), Some(&json!(3))); + assert_eq!(structured.get("total"), Some(&json!(16))); + assert_eq!(structured.get("truncated"), Some(&json!(true))); + let sample = structured + .get("results") + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!(sample.len(), 3); + assert_eq!( + sample[0], + json!({ "name": "Insight 0", "short_id": "ins-0" }) + ); + + let text = shaped_content[0] + .get("text") + .and_then(|t| t.as_str()) + .unwrap(); + let parsed_text: Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed_text.get("returned"), Some(&json!(3))); +} + +#[test] +fn bracketed_array_key_base_normalizes_posthog_results_key() { + assert_eq!( + bracketed_array_key_base("results[16]"), + Some("results".to_string()) + ); + assert_eq!(bracketed_array_key_base("results"), None); +} + +#[test] +fn posthog_paginated_results_truncates_from_resource_block_json() { + let results: Vec = (0..16) + .map(|i| { + json!({ + "name": format!("Insight {i}"), + "short_id": format!("ins-{i}"), + "description": "noise" + }) + }) + .collect(); + let payload = json!({ + "count": 16, + "next": null, + "previous": null, + "results": results, + }); + let content = vec![json!({ + "type": "resource", + "resource": { + "uri": "posthog://insights", + "mimeType": "application/json", + "text": payload.to_string(), + } + })]; + let filter = parse_invoke_filter(Some(&json!({ + "max_rows": 3, + "fields": ["name", "short_id"] + }))) + .unwrap(); + + let (_, shaped_structured) = apply_invoke_result_filter(content, None, &filter); + + let structured = shaped_structured.expect("structured shaped from resource JSON"); + assert_eq!(structured.get("returned"), Some(&json!(3))); + assert_eq!(structured.get("total"), Some(&json!(16))); + assert_eq!(structured.get("truncated"), Some(&json!(true))); +} + +#[test] +fn fields_only_projects_columns_on_nested_results() { + let results = vec![ + json!({ "name": "a", "short_id": "1", "description": "x" }), + json!({ "name": "b", "short_id": "2", "description": "y" }), + ]; + let filter = InvokeResultFilter { + fields: Some(vec!["name".into(), "short_id".into()]), + ..Default::default() + }; + let shaped = shape_json_value(json!({ "count": 2, "results": results }), &filter); + let sample = shaped.get("results").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample[0], json!({ "name": "a", "short_id": "1" })); +} + +#[test] +fn plain_text_byte_trunc_includes_metadata() { + let text = "x".repeat(100); + let filter = InvokeResultFilter { + max_bytes: Some(50), + ..Default::default() + }; + let block = json!({ "type": "text", "text": text }); + let shaped = shape_content_block(block, &filter); + let parsed: Value = + serde_json::from_str(shaped.get("text").unwrap().as_str().unwrap()).unwrap(); + assert_eq!(parsed.get("truncated"), Some(&json!(true))); + assert_eq!(parsed.get("total"), Some(&json!(100))); +} + +#[test] +fn byte_trunc_mid_multibyte_char_does_not_panic() { + // Each rocket emoji is 4 bytes. A max_bytes of 5 would land in the middle of + // the second emoji — the char-boundary floor must step back to byte 4. + let text = "🚀🚀🚀🚀".to_string(); // 16 bytes total + let envelope = byte_truncation_envelope(&text, 5); + assert_eq!(envelope.get("truncated"), Some(&json!(true))); + assert_eq!(envelope.get("total"), Some(&json!(16))); + // returned must be ≤ max_bytes and land on a char boundary (4, not 5) + let returned = envelope.get("returned").and_then(|v| v.as_u64()).unwrap(); + assert!(returned <= 5, "returned {returned} exceeds max_bytes 5"); + // The text field must be valid UTF-8 (would panic on deser otherwise) + let text_val = envelope.get("text").and_then(|v| v.as_str()).unwrap(); + assert!(text_val.ends_with("...[truncated]")); +} + +#[test] +fn byte_trunc_exact_char_boundary_does_not_regress() { + // "café" = 5 bytes (c-a-f-é where é is 2 bytes). max_bytes=4 lands exactly at + // a boundary — no backward walk needed, output is "caf". + let text = "café"; + let envelope = byte_truncation_envelope(text, 4); + assert_eq!(envelope.get("truncated"), Some(&json!(true))); + let text_val = envelope.get("text").and_then(|v| v.as_str()).unwrap(); + assert!(text_val.starts_with("caf"), "got: {text_val}"); +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_shaping.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_shaping.rs new file mode 100644 index 00000000..e751d220 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_result_shaping.rs @@ -0,0 +1,229 @@ +//! JSON and MCP content-block shaping helpers for invoke result filtering. + +use serde_json::{json, Map, Value}; + +use super::invoke_payload_parse::{ + collect_parsed_json_blocks, content_block_text, normalize_json_rows, + parse_structured_payload_from_text, HEAVY_ARRAY_KEYS, +}; +use super::invoke_result_filter::InvokeResultFilter; + +/// Shape all MCP content blocks, aggregating multi-block list payloads when needed. +pub(crate) fn shape_content_blocks(blocks: Vec, filter: &InvokeResultFilter) -> Vec { + if blocks.is_empty() { + return blocks; + } + + let parsed_blocks = collect_parsed_json_blocks(&blocks); + if parsed_blocks.len() >= 2 && filter.has_effect() { + let rows: Vec = parsed_blocks + .into_iter() + .flat_map(normalize_json_rows) + .collect(); + if rows.len() >= 2 { + let shaped = shape_json_value(Value::Array(rows), filter); + return vec![json!({ + "type": "text", + "text": shaped.to_string(), + })]; + } + } + + blocks + .into_iter() + .map(|block| shape_content_block(block, filter)) + .collect() +} + +/// Shape one MCP content block (typically `{ "type": "text", "text": "..." }`). +pub(crate) fn shape_content_block(block: Value, filter: &InvokeResultFilter) -> Value { + let Some(text) = content_block_text(&block) else { + return block; + }; + + if let Some(parsed) = parse_structured_payload_from_text(text) { + let shaped = shape_json_value(parsed, filter); + return json!({ + "type": "text", + "text": shaped.to_string(), + }); + } + + let Some(max_bytes) = filter.max_bytes else { + return block; + }; + if text.len() <= max_bytes { + return block; + } + + let envelope = byte_truncation_envelope(text, max_bytes); + json!({ + "type": "text", + "text": envelope.to_string(), + }) +} + +/// Shape a JSON value, applying truncation when explicit filter limits are set. +pub fn shape_json_value(value: Value, filter: &InvokeResultFilter) -> Value { + match value { + Value::Array(items) => shape_array(items, filter, "items"), + Value::Object(map) => shape_object(map, filter), + other => enforce_byte_limit(other, filter), + } +} + +fn shape_object(map: Map, filter: &InvokeResultFilter) -> Value { + for key in HEAVY_ARRAY_KEYS { + if let Some(Value::Array(items)) = map.get(*key).cloned() { + if should_truncate(items.len(), filter) { + return shape_object_with_truncated_array(map, key, items, filter); + } + if filter.fields.is_some() { + let mut map = map; + map.insert( + key.to_string(), + Value::Array(apply_fields_filter(items, filter)), + ); + return enforce_byte_limit(Value::Object(map), filter); + } + } + } + + for (key, value) in &map { + if let Value::Array(items) = value { + if should_truncate(items.len(), filter) { + return shape_object_with_truncated_array(map.clone(), key, items.clone(), filter); + } + if filter.fields.is_some() { + let mut map = map.clone(); + map.insert( + key.clone(), + Value::Array(apply_fields_filter(items.clone(), filter)), + ); + return enforce_byte_limit(Value::Object(map), filter); + } + } + } + + enforce_byte_limit(Value::Object(map), filter) +} + +fn shape_object_with_truncated_array( + mut map: Map, + array_key: &str, + items: Vec, + filter: &InvokeResultFilter, +) -> Value { + let shaped_array = shape_array(items, filter, array_key); + if let Value::Object(truncation) = &shaped_array { + if truncation.get("truncated") == Some(&Value::Bool(true)) { + for (meta_key, meta_value) in truncation { + if meta_key != array_key { + map.insert(meta_key.clone(), meta_value.clone()); + } + } + if let Some(data) = truncation.get(array_key) { + map.insert(array_key.to_string(), data.clone()); + } + return enforce_byte_limit(Value::Object(map), filter); + } + } + + map.insert(array_key.to_string(), shaped_array); + enforce_byte_limit(Value::Object(map), filter) +} + +fn shape_array(items: Vec, filter: &InvokeResultFilter, data_key: &str) -> Value { + let total = items.len(); + let filtered_items = apply_fields_filter(items, filter); + + let Some(max_rows) = filter.max_rows else { + return enforce_byte_limit(Value::Array(filtered_items), filter); + }; + + if total <= max_rows { + return enforce_byte_limit(Value::Array(filtered_items), filter); + } + + let sample_size = if filter.is_summary() { + max_rows.min(5) + } else { + max_rows + }; + let sample: Vec = filtered_items.into_iter().take(sample_size).collect(); + let returned = sample.len(); + + json!({ + "returned": returned, + "total": total, + "truncated": true, + data_key: sample, + }) +} + +fn apply_fields_filter(items: Vec, filter: &InvokeResultFilter) -> Vec { + let Some(fields) = &filter.fields else { + return items; + }; + + items + .into_iter() + .map(|item| pick_fields(item, fields)) + .collect() +} + +fn pick_fields(value: Value, fields: &[String]) -> Value { + let Value::Object(map) = value else { + return value; + }; + + let mut picked = Map::new(); + for field in fields { + if let Some(v) = map.get(field) { + picked.insert(field.clone(), v.clone()); + } + } + Value::Object(picked) +} + +fn should_truncate(length: usize, filter: &InvokeResultFilter) -> bool { + match filter.max_rows { + Some(max_rows) => length > max_rows, + None => false, + } +} + +/// Cap serialized JSON/text size. Uses `Value::to_string()` byte length as a proxy — +/// not identical to on-wire MCP payload size, but stable enough for agent-facing truncation. +fn enforce_byte_limit(value: Value, filter: &InvokeResultFilter) -> Value { + let Some(max_bytes) = filter.max_bytes else { + return value; + }; + + let serialized = value.to_string(); + if serialized.len() <= max_bytes { + return value; + } + + byte_truncation_envelope(&serialized, max_bytes) +} + +/// Build a `{ returned, total, truncated, text }` envelope for byte-capped plain text or JSON. +/// +/// Floors `max_bytes` to the nearest valid UTF-8 char boundary before slicing so that +/// multi-byte characters (emoji, CJK, accented text) never cause a panic. +pub(crate) fn byte_truncation_envelope(text: &str, max_bytes: usize) -> Value { + let total_bytes = text.len(); + let mut end = max_bytes.min(total_bytes); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + let mut truncated = text[..end].to_string(); + truncated.push_str("...[truncated]"); + json!({ + "returned": end, + "total": total_bytes, + "truncated": true, + "text": truncated, + }) +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_tool.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_tool.rs new file mode 100644 index 00000000..8269fed1 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_tool.rs @@ -0,0 +1,383 @@ +//! `mcpmux_invoke_tool` — permission-checked gateway into backend MCP tools. + +pub(crate) use super::invoke_alias::feature_matches_tool_name; +pub use super::invoke_alias::{ + normalize_invoke_tool_name, resolve_invoke_server_id, resolve_invoke_tool, + resolve_invoke_tool_args, +}; + +use async_trait::async_trait; +use rmcp::model::{CallToolResult, Content}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use tracing::debug; + +use super::diagnose_server::parse_missing_required_inputs; +use super::invoke_result_filter::{apply_invoke_result_filter, parse_invoke_filter}; +use super::meta_tool_common::{ + caller_resolution, caller_space_id, classify_invoke_denial, + format_invoke_not_ready_action_with_name, +}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use crate::pool::{format_invoke_permission_denied, ConnectionStatus}; +use crate::services::levenshtein_suggestions; +use mcpmux_core::{DefaultParamsStrategy, FeatureType}; + +/// Meta tool that forwards invocations to [`RoutingService::call_tool`]. +pub struct InvokeToolTool; + +#[async_trait] +impl MetaTool for InvokeToolTool { + fn name(&self) -> &'static str { + "mcpmux_invoke_tool" + } + + fn description(&self) -> &'static str { + "Invoke a backend MCP tool by server_id and tool (bare or qualified from \ + mcpmux_search_tools). Skip search when you already know the tool — pass \ + bare_name or qualified_name directly. Set preflight: true to check readiness \ + without calling the backend (returns { ready: true } or a structured not_ready \ + error). Requires the server to be ready and the tool in the current permission \ + set. Search results include required_params types — mcpmux_get_tool_schema is \ + optional for complex tools. Pass an optional filter to bound large payloads; omit \ + filter to return the backend response as-is." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["server_id", "tool"], + "properties": { + "server_id": { + "type": "string", + "description": "Registry server id (e.g. github). Aliases: server, serverId (server_id wins if multiple are set)." + }, + "server": { + "type": "string", + "description": "Alias for server_id" + }, + "serverId": { + "type": "string", + "description": "Alias for server_id" + }, + "tool": { + "type": "string", + "description": "Tool name on that server — bare (e.g. list_issues) or qualified from mcpmux_search_tools (e.g. github_list_issues); bare_name in search results is the invoke value when unsure. Known tools can be invoked directly without a prior search. Alias: tool_name (tool wins if both are set)." + }, + "tool_name": { + "type": "string", + "description": "Alias for tool" + }, + "preflight": { + "type": "boolean", + "default": false, + "description": "When true, verify server and tool readiness without calling the backend. Returns { ready: true } on success or a structured not_ready error (same shape as a failed invoke)." + }, + "args": { + "type": "object", + "description": "Arguments object passed to the backend tool. Aliases: params, arguments (args wins if multiple are set).", + "default": {} + }, + "params": { + "type": "object", + "description": "Alias for args" + }, + "arguments": { + "type": "object", + "description": "Alias for args" + }, + "filter": { + "type": "object", + "description": "Optional result shaping (max_rows, max_bytes, fields, format). Omit to return the backend response as-is.", + "properties": { + "max_rows": { + "type": "integer", + "minimum": 1, + "description": "Maximum rows/items to return from large arrays" + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "description": "Maximum UTF-8 bytes for text or serialized JSON payloads" + }, + "fields": { + "type": "array", + "items": { "type": "string" }, + "description": "When set, keep only these fields on each object in list results" + }, + "format": { + "type": "string", + "enum": ["summary", "full"], + "description": "When max_rows is set: summary caps the sample at min(max_rows, 5); full returns up to max_rows rows. Ignored when max_rows is omitted." + } + } + } + } + }) + } + + fn is_write(&self) -> bool { + false + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let server_id = resolve_invoke_server_id(&call.args).ok_or_else(|| { + MetaToolError::InvalidArgument("missing `server_id` (aliases: server, serverId)".into()) + })?; + let tool_input = resolve_invoke_tool(&call.args).ok_or_else(|| { + MetaToolError::InvalidArgument( + "missing `tool` (aliases: tool_name; bare or qualified, e.g. \"list_issues\" or \"github_list_issues\")" + .into(), + ) + })?; + let bare_tool_name = normalize_invoke_tool_name(&server_id, &tool_input); + let preflight = call + .args + .get("preflight") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let args = resolve_invoke_tool_args(&call.args); + let filter = parse_invoke_filter(call.args.get("filter")); + + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let binding_servers: std::collections::HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + + let installed = call + .ctx + .installed_server_repo + .get_by_server_id(&space_id.to_string(), &server_id) + .await + .ok() + .flatten(); + let server_display_name = installed.as_ref().map(|s| s.display_name().to_string()); + + if !binding_servers.contains(&server_id) { + let (reason, tool) = + classify_invoke_denial(false, ConnectionStatus::Disconnected, false) + .unwrap_or(("inactive", "mcpmux_bind_current_workspace")); + return Ok(invoke_not_ready( + reason, + format_invoke_not_ready_action_with_name( + reason, + &server_id, + server_display_name.as_deref(), + ), + tool, + )); + } + + let pool_statuses = call.ctx.server_manager.get_all_statuses(space_id).await; + let connection_status = pool_statuses + .get(&server_id) + .map(|(status, _, _, _)| *status) + .unwrap_or(ConnectionStatus::Disconnected); + let has_missing_inputs = installed + .as_ref() + .map(|server| !parse_missing_required_inputs(server).is_empty()) + .unwrap_or(false); + + if let Some((reason, tool)) = + classify_invoke_denial(true, connection_status, has_missing_inputs) + { + return Ok(invoke_not_ready( + reason, + format_invoke_not_ready_action_with_name( + reason, + &server_id, + server_display_name.as_deref(), + ), + tool, + )); + } + + let matched = invokable.iter().find(|f| { + f.feature_type == FeatureType::Tool + && f.server_id == server_id + && feature_matches_tool_name( + &f.feature_name, + &f.qualified_name(), + &tool_input, + &bare_tool_name, + ) + }); + let qualified_name = matched.map(|f| f.qualified_name()).unwrap_or_else(|| { + if tool_input.starts_with(&format!("{server_id}_")) { + tool_input.clone() + } else { + format!("{server_id}_{bare_tool_name}") + } + }); + let is_invokable = matched.map(|f| f.is_available).unwrap_or(false); + + if !is_invokable { + if preflight { + return Ok(invoke_not_ready( + "permission_denied", + format_invoke_not_ready_action_with_name( + "permission_denied", + &server_id, + server_display_name.as_deref(), + ), + "mcpmux_search_tools", + )); + } + let candidates: Vec = invokable + .iter() + .filter(|f| f.server_id == server_id) + .map(|f| f.feature_name.clone()) + .collect(); + let suggestions = levenshtein_suggestions(&bare_tool_name, &candidates, 5); + return Ok(invoke_error(format_invoke_permission_denied( + &qualified_name, + &server_id, + &bare_tool_name, + &suggestions, + ))); + } + + if preflight { + return Ok(invoke_preflight_ok()); + } + + let effective_args = match installed { + Some(server) => { + merge_default_params(args, &server.default_params, server.default_params_strategy) + } + None => args, + }; + + let backend = call + .ctx + .invoke_backend + .as_ref() + .ok_or_else(|| MetaToolError::Internal("invoke routing not configured".into()))?; + match backend + .call_tool( + space_id, + &resolved.feature_set_ids, + &qualified_name, + effective_args, + ) + .await + { + Ok(result) => { + if result.is_error { + let content: Vec = result + .content + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let mut mcp_result = CallToolResult::error(content); + mcp_result.structured_content = result.structured_content; + return Ok(mcp_result); + } + + let (content, structured_content) = match filter.as_ref().filter(|f| f.has_effect()) + { + Some(active_filter) => apply_invoke_result_filter( + result.content, + result.structured_content, + active_filter, + ), + None => (result.content, result.structured_content), + }; + let parsed_content: Vec = content + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let mut mcp_result = CallToolResult::success(parsed_content); + mcp_result.structured_content = structured_content; + Ok(mcp_result) + } + Err(e) => Ok(invoke_error(e.to_string())), + } + } +} + +/// Merge per-server default params with caller-supplied args. +/// +/// `Fill` (default): `{ ...defaults, ...caller_args }` — caller wins on collision. +/// `Override`: `{ ...caller_args, ...defaults }` — defaults win on collision. +/// +/// Returns `args` unchanged when `defaults` is empty or `args` is not an Object. +fn merge_default_params( + args: Value, + defaults: &HashMap, + strategy: DefaultParamsStrategy, +) -> Value { + if defaults.is_empty() { + return args; + } + let Value::Object(caller_map) = args else { + debug!("merge_default_params: args is not an Object; server defaults not applied"); + return args; + }; + let defaults_map: Map = defaults + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let mut merged = match strategy { + DefaultParamsStrategy::Fill => { + // defaults as base, caller overwrites + let mut m = defaults_map; + m.extend(caller_map); + m + } + DefaultParamsStrategy::Override => { + // caller as base, defaults overwrite + let mut m: Map = caller_map; + m.extend(defaults_map); + m + } + }; + // keep deterministic key order for tests + merged.sort_keys(); + Value::Object(merged) +} + +/// Build a structured MCP error payload for invoke failures. +fn invoke_error(message: String) -> CallToolResult { + let payload = json!({ + "error": "invoke_failed", + "message": message, + }); + CallToolResult::error(vec![Content::text(payload.to_string())]) +} + +/// Build a structured not-ready denial before backend dispatch. +fn invoke_not_ready(reason: &str, action: String, tool: &str) -> CallToolResult { + let payload = json!({ + "error": "not_ready", + "reason": reason, + "action": action, + "tool": tool, + }); + CallToolResult::error(vec![Content::text(payload.to_string())]) +} + +/// Successful preflight response — readiness verified, no backend call. +fn invoke_preflight_ok() -> CallToolResult { + CallToolResult::success(vec![Content::text(json!({ "ready": true }).to_string())]) +} + +#[cfg(test)] +#[path = "invoke_tool_tests.rs"] +mod tests; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_tool_tests.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_tool_tests.rs new file mode 100644 index 00000000..c01bef43 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_tool_tests.rs @@ -0,0 +1,138 @@ +use super::*; +use serde_json::json; + +#[test] +fn resolve_invoke_tool_args_prefers_args_over_params() { + let call_args = json!({ + "args": { "owner": "a" }, + "params": { "owner": "b" } + }); + assert_eq!( + resolve_invoke_tool_args(&call_args), + json!({ "owner": "a" }) + ); +} + +#[test] +fn resolve_invoke_tool_args_falls_back_to_params() { + let call_args = json!({ "params": { "repo": "mcp-mux" } }); + assert_eq!( + resolve_invoke_tool_args(&call_args), + json!({ "repo": "mcp-mux" }) + ); +} + +#[test] +fn resolve_invoke_tool_args_defaults_to_empty_object() { + assert_eq!(resolve_invoke_tool_args(&json!({})), json!({})); +} + +#[test] +fn resolve_invoke_tool_args_falls_back_to_arguments() { + let call_args = json!({ "arguments": { "id": "page-1" } }); + assert_eq!( + resolve_invoke_tool_args(&call_args), + json!({ "id": "page-1" }) + ); +} + +#[test] +fn resolve_invoke_tool_args_prefers_args_over_arguments() { + let call_args = json!({ + "args": { "id": "a" }, + "arguments": { "id": "b" } + }); + assert_eq!(resolve_invoke_tool_args(&call_args), json!({ "id": "a" })); +} + +#[test] +fn resolve_invoke_server_id_accepts_aliases() { + assert_eq!( + resolve_invoke_server_id(&json!({ "server_id": "github" })), + Some("github".to_string()) + ); + assert_eq!( + resolve_invoke_server_id(&json!({ "server": "notion" })), + Some("notion".to_string()) + ); + assert_eq!( + resolve_invoke_server_id(&json!({ "serverId": "jira" })), + Some("jira".to_string()) + ); +} + +#[test] +fn resolve_invoke_server_id_prefers_server_id_over_aliases() { + let call_args = json!({ + "server_id": "canonical", + "server": "alias", + "serverId": "other" + }); + assert_eq!( + resolve_invoke_server_id(&call_args), + Some("canonical".to_string()) + ); +} + +#[test] +fn resolve_invoke_tool_accepts_tool_name_alias() { + assert_eq!( + resolve_invoke_tool(&json!({ "tool_name": "notion-fetch" })), + Some("notion-fetch".to_string()) + ); +} + +#[test] +fn resolve_invoke_tool_prefers_tool_over_tool_name() { + let call_args = json!({ + "tool": "bare", + "tool_name": "alias" + }); + assert_eq!(resolve_invoke_tool(&call_args), Some("bare".to_string())); +} + +#[test] +fn normalize_invoke_tool_name_strips_server_prefix() { + assert_eq!( + normalize_invoke_tool_name("github", "github_list_issues"), + "list_issues" + ); +} + +#[test] +fn normalize_invoke_tool_name_passes_bare_through() { + assert_eq!( + normalize_invoke_tool_name("github", "list_issues"), + "list_issues" + ); +} + +#[test] +fn normalize_invoke_tool_name_strips_repeated_prefix() { + assert_eq!( + normalize_invoke_tool_name("github", "github_github_list_issues"), + "list_issues" + ); +} + +#[test] +fn feature_matches_tool_name_accepts_qualified_or_bare() { + assert!(feature_matches_tool_name( + "list_issues", + "github_list_issues", + "github_list_issues", + "list_issues" + )); + assert!(feature_matches_tool_name( + "list_issues", + "github_list_issues", + "list_issues", + "list_issues" + )); + assert!(!feature_matches_tool_name( + "other_tool", + "github_other_tool", + "list_issues", + "list_issues" + )); +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/list_servers.rs b/crates/mcpmux-gateway/src/services/meta_tools/list_servers.rs new file mode 100644 index 00000000..511490db --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/list_servers.rs @@ -0,0 +1,173 @@ +//! `mcpmux_list_servers` — roster of installed MCP servers with readiness. + +use async_trait::async_trait; +use mcpmux_core::FeatureType; +use rmcp::model::CallToolResult; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +use super::diagnose_server::{ + classify_health, connection_status_label, parse_missing_required_inputs, ServerHealth, +}; +use super::meta_tool_common::{caller_resolution, derive_server_readiness, text_result}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use crate::pool::ConnectionStatus; + +pub struct ListServersTool; + +#[async_trait] +impl MetaTool for ListServersTool { + fn name(&self) -> &'static str { + "mcpmux_list_servers" + } + + fn description(&self) -> &'static str { + "List every MCP server installed in the caller's resolved Space with \ + readiness per server: bindable (not in the active binding — use \ + bindable_feature_set_ids with mcpmux_bind_current_workspace), bound \ + (in binding but not invokable — see blocking_reason), or ready (safe \ + to invoke). Each entry includes connection, health, and conditional \ + missing_inputs when setup is incomplete. Clone installs include \ + optional cloned_from (source server_id)." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = resolved + .space_id + .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; + + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await?; + let binding_servers: HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + + let features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + + let installed = call + .ctx + .installed_server_repo + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let installed_by_server: HashMap = installed + .into_iter() + .map(|s| (s.server_id.clone(), s)) + .collect(); + + let pool_statuses = call.ctx.server_manager.get_all_statuses(space_id).await; + + let inactive_by_server: HashMap> = call + .ctx + .feature_service + .list_inactive_discovery_tools(&space_id.to_string(), &resolved.feature_set_ids, None) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))? + .into_iter() + .fold(HashMap::new(), |mut acc, entry| { + acc.entry(entry.feature.server_id.clone()) + .or_default() + .insert(entry.bindable_feature_set_id); + acc + }); + + // Seed every installed server first so servers with no discovered tool features + // still appear (e.g. auth-pending or needs-setup servers with zero rows). + let mut by_server: HashMap, usize)> = installed_by_server + .keys() + .map(|id| (id.clone(), (None, 0usize))) + .collect(); + for feature in &features { + if feature.feature_type != FeatureType::Tool { + continue; + } + let entry = by_server + .entry(feature.server_id.clone()) + .or_insert((None, 0)); + if entry.0.is_none() { + entry.0 = feature.display_name.clone(); + } + entry.1 += 1; + } + + let mut servers: Vec = by_server + .into_iter() + .map(|(id, (feature_display_name, tool_count))| { + let installed = installed_by_server.get(&id); + let name = installed + .map(|s| s.display_name().to_string()) + .or(feature_display_name) + .unwrap_or_else(|| id.clone()); + + let in_binding = binding_servers.contains(&id); + let connection_status = pool_statuses + .get(&id) + .map(|(status, _, _, _)| *status) + .unwrap_or(ConnectionStatus::Disconnected); + let missing_inputs = installed + .map(parse_missing_required_inputs) + .unwrap_or_default(); + let has_missing_inputs = !missing_inputs.is_empty(); + let health = classify_health(connection_status, has_missing_inputs); + let (readiness, blocking_reason) = + derive_server_readiness(in_binding, connection_status, has_missing_inputs); + + let mut entry = json!({ + "id": id, + "name": name, + "tool_count": tool_count, + "readiness": readiness, + "connection": connection_status_label(connection_status), + "health": health, + }); + + if let Some(reason) = blocking_reason { + entry["blocking_reason"] = json!(reason); + } + if health == ServerHealth::NeedsSetup { + entry["missing_inputs"] = json!(missing_inputs); + } + if let Some(cloned_from) = installed.and_then(|s| s.cloned_from.as_ref()) { + entry["cloned_from"] = json!(cloned_from); + } + if let Some(server) = installed { + if !server.default_params.is_empty() { + let mut keys: Vec<&str> = + server.default_params.keys().map(String::as_str).collect(); + keys.sort_unstable(); + entry["prefilled_params"] = json!(keys); + } + } + if readiness == "bindable" { + if let Some(fs_ids) = inactive_by_server.get(&id) { + let mut ids: Vec<_> = fs_ids.iter().cloned().collect(); + ids.sort(); + entry["bindable_feature_set_ids"] = json!(ids); + } + } + entry + }) + .collect(); + servers.sort_by(|a, b| { + a.get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(b.get("id").and_then(|v| v.as_str()).unwrap_or("")) + }); + + Ok(text_result(json!({ "servers": servers }))) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/meta_tool_common.rs b/crates/mcpmux-gateway/src/services/meta_tools/meta_tool_common.rs new file mode 100644 index 00000000..f2f83bc4 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/meta_tool_common.rs @@ -0,0 +1,300 @@ +//! Shared helpers for meta-tool implementations — caller resolution, readiness, +//! structured invoke denial, write approval, and domain-event emission. + +use std::collections::{HashMap, HashSet}; + +use rmcp::model::{CallToolResult, Content}; +use serde_json::Value; +use tokio::sync::broadcast; +use uuid::Uuid; + +use super::approval::ApprovalPayload; +use super::diagnose_server::{classify_health, parse_missing_required_inputs, ServerHealth}; +use super::registry::{MetaToolCall, MetaToolError}; +use crate::pool::{ + format_server_bound_offline_error, format_server_inactive_error, ConnectionStatus, +}; +use crate::services::ResolvedFeatureSet; +use mcpmux_core::DomainEvent; + +/// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a +/// `tools/list_changed` notification to every connected client in the Space. +/// Used by every write tool after a successful mutation. +pub(crate) fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: Uuid) { + let _ = event_tx.send(DomainEvent::FeatureSetMembersChanged { + space_id, + feature_set_id: "meta-tool-write".into(), + added_count: 0, + removed_count: 0, + }); +} + +/// Notify listeners that a workspace binding row changed. +pub(crate) fn emit_workspace_binding_changed( + event_tx: &broadcast::Sender, + space_id: Uuid, + workspace_root: &str, +) { + let _ = event_tx.send(DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root: workspace_root.to_string(), + }); +} + +pub(crate) fn text_result(v: Value) -> CallToolResult { + CallToolResult::success(vec![Content::text(v.to_string())]) +} + +/// Resolve the Space the caller is *actually* routed into — i.e. whichever +/// Space the resolver picks via WorkspaceBinding for this session's reported +/// roots, falling back to the default Space when no binding matches. +/// +/// Every meta tool reads (and writes) inside this Space. That keeps the +/// caller's tool/FS view aligned with the tools the gateway actually exposes +/// to them, and prevents an LLM in workspace A from mutating FSes in +/// workspace B just because both sit under the same default-Space-flagged +/// row in the DB. +pub(crate) async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { + let resolved = call + .ctx + .resolver + .resolve(call.session_id, Some(call.client_id)) + .await?; + if let Some(space_id) = resolved.space_id { + return Ok(space_id); + } + // Resolver returned no space — should only happen in the pathological + // "no default space configured" setup. Fail loudly so callers see why. + Err(MetaToolError::Internal( + "no Space resolved for this caller (no default Space configured?)".into(), + )) +} + +/// Full resolver output for the caller — space + binding FS ids + source. +pub(crate) async fn caller_resolution( + call: &MetaToolCall<'_>, +) -> Result { + call.ctx + .resolver + .resolve(call.session_id, Some(call.client_id)) + .await + .map_err(|e| MetaToolError::Internal(e.to_string())) +} + +/// Map a health bucket to the `blocking_reason` string for bound-but-not-ready servers. +fn blocking_reason_from_health(health: ServerHealth) -> Option<&'static str> { + match health { + ServerHealth::Healthy => None, + ServerHealth::AuthRequired => Some("auth_required"), + ServerHealth::NeedsSetup => Some("needs_setup"), + ServerHealth::Disconnected => Some("disconnected"), + ServerHealth::Error => Some("error"), + } +} + +/// Derive agent-facing readiness from binding membership and live pool state. +/// +/// `ready` requires binding + `Connected` + no missing required inputs; `bound` covers +/// bound-but-offline/auth/setup cases; `bindable` means not in the active binding. +pub(crate) fn derive_server_readiness( + in_binding: bool, + connection_status: ConnectionStatus, + has_missing_inputs: bool, +) -> (&'static str, Option<&'static str>) { + if !in_binding { + return ("bindable", None); + } + + if has_missing_inputs { + return ("bound", Some("needs_setup")); + } + + if connection_status == ConnectionStatus::Connected { + return ("ready", None); + } + + let health = classify_health(connection_status, false); + let blocking = blocking_reason_from_health(health).or(Some("disconnected")); + ("bound", blocking) +} + +/// Structured invoke denial reason and remedy meta tool when a server cannot accept calls. +pub(crate) fn classify_invoke_denial( + in_binding: bool, + connection_status: ConnectionStatus, + has_missing_inputs: bool, +) -> Option<(&'static str, &'static str)> { + let (readiness, blocking_reason) = + derive_server_readiness(in_binding, connection_status, has_missing_inputs); + + match readiness { + "ready" => None, + "bindable" => Some(("inactive", "mcpmux_bind_current_workspace")), + "bound" => { + let reason = match blocking_reason { + Some("needs_setup") => "needs_setup", + Some("auth_required") => "auth_required", + _ => "bound_offline", + }; + Some((reason, "mcpmux_diagnose_server")) + } + _ => None, + } +} + +/// Human-readable `action` string for structured invoke denial payloads. +pub(crate) fn format_invoke_not_ready_action(reason: &str, server_id: &str) -> String { + match reason { + "inactive" => format_server_inactive_error(server_id), + "permission_denied" => format!( + "Tool not granted for server '{server_id}'. \ + Use mcpmux_search_tools to discover invokable tools with current grants." + ), + "auth_required" => format!( + "Server '{server_id}' requires authentication. Run mcpmux_diagnose_server to connect." + ), + "needs_setup" => format!( + "Server '{server_id}' has missing required setup inputs. Run mcpmux_diagnose_server to see what's needed." + ), + _ => format_server_bound_offline_error(server_id), + } +} + +/// Like [`format_invoke_not_ready_action`] but appends the server display name when known. +pub(crate) fn format_invoke_not_ready_action_with_name( + reason: &str, + server_id: &str, + display_name: Option<&str>, +) -> String { + let base = format_invoke_not_ready_action(reason, server_id); + match display_name { + Some(name) if !name.is_empty() && name != server_id => format!("{base} ({name})"), + _ => base, + } +} + +/// Display names and pre-configured default param keys per installed server. +pub(crate) async fn build_installed_server_meta_maps( + call: &MetaToolCall<'_>, + space_id: &Uuid, +) -> Result<(HashMap, HashMap>), MetaToolError> { + let installed = call + .ctx + .installed_server_repo + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let mut display_names = HashMap::new(); + let mut prefilled_params = HashMap::new(); + for server in installed { + display_names.insert(server.server_id.clone(), server.display_name().to_string()); + if !server.default_params.is_empty() { + let mut keys: Vec = server.default_params.keys().cloned().collect(); + keys.sort(); + prefilled_params.insert(server.server_id.clone(), keys); + } + } + + Ok((display_names, prefilled_params)) +} + +/// Whether the caller omitted or blanked the search query. +pub(crate) fn is_query_empty(query: Option<&str>) -> bool { + query.map(str::trim).is_none_or(str::is_empty) +} + +/// Point-in-time `readiness` label per server for search hit enrichment. +pub(crate) async fn build_server_readiness_map( + call: &MetaToolCall<'_>, + space_id: &Uuid, + resolved: &ResolvedFeatureSet, +) -> Result, MetaToolError> { + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await?; + let binding_servers: HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + + let installed = call + .ctx + .installed_server_repo + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let installed_by_id: HashMap = installed + .into_iter() + .map(|s| (s.server_id.clone(), s)) + .collect(); + + let pool_statuses = call.ctx.server_manager.get_all_statuses(*space_id).await; + + let server_ids: HashSet = binding_servers + .iter() + .chain(installed_by_id.keys()) + .cloned() + .collect(); + + let map = server_ids + .into_iter() + .map(|server_id| { + let in_binding = binding_servers.contains(&server_id); + let connection_status = pool_statuses + .get(&server_id) + .map(|(status, _, _, _)| *status) + .unwrap_or(ConnectionStatus::Disconnected); + let has_missing_inputs = installed_by_id + .get(&server_id) + .map(|server| !parse_missing_required_inputs(server).is_empty()) + .unwrap_or(false); + let (readiness, _) = + derive_server_readiness(in_binding, connection_status, has_missing_inputs); + (server_id, readiness) + }) + .collect(); + Ok(map) +} + +/// Common path for every write tool: build payload, ask broker, run the +/// mutation. Returns the broker's decision so the caller can proceed only +/// on success. `mutate` is the thing that runs post-approval and is +/// expected to emit `tools/list_changed` when relevant. +pub(crate) async fn with_approval( + call: &MetaToolCall<'_>, + tool_name: &'static str, + summary: String, + diff: Option, + affects_other_clients: bool, + raw_args: Value, + mutate: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let payload = ApprovalPayload { + tool_name: tool_name.to_string(), + summary, + diff, + raw_args, + affects_other_clients, + }; + call.ctx + .approval_broker + .request_approval(call.client_id, tool_name, payload) + .await?; + mutate().await +} + +pub(crate) fn parse_uuid_arg(args: &Value, field: &str) -> Result { + let s = args + .get(field) + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`")))?; + Uuid::parse_str(s) + .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index b56735a1..6e983d7a 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -21,21 +21,72 @@ //! (`mcpmux_`) so the handler can route them before feature-set filtering. pub mod approval; +mod bind_workspace; +mod diagnose_server; +mod diagnose_view; pub mod diff; +pub mod disclosure_backend; +mod disclosure_read; +mod disclosure_search; +mod feature_set_tools; +mod invoke_alias; +pub mod invoke_backend; +mod invoke_payload_parse; +pub mod invoke_result_filter; +mod invoke_result_shaping; +pub mod invoke_tool; +mod list_servers; +mod meta_tool_common; mod registry; -mod tools; +mod search_tools; +mod search_tools_index; +mod set_workspace_root; +mod token_budget; pub use approval::{ ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, ApprovalRequest, - ApprovalScope, + ApprovalScope, ResolutionNotifier, META_TOOL_APPROVAL_EVENT, META_TOOL_APPROVAL_RESOLVED_EVENT, }; +pub use bind_workspace::BindCurrentWorkspaceTool; +pub use diagnose_server::DiagnoseServerTool; pub use diff::ToolDiff; -pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry}; +pub use disclosure_backend::{pool_as_disclosure_backend, DisclosureBackend}; +pub use feature_set_tools::{GetToolSchemaTool, ListFeatureSetsTool}; +pub use invoke_backend::{routing_as_invoke_backend, InvokeToolBackend}; +pub use invoke_tool::InvokeToolTool; +pub use list_servers::ListServersTool; +pub use registry::{ + feature_set_ids_fingerprint, MetaToolContext, MetaToolError, MetaToolRegistry, + META_TOOLS_ENABLED_KEY, +}; +pub use search_tools::SearchToolsTool; +pub use set_workspace_root::SetWorkspaceRootTool; +pub use token_budget::{measure_meta_tool_token_budget, MetaToolTokenBudget}; + +use std::path::PathBuf; + +use crate::services::{EmbeddingService, ToolDiscoveryService}; /// Every built-in tool's name must start with this prefix so the handler /// can intercept it before routing to backend servers. pub const MCPMUX_PREFIX: &str = "mcpmux_"; +/// Tools advertised in `tools/list` on every session. The remainder are +/// registered (callable) but hidden — agents reach them through the +/// error/hint recovery strings that name them when needed. +/// +/// Core = the hot path every session: discover → schema → invoke + roster. +/// `mcpmux_set_workspace_root` is included here because it is the only +/// escape hatch when the automatic roots/list probe fails (PendingRoots +/// state) — without it in the list the LLM has no way to unblock itself. +pub const CORE_META_TOOLS: &[&str] = &[ + "mcpmux_search_tools", + "mcpmux_invoke_tool", + "mcpmux_get_tool_schema", + "mcpmux_list_servers", + "mcpmux_set_workspace_root", +]; + /// Convenience: is this tool name one of ours? pub fn is_meta_tool(name: &str) -> bool { name.starts_with(MCPMUX_PREFIX) @@ -52,40 +103,72 @@ pub fn build_default_registry( feature_set_repo: std::sync::Arc, binding_repo: std::sync::Arc, server_feature_repo: std::sync::Arc, + installed_server_repo: std::sync::Arc, resolver: std::sync::Arc, feature_service: std::sync::Arc, + invoke_backend: Option>, + disclosure_backend: Option>, session_roots: std::sync::Arc, approval_broker: std::sync::Arc, domain_event_tx: tokio::sync::broadcast::Sender, settings_repo: Option>, - builtin_config_repo: Option>, + server_manager: std::sync::Arc, + log_manager: std::sync::Arc, + data_dir: PathBuf, + embedding_repo: std::sync::Arc, ) -> std::sync::Arc { + let tool_discovery = + std::sync::Arc::new(ToolDiscoveryService::new(server_feature_repo.clone())); + let resource_discovery = std::sync::Arc::new(crate::services::ResourceDiscoveryService::new( + server_feature_repo.clone(), + )); + let prompt_discovery = std::sync::Arc::new(crate::services::PromptDiscoveryService::new( + server_feature_repo.clone(), + )); + let search_cache = session_roots.search_cache(); + let embedding_store = std::sync::Arc::new(dashmap::DashMap::new()); + let embeddings = std::sync::Arc::new(EmbeddingService::new(data_dir)); let ctx = MetaToolContext { client_repo, space_repo, feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, + invoke_backend, + tool_discovery, + resource_discovery, + prompt_discovery, + disclosure_backend, session_roots, approval_broker, domain_event_tx, settings_repo, - builtin_config_repo, + server_manager, + log_manager, + search_cache, + embedding_store, + embedding_repo, + embeddings, }; let mut registry = MetaToolRegistry::new(ctx); // Reads — no approval needed. - registry.register(Box::new(tools::ListSpacesTool)); - registry.register(Box::new(tools::ListAllToolsTool)); - registry.register(Box::new(tools::SearchToolsTool)); - registry.register(Box::new(tools::ListFeatureSetsTool)); - // Both `describe_resolution` and `describe_workspace` were removed by - // user request — the read surface is the list_* tools above plus - // `search_tools`, which an LLM can stitch into the same picture. - // Writes — gated by ApprovalBroker. - registry.register(Box::new(tools::ManageFeatureSetTool)); - registry.register(Box::new(tools::BindCurrentWorkspaceTool)); + registry.register(Box::new(feature_set_tools::ListFeatureSetsTool)); + registry.register(Box::new(list_servers::ListServersTool)); + registry.register(Box::new(search_tools::SearchToolsTool)); + registry.register(Box::new(feature_set_tools::GetToolSchemaTool)); + registry.register(Box::new(diagnose_server::DiagnoseServerTool)); + registry.register(Box::new(invoke_tool::InvokeToolTool)); + registry.register(Box::new(disclosure_search::SearchResourcesTool)); + registry.register(Box::new(disclosure_read::ReadResourceTool)); + registry.register(Box::new(disclosure_search::SearchPromptsTool)); + registry.register(Box::new(disclosure_read::FetchPromptTool)); + // Writes — gated by ApprovalBroker (bind-only; humans author bundles in UI). + registry.register(Box::new(bind_workspace::BindCurrentWorkspaceTool)); + // Session root override — no approval (ephemeral in-memory only). + registry.register(Box::new(set_workspace_root::SetWorkspaceRootTool)); std::sync::Arc::new(registry) } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index b260316e..c8604880 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -4,24 +4,47 @@ //! dispatches a tool name to its handler and exposes `list()` for the MCP //! `tools/list` response. +use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; -use std::sync::Arc; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; +use dashmap::DashMap; use mcpmux_core::{ - builtin_server, DomainEvent, FeatureSetRepository, InboundMcpClientRepository, - ServerFeatureRepository, SpaceBuiltinConfigRepository, SpaceRepository, - WorkspaceBindingRepository, TOOL_OPTIMIZATION_SERVER_ID, + DomainEvent, EmbeddingRepository, FeatureSetRepository, InboundMcpClientRepository, + InstalledServerRepository, ServerFeatureRepository, ServerLogManager, SpaceRepository, + WorkspaceBindingRepository, }; use rmcp::model::{CallToolResult, Tool}; use serde_json::Value; use thiserror::Error; use tokio::sync::broadcast; -use uuid::Uuid; +use tracing::debug; use super::approval::ApprovalBroker; -use crate::pool::FeatureService; -use crate::services::{FeatureSetResolverService, SessionRootsRegistry}; +use super::disclosure_backend::DisclosureBackend; +use super::invoke_backend::InvokeToolBackend; +use crate::pool::{FeatureService, ServerManager}; +use crate::services::{ + EmbeddingService, FeatureSetResolverService, PromptDiscoveryService, ResourceDiscoveryService, + SessionRootsRegistry, ToolDiscoveryService, ToolIndex, +}; + +/// Stable hash of sorted `feature_set_ids` for per-session search cache keys. +pub fn feature_set_ids_fingerprint(feature_set_ids: &[String]) -> u64 { + let mut ids = feature_set_ids.to_vec(); + ids.sort(); + let mut hasher = DefaultHasher::new(); + for id in ids { + id.hash(&mut hasher); + } + hasher.finish() +} + +/// App-settings key that toggles the entire `mcpmux_*` namespace. +/// Present + "false" → hidden; missing or anything else → enabled. +pub const META_TOOLS_ENABLED_KEY: &str = "gateway.meta_tools_enabled"; /// Context injected into every meta-tool invocation. /// @@ -34,21 +57,37 @@ pub struct MetaToolContext { pub feature_set_repo: Arc, pub binding_repo: Arc, pub server_feature_repo: Arc, + pub installed_server_repo: Arc, pub resolver: Arc, pub feature_service: Arc, + /// Backend invoke path — required for `mcpmux_invoke_tool`. + pub invoke_backend: Option>, + pub tool_discovery: Arc, + pub resource_discovery: Arc, + pub prompt_discovery: Arc, + /// Backend read/fetch path — required for `mcpmux_read_resource` / `mcpmux_fetch_prompt`. + pub disclosure_backend: Option>, pub session_roots: Arc, pub approval_broker: Arc, /// Broadcast domain events (e.g. ToolsChanged) so MCPNotifier can push /// `tools/list_changed` to connected peers after a write mutates state. pub domain_event_tx: broadcast::Sender, - /// App-settings repo (retained for future built-in servers; the meta-tools - /// enablement now lives in `builtin_config_repo`, scoped per Space). + /// App-settings repo for the `gateway.meta_tools_enabled` master switch. + /// Optional because older dependency builders may not have wired it. + /// When absent the switch defaults to ENABLED (matches the product default). pub settings_repo: Option>, - /// Per-Space built-in-server config. Gates whether the Tool Optimization - /// (`mcpmux_*`) server + its individual tools are advertised for a given - /// Space. Optional because some dependency builders / tests don't wire it; - /// when absent the server defaults to ENABLED with all tools on. - pub builtin_config_repo: Option>, + /// Runtime connection status for installed servers (pool orchestrator). + pub server_manager: Arc, + /// Per-server log tail reader (`current.log`); same source as the desktop UI. + pub log_manager: Arc, + /// Per-session active tool index for `mcpmux_search_tools` (fingerprint-keyed). + pub search_cache: Arc>, + /// Global embedding vectors keyed by content hash. + pub embedding_store: Arc>>, + /// Persistent embedding repository backing `embedding_store` hydration. + pub embedding_repo: Arc, + /// Local ONNX embedding service for hybrid tool ranking. + pub embeddings: Arc, } /// Per-request metadata threaded through every tool call. @@ -62,6 +101,9 @@ pub struct MetaToolCall<'a> { /// JSON arguments supplied in `CallToolRequestParams.arguments`. pub args: Value, pub ctx: &'a MetaToolContext, + /// Write tools set this before returning `Ok` to override the default + /// `"allow_once"` audit decision (e.g. workspace bind). + pub audit_decision: Arc>>, } /// Errors a meta tool can surface that map cleanly to `CallToolResult::error`. @@ -161,72 +203,46 @@ impl MetaToolRegistry { self.tools.contains_key(name) } - /// Is the Tool Optimization (`mcpmux_*`) built-in server enabled for this - /// Space? Reads the per-Space override, falling back to the descriptor's - /// `default_enabled` (ON). When no config repo is wired (older builders / - /// tests), defaults to ON. - pub async fn is_server_enabled_for_space(&self, space_id: &Uuid) -> bool { - let Some(repo) = self.ctx.builtin_config_repo.as_ref() else { + /// Master switch: are meta tools enabled in app settings? When disabled, + /// the gateway handler hides `mcpmux_*` from `list_tools` and routes + /// `call_tool` invocations straight to the feature-set path (where they + /// will miss and return "tool not found"). + /// + /// Default when the setting is missing or the repo is not wired: ON. + /// Default when the setting value is unparseable: ON (fail-open on the + /// discoverability side; security-sensitive writes still require approval). + pub async fn is_enabled(&self) -> bool { + let Some(repo) = self.ctx.settings_repo.as_ref() else { return true; }; - let default = builtin_server(TOOL_OPTIMIZATION_SERVER_ID) - .map(|d| d.default_enabled) - .unwrap_or(true); - repo.server_enabled_override(&space_id.to_string(), TOOL_OPTIMIZATION_SERVER_ID) - .await - .ok() - .flatten() - .unwrap_or(default) - } - - /// Tool names disabled for this Space (empty when no config repo / none). - async fn disabled_tools_for_space(&self, space_id: &Uuid) -> Vec { - match self.ctx.builtin_config_repo.as_ref() { - Some(repo) => repo - .disabled_tools(&space_id.to_string(), TOOL_OPTIMIZATION_SERVER_ID) - .await - .unwrap_or_default(), - None => Vec::new(), - } - } - - /// Whether a specific meta tool is advertised/callable for a Space — the - /// server must be enabled and the individual tool not disabled. Used by the - /// `call_tool` interception path so a disabled tool can't be invoked. - pub async fn is_tool_enabled_for_space(&self, space_id: &Uuid, tool_name: &str) -> bool { - if !self.is_server_enabled_for_space(space_id).await { - return false; + match repo.get(META_TOOLS_ENABLED_KEY).await { + Ok(Some(v)) => !matches!(v.as_str(), "false" | "0"), + _ => true, } - !self - .disabled_tools_for_space(space_id) - .await - .iter() - .any(|n| n == tool_name) } - /// The `rmcp::model::Tool` list advertised to a Space — the enabled tools - /// of the Tool Optimization server, or empty when that server is disabled - /// for the Space. - pub async fn list_as_tools_for_space(&self, space_id: &Uuid) -> Vec { - if !self.is_server_enabled_for_space(space_id).await { - return Vec::new(); - } - let disabled = self.disabled_tools_for_space(space_id).await; - self.list_as_tools() - .into_iter() - .filter(|t| !disabled.iter().any(|d| d == t.name.as_ref())) - .collect() - } - - /// The full unfiltered `rmcp::model::Tool` list (every registered tool, - /// ignoring per-Space config). Used by the per-Space filter above. + /// The `rmcp::model::Tool` list advertised to clients. + /// + /// Only [`super::CORE_META_TOOLS`] are included. The remaining registered + /// tools are hidden from `tools/list` but remain fully callable — agents + /// reach them through the error/hint recovery strings that name them. pub fn list_as_tools(&self) -> Vec { - self.tools + let mut tools: Vec<_> = self + .tools .values() + .filter(|t| super::CORE_META_TOOLS.contains(&t.name())) .map(|t| { - let schema: serde_json::Map = - serde_json::from_value(t.input_schema()).unwrap_or_default(); - let mut tool = Tool::new(t.name(), t.description(), Arc::new(schema)); + let name = t.name(); + let schema: serde_json::Map = match serde_json::from_value( + t.input_schema(), + ) { + Ok(map) => map, + Err(e) => { + debug!(tool = name, error = %e, "meta tool input_schema parse failed; advertising empty schema"); + serde_json::Map::new() + } + }; + let mut tool = Tool::new(name, t.description(), Arc::new(schema)); // Annotate writes so well-behaved clients surface the hint. if t.is_write() { let mut ann = tool.annotations.unwrap_or_default(); @@ -240,7 +256,14 @@ impl MetaToolRegistry { } tool }) - .collect() + .collect(); + tools.sort_by_key(|t| { + super::CORE_META_TOOLS + .iter() + .position(|name| *name == t.name.as_ref()) + .unwrap_or(usize::MAX) + }); + tools } /// Dispatch. Caller (the MCP handler) has already verified the name @@ -262,16 +285,25 @@ impl MetaToolRegistry { .get(name) .ok_or_else(|| MetaToolError::InvalidArgument(format!("unknown meta tool: {name}")))?; let is_write = tool.is_write(); + let audit_decision = Arc::new(Mutex::new(None)); let call = MetaToolCall { client_id, session_id, args: args.clone(), ctx: &self.ctx, + audit_decision: audit_decision.clone(), }; let result = tool.call(call).await; let (decision, summary) = match &result { - Ok(_) if is_write => ("allow_once", format!("{name} succeeded")), + Ok(_) if is_write => ( + audit_decision + .lock() + .ok() + .and_then(|g| *g) + .unwrap_or("allow_once"), + format!("{name} succeeded"), + ), Ok(_) => ("read", format!("{name} read")), Err(MetaToolError::ApprovalDenied) => ("deny", format!("{name} denied by user")), Err(MetaToolError::ApprovalTimedOut) => ("timeout", format!("{name} timed out")), @@ -297,4 +329,14 @@ impl MetaToolRegistry { pub fn context(&self) -> &MetaToolContext { &self.ctx } + + /// Evict cached active index for one MCP session. + pub fn evict_search_cache_for_session(&self, session_id: &str) { + self.ctx.search_cache.remove(session_id); + } + + /// Whether a session has a cached active search index entry. + pub fn search_cache_contains(&self, session_id: &str) -> bool { + self.ctx.search_cache.contains_key(session_id) + } } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/search_tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/search_tools.rs new file mode 100644 index 00000000..2bc5531b --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/search_tools.rs @@ -0,0 +1,446 @@ +//! `mcpmux_search_tools` — hybrid search and browse over the active tool index. + +use async_trait::async_trait; +use rmcp::model::CallToolResult; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::time::Instant; +use tracing::{debug, info}; +use uuid::Uuid; + +use super::meta_tool_common::{ + build_installed_server_meta_maps, build_server_readiness_map, caller_resolution, + is_query_empty, text_result, +}; +use super::registry::{feature_set_ids_fingerprint, MetaTool, MetaToolCall, MetaToolError}; +use super::search_tools_index::{ + build_active_index, build_and_cache_active_index, hydrate_active_embeddings, +}; + +pub struct SearchToolsTool; + +#[async_trait] +impl MetaTool for SearchToolsTool { + fn name(&self) -> &'static str { + "mcpmux_search_tools" + } + + fn description(&self) -> &'static str { + "Search backend tools in the caller's resolved Space. Each match includes \ + qualified_name, bare_name (use as mcpmux_invoke_tool.tool), required_params, \ + optional_params (name + type, capped), server_readiness (bindable | bound | ready), \ + schema_complex (call mcpmux_get_tool_schema when true), and invoke_example on browse \ + hits (copy-paste into mcpmux_invoke_tool). Browse mode: omit query with server_id for \ + that server's A–Z catalog, or set mode: \"browse\" alone for the whole Space (default \ + limit 50, paginated). Ranked search uses default limit 20. By default only invokable \ + tools match; set include_inactive: true (or scope \"all\") for unbound FeatureSets. \ + Supports detail_level (name | description | schema) and cursor pagination." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "server_id": { "type": "string" }, + "include_inactive": { + "type": "boolean", + "default": false, + "description": "When true, include tools from FeatureSets not bound to this workspace (inactive matches carry bindable_feature_set_id). Alias: scope \"all\" — same effect." + }, + "scope": { + "type": "string", + "description": "Optional alias for include_inactive: use \"all\" to search active and inactive tools (prefer include_inactive in new calls)" + }, + "detail_level": { + "type": "string", + "enum": ["name", "description", "schema"], + "default": "description" + }, + "mode": { + "type": "string", + "enum": ["browse"], + "description": "Explicit browse alias: paginated A–Z catalog (default limit 50). With server_id, scopes to that server; without server_id, lists invokable tools across the whole Space. Same as omitting query when server_id is set." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Default 20 for ranked search; 50 when browsing (empty query + server_id or mode browse)" + }, + "cursor": { "type": "string" } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let started = Instant::now(); + let query_id: String = Uuid::new_v4() + .to_string() + .chars() + .filter(|c| *c != '-') + .take(8) + .collect(); + + let resolve_started = Instant::now(); + let resolved = caller_resolution(&call).await?; + let resolve_ms = resolve_started.elapsed().as_millis() as u64; + + // Derive from the already-resolved result — avoids a second resolver round-trip. + let space_id = resolved.space_id.ok_or_else(|| { + MetaToolError::Internal( + "no Space resolved for this caller (no default Space configured?)".into(), + ) + })?; + + debug!( + query_id = %query_id, + resolve_ms, + feature_set_count = resolved.feature_set_ids.len(), + "[search] resolver timing" + ); + + let query_str = call.args.get("query").and_then(|v| v.as_str()); + + let server_id_filter = call.args.get("server_id").and_then(|v| v.as_str()); + let mode_browse = call + .args + .get("mode") + .and_then(|v| v.as_str()) + .is_some_and(|m| m == "browse"); + let is_browse = mode_browse || (is_query_empty(query_str) && server_id_filter.is_some()); + let effective_query = if is_browse { None } else { query_str }; + + let detail_level = call + .args + .get("detail_level") + .and_then(|v| v.as_str()) + .and_then(crate::services::tool_discovery::DetailLevel::parse) + .unwrap_or(crate::services::tool_discovery::DetailLevel::Description); + + let default_limit = if is_browse { 50 } else { 20 }; + let limit = call + .args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(default_limit) as usize; + + let scope_all = call + .args + .get("scope") + .and_then(|v| v.as_str()) + .map(|s| s == "all") + .unwrap_or(false); + let include_inactive = scope_all + || call + .args + .get("include_inactive") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let fingerprint = feature_set_ids_fingerprint(&resolved.feature_set_ids); + + info!( + query_id = %query_id, + session_id = ?call.session_id, + fingerprint, + query_len = query_str.map(str::len).unwrap_or(0), + detail_level = ?detail_level, + limit, + is_browse, + include_inactive, + "[search] call entry" + ); + if let Some(query) = effective_query { + debug!(query_id = %query_id, query, "[search] query text"); + } + + let readiness_map = build_server_readiness_map(&call, &space_id, &resolved).await?; + let (server_display_names, prefilled_params_by_server) = + build_installed_server_meta_maps(&call, &space_id).await?; + + let mut index_cache_hit = false; + let active_index_started = Instant::now(); + let active_index = if let Some(session_id) = call.session_id { + if let Some(entry) = call.ctx.search_cache.get(session_id) { + let (cached_fp, cached_index) = entry.value(); + if *cached_fp == fingerprint { + index_cache_hit = true; + cached_index.clone() + } else { + drop(entry); + build_and_cache_active_index( + &call, + &space_id, + &resolved, + fingerprint, + session_id, + query_id.as_str(), + ) + .await? + } + } else { + build_and_cache_active_index( + &call, + &space_id, + &resolved, + fingerprint, + session_id, + query_id.as_str(), + ) + .await? + } + } else { + build_active_index(&call, &space_id, &resolved, query_id.as_str()).await? + }; + let active_index_ms = active_index_started.elapsed().as_millis() as u64; + + debug!( + query_id = %query_id, + index_cache_hit, + active_tools = active_index.len(), + active_index_ms, + "[search] active index ready" + ); + + let clone_started = Instant::now(); + let mut index = active_index.clone(); + let index_clone_ms = clone_started.elapsed().as_millis() as u64; + + let mut inactive_tool_count = 0usize; + let mut inactive_widen_ms = 0_u64; + + if include_inactive { + debug!( + query_id = %query_id, + "[search] inactive scan starting" + ); + let inactive_started = Instant::now(); + let inactive = call + .ctx + .feature_service + .list_inactive_discovery_tools( + &space_id.to_string(), + &resolved.feature_set_ids, + Some(query_id.as_str()), + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + inactive_tool_count = inactive.len(); + let inactive_index = + crate::services::tool_discovery::ToolDiscoveryService::build_inactive_index( + &inactive, + ); + let active_keys: HashSet<(String, String)> = index + .iter() + .map(|e| (e.server_id.clone(), e.feature_name.clone())) + .collect(); + let before_merge = index.len(); + for entry in inactive_index { + let key = (entry.server_id.clone(), entry.feature_name.clone()); + if !active_keys.contains(&key) { + index.push(entry); + } + } + index.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + inactive_widen_ms = inactive_started.elapsed().as_millis() as u64; + debug!( + query_id = %query_id, + inactive_tools = inactive_tool_count, + merged_index = index.len(), + added_inactive = index.len().saturating_sub(before_merge), + inactive_widen_ms, + "[search] inactive widen complete" + ); + } + + let hydrate_ms = if effective_query.is_some() { + hydrate_active_embeddings(&call, query_id.as_str(), active_index.as_slice()).await? + } else { + 0 + }; + + let hybrid = effective_query.map(|_| crate::services::tool_discovery::SearchContext { + embeddings: call.ctx.embeddings.as_ref(), + embedding_store: call.ctx.embedding_store.as_ref(), + active_index: active_index.as_slice(), + index_cache_hit, + }); + + let rank_started = Instant::now(); + let result = crate::services::tool_discovery::ToolDiscoveryService::search( + &index, + effective_query, + server_id_filter, + detail_level, + limit, + call.args.get("cursor").and_then(|v| v.as_str()), + Some(query_id.as_str()), + hybrid, + Some(&readiness_map), + Some(&server_display_names), + Some(&prefilled_params_by_server), + is_browse, + ); + let rank_ms = rank_started.elapsed().as_millis() as u64; + + let top_qualified_name = result + .tools + .first() + .and_then(|tool| tool.get("qualified_name")) + .and_then(|value| value.as_str()) + .unwrap_or(""); + + let post_started = Instant::now(); + let mut payload = json!({ + "tools": result.tools, + "next_cursor": result.next_cursor, + "total": result.total, + "ranking": result.ranking, + "scope": if include_inactive { "active_and_inactive" } else { "active_only" }, + }); + + if is_browse { + payload["mode"] = json!("browse"); + } + + if include_inactive && inactive_tool_count > 50 && server_id_filter.is_none() { + payload["hint"] = json!("Narrow with `server_id` for faster results."); + } + + if !include_inactive && result.total == 0 && effective_query.is_some() { + let inactive_started = Instant::now(); + let inactive = call + .ctx + .feature_service + .list_inactive_discovery_tools( + &space_id.to_string(), + &resolved.feature_set_ids, + Some(query_id.as_str()), + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let ready_inactive: Vec<_> = inactive + .into_iter() + .filter(|entry| { + readiness_map + .get(&entry.feature.server_id) + .is_some_and(|readiness| *readiness == "ready") + }) + .collect(); + + if ready_inactive.is_empty() { + payload["hint"] = json!( + "No active tools matched. Call mcpmux_list_servers to see installed servers \ + and their readiness — bindable servers can be activated via \ + mcpmux_bind_current_workspace. To browse all available tools across \ + FeatureSets, retry with include_inactive: true." + ); + } else { + let preview_index = + crate::services::tool_discovery::ToolDiscoveryService::build_inactive_index( + &ready_inactive, + ); + let preview = crate::services::tool_discovery::ToolDiscoveryService::search( + &preview_index, + effective_query, + server_id_filter, + detail_level, + 3, + None, + Some(query_id.as_str()), + None, + Some(&readiness_map), + Some(&server_display_names), + Some(&prefilled_params_by_server), + false, + ); + payload["inactive_preview"] = json!(preview.tools); + payload["hint"] = json!( + "No active tools matched, but ready-to-invoke tools exist in an unbound \ + FeatureSet (see inactive_preview). Call mcpmux_bind_current_workspace with \ + the bindable_feature_set_id shown on each preview entry to activate them." + ); + } + inactive_widen_ms = inactive_started.elapsed().as_millis() as u64; + debug!( + query_id = %query_id, + ready_inactive_preview = payload + .get("inactive_preview") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0), + inactive_widen_ms, + "[search] zero-result inactive preview" + ); + } else if include_inactive && result.total == 0 { + let catalog = call + .ctx + .tool_discovery + .build_catalog_index(&space_id.to_string()) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let catalog_result = crate::services::tool_discovery::ToolDiscoveryService::search( + &catalog, + effective_query, + call.args.get("server_id").and_then(|v| v.as_str()), + detail_level, + limit, + call.args.get("cursor").and_then(|v| v.as_str()), + Some(query_id.as_str()), + None, + Some(&readiness_map), + Some(&server_display_names), + Some(&prefilled_params_by_server), + is_browse, + ); + if catalog_result.total > 0 { + payload["hint"] = json!( + "Matching tools exist in this Space but no FeatureSet contains them. \ + Ask the user to create a bundle in the McpMux desktop or web UI \ + (Workspaces → Feature Sets), then mcpmux_bind_current_workspace \ + with the new feature_set_id." + ); + } + } + let post_ms = post_started.elapsed().as_millis() as u64; + + let total_ms = started.elapsed().as_millis() as u64; + let accounted_ms = resolve_ms + + active_index_ms + + index_clone_ms + + inactive_widen_ms + + hydrate_ms + + rank_ms + + post_ms; + + info!( + query_id = %query_id, + ranking = result.ranking, + total = result.total, + returned = result.tools.len(), + top_qualified_name, + top_fused_score = ?result.top_fused_score, + total_ms, + "[search] result summary" + ); + info!( + query_id = %query_id, + resolve_ms, + active_index_ms, + index_clone_ms, + inactive_widen_ms, + hydrate_ms, + rank_ms, + post_ms, + accounted_ms, + unaccounted_ms = total_ms.saturating_sub(accounted_ms), + merged_index = index.len(), + "[search] timing breakdown" + ); + + Ok(text_result(payload)) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/search_tools_index.rs b/crates/mcpmux-gateway/src/services/meta_tools/search_tools_index.rs new file mode 100644 index 00000000..ccb9ec0c --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/search_tools_index.rs @@ -0,0 +1,128 @@ +//! Active tool index build, session cache, and embedding hydration for `mcpmux_search_tools`. + +use std::collections::HashSet; +use std::time::Instant; +use tracing::debug; +use uuid::Uuid; + +use super::registry::{MetaToolCall, MetaToolError}; +use crate::services::ResolvedFeatureSet; + +/// Build the active tool index from DB grants (no cache write). +pub(crate) async fn build_active_index( + call: &MetaToolCall<'_>, + space_id: &Uuid, + resolved: &ResolvedFeatureSet, + query_id: &str, +) -> Result, MetaToolError> { + let invokable_started = Instant::now(); + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let invokable_ms = invokable_started.elapsed().as_millis() as u64; + + let build_index_started = Instant::now(); + let index = call + .ctx + .tool_discovery + .build_index(&space_id.to_string(), &invokable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let build_index_ms = build_index_started.elapsed().as_millis() as u64; + + debug!( + query_id, + invokable_count = invokable.len(), + index_entries = index.len(), + invokable_ms, + build_index_ms, + active_index_build_ms = invokable_ms + build_index_ms, + "[search] active index build" + ); + + Ok(index) +} + +/// Build the active index and store it in the per-session search cache. +pub(crate) async fn build_and_cache_active_index( + call: &MetaToolCall<'_>, + space_id: &Uuid, + resolved: &ResolvedFeatureSet, + fingerprint: u64, + session_id: &str, + query_id: &str, +) -> Result, MetaToolError> { + let index = build_active_index(call, space_id, resolved, query_id).await?; + call.ctx + .search_cache + .insert(session_id.to_string(), (fingerprint, index.clone())); + Ok(index) +} + +/// Load missing active-tool vectors from persistent storage into the global embedding map. +pub(crate) async fn hydrate_active_embeddings( + call: &MetaToolCall<'_>, + query_id: &str, + active_index: &[crate::services::ToolIndexEntry], +) -> Result { + let hydrate_started = Instant::now(); + let missing_hashes: HashSet = active_index + .iter() + .map(crate::services::tool_discovery::entry_content_hash) + .filter(|content_hash| !call.ctx.embedding_store.contains_key(content_hash)) + .collect(); + let hashes_requested = missing_hashes.len(); + + if missing_hashes.is_empty() { + let store_hits = active_index + .iter() + .map(crate::services::tool_discovery::entry_content_hash) + .filter(|content_hash| call.ctx.embedding_store.contains_key(content_hash)) + .count(); + let hydrate_ms = hydrate_started.elapsed().as_millis() as u64; + debug!( + query_id, + hashes_requested = 0, + store_hits, + store_misses = 0, + hydrate_ms, + "[embed] store hydrate" + ); + return Ok(hydrate_ms); + } + + let missing_hashes: Vec = missing_hashes.into_iter().collect(); + let db_started = Instant::now(); + let records = call + .ctx + .embedding_repo + .get_many(&missing_hashes, call.ctx.embeddings.model_version()) + .await + .map_err(|error| MetaToolError::Internal(error.to_string()))?; + let db_ms = db_started.elapsed().as_millis() as u64; + + for record in records { + call.ctx + .embedding_store + .insert(record.content_hash, record.vector); + } + let store_hits = missing_hashes + .iter() + .filter(|content_hash| call.ctx.embedding_store.contains_key(*content_hash)) + .count(); + let hydrate_ms = hydrate_started.elapsed().as_millis() as u64; + debug!( + query_id, + hashes_requested, + store_hits, + store_misses = hashes_requested.saturating_sub(store_hits), + db_ms, + hydrate_ms, + "[embed] store hydrate" + ); + + Ok(hydrate_ms) +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/set_workspace_root.rs b/crates/mcpmux-gateway/src/services/meta_tools/set_workspace_root.rs new file mode 100644 index 00000000..b1fd677c --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/set_workspace_root.rs @@ -0,0 +1,111 @@ +//! `mcpmux_set_workspace_root` — manually declare this session's workspace root. +//! +//! Emergency escape hatch for roots-capable clients (e.g. older Cursor versions) +//! that declare the MCP `roots` capability at `initialize` but never respond to +//! server-initiated `roots/list` probes. In that scenario the resolver stays in +//! the `PendingRoots` state and returns an empty FeatureSet — all backend servers +//! show as `bindable` even when a binding already exists in the database. +//! +//! Calling this tool injects `workspace_root` into the session registry for the +//! current session, re-triggers resolution, and fires `tools/list_changed` so +//! the session immediately sees its bound tools. + +use async_trait::async_trait; +use mcpmux_core::normalize_workspace_root; +use rmcp::model::CallToolResult; +use serde_json::{json, Value}; + +use super::meta_tool_common::{emit_tools_list_changed, text_result}; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; + +pub struct SetWorkspaceRootTool; + +#[async_trait] +impl MetaTool for SetWorkspaceRootTool { + fn name(&self) -> &'static str { + "mcpmux_set_workspace_root" + } + + fn description(&self) -> &'static str { + "Emergency escape hatch: manually declare this session's workspace root when \ + the automatic roots/list probe is not working (e.g. Cursor reports the MCP \ + roots capability but never responds to list_roots). Injects the given path \ + into the session registry and re-resolves the FeatureSet binding — all bound \ + servers become available immediately without restarting. Use when \ + mcpmux_list_servers shows readiness: bindable for all servers despite a \ + workspace binding already existing in McpMux." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["workspace_root"], + "properties": { + "workspace_root": { + "type": "string", + "description": "Absolute path or file:// URI of the workspace root \ + (e.g. /Users/joe/myproject)" + } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let raw_root = call + .args + .get("workspace_root") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `workspace_root`".into()))?; + + let session_id = call.session_id.ok_or_else(|| { + MetaToolError::InvalidArgument( + "no session id — stateless transport cannot track workspace root".into(), + ) + })?; + + let normalized = normalize_workspace_root(raw_root); + if normalized.is_empty() { + return Err(MetaToolError::InvalidArgument(format!( + "workspace_root `{raw_root}` normalized to an empty string — provide an absolute path" + ))); + } + + call.ctx + .session_roots + .set(session_id, std::iter::once(normalized.as_str())); + + let resolved = call + .ctx + .resolver + .resolve(Some(session_id), Some(call.client_id)) + .await?; + + let space_id = resolved + .space_id + .ok_or_else(|| MetaToolError::Internal("no Space resolved".into()))?; + + emit_tools_list_changed(&call.ctx.domain_event_tx, space_id); + + let message = if resolved.feature_set_ids.is_empty() { + format!( + "Root injected but no binding found for '{normalized}'. \ + Use mcpmux_bind_current_workspace to create one." + ) + } else { + format!( + "Root injected and binding resolved ({} FeatureSet(s)). \ + tools/list_changed fired — your bound servers are now available.", + resolved.feature_set_ids.len() + ) + }; + + Ok(text_result(json!({ + "ok": true, + "workspace_root": normalized, + "session_id": session_id, + "resolved_feature_set_ids": resolved.feature_set_ids, + "resolution_source": resolved.source, + "message": message + }))) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/token_budget.rs b/crates/mcpmux-gateway/src/services/meta_tools/token_budget.rs new file mode 100644 index 00000000..2afd4167 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/token_budget.rs @@ -0,0 +1,192 @@ +//! Meta-tool `tools/list` token budget measurement (tests + `pnpm count-tokens`). + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use super::bind_workspace::BindCurrentWorkspaceTool; +use super::diagnose_server::DiagnoseServerTool; +use super::disclosure_read::{FetchPromptTool, ReadResourceTool}; +use super::disclosure_search::{SearchPromptsTool, SearchResourcesTool}; +use super::feature_set_tools::{GetToolSchemaTool, ListFeatureSetsTool}; +use super::invoke_tool::InvokeToolTool; +use super::list_servers::ListServersTool; +use super::registry::MetaTool; +use super::search_tools::SearchToolsTool; +use super::CORE_META_TOOLS; +use rmcp::model::Tool; + +/// Every tool registered in [`super::build_default_registry`] (11 agent-facing tools). +pub const ALL_REGISTERED_META_TOOL_NAMES: &[&str] = &[ + "mcpmux_list_feature_sets", + "mcpmux_list_servers", + "mcpmux_search_tools", + "mcpmux_get_tool_schema", + "mcpmux_diagnose_server", + "mcpmux_invoke_tool", + "mcpmux_search_resources", + "mcpmux_read_resource", + "mcpmux_search_prompts", + "mcpmux_fetch_prompt", + "mcpmux_bind_current_workspace", + "mcpmux_set_workspace_root", +]; + +/// Slim MCP tool object (name + description + inputSchema) — matches `pnpm count-tokens` / planning doc. +fn slim_tool_json(tool: &dyn MetaTool) -> Value { + json!({ + "name": tool.name(), + "description": tool.description(), + "inputSchema": tool.input_schema(), + }) +} + +/// Full `tools/list` entry as built by [`super::MetaToolRegistry::list_as_tools`]. +fn list_as_tools_entry_json(tool: &dyn MetaTool) -> Value { + let schema: serde_json::Map = + serde_json::from_value(tool.input_schema()).unwrap_or_default(); + let mut rmcp_tool = Tool::new(tool.name(), tool.description(), Arc::new(schema)); + if tool.is_write() { + let mut ann = rmcp_tool.annotations.unwrap_or_default(); + ann.destructive_hint = Some(true); + ann.read_only_hint = Some(false); + rmcp_tool.annotations = Some(ann); + } else { + let mut ann = rmcp_tool.annotations.unwrap_or_default(); + ann.read_only_hint = Some(true); + rmcp_tool.annotations = Some(ann); + } + serde_json::to_value(&rmcp_tool).unwrap_or_else(|_| slim_tool_json(tool)) +} + +/// Unit structs for each registered meta tool (same set as `build_default_registry`). +fn all_registered_meta_tools() -> Vec> { + vec![ + Box::new(ListFeatureSetsTool), + Box::new(ListServersTool), + Box::new(SearchToolsTool), + Box::new(GetToolSchemaTool), + Box::new(DiagnoseServerTool), + Box::new(InvokeToolTool), + Box::new(SearchResourcesTool), + Box::new(ReadResourceTool), + Box::new(SearchPromptsTool), + Box::new(FetchPromptTool), + Box::new(BindCurrentWorkspaceTool), + ] +} + +/// Byte length of serialized tool entries for the given tool names. +fn serialized_bytes(tool_names: &[&str], entry_json: fn(&dyn MetaTool) -> Value) -> usize { + let name_set: std::collections::HashSet<&str> = tool_names.iter().copied().collect(); + all_registered_meta_tools() + .into_iter() + .filter(|t| name_set.contains(t.name())) + .map(|t| entry_json(t.as_ref()).to_string().len()) + .sum() +} + +/// Tiktoken-style token estimate from UTF-8 bytes (cl100k_base proxy: bytes / 4). +fn tiktoken_estimate_from_bytes(bytes: usize) -> usize { + bytes.div_ceil(4) +} + +/// Claude context estimate (planning doc: tiktoken × 1.1). +fn claude_estimate(tiktoken: usize) -> usize { + ((tiktoken as f64) * 1.1).ceil() as usize +} + +/// Measured budgets for advertised core vs full registered surface (slim MCP JSON). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetaToolTokenBudget { + pub core_bytes: usize, + pub full_bytes: usize, + pub core_tiktoken: usize, + pub full_tiktoken: usize, + pub core_claude_est: usize, + pub full_claude_est: usize, + pub saved_tiktoken: usize, + pub saved_claude_est: usize, + /// Serialized rmcp `Tool` entries (includes annotations) — upper bound on wire size. + pub core_rmcp_bytes: usize, + pub full_rmcp_bytes: usize, +} + +/// Compute token budgets for core-only vs all registered meta tools. +pub fn measure_meta_tool_token_budget() -> MetaToolTokenBudget { + let core_bytes = serialized_bytes(CORE_META_TOOLS, slim_tool_json); + let full_bytes = serialized_bytes(ALL_REGISTERED_META_TOOL_NAMES, slim_tool_json); + let core_rmcp_bytes = serialized_bytes(CORE_META_TOOLS, list_as_tools_entry_json); + let full_rmcp_bytes = + serialized_bytes(ALL_REGISTERED_META_TOOL_NAMES, list_as_tools_entry_json); + let core_tiktoken = tiktoken_estimate_from_bytes(core_bytes); + let full_tiktoken = tiktoken_estimate_from_bytes(full_bytes); + let core_claude_est = claude_estimate(core_tiktoken); + let full_claude_est = claude_estimate(full_tiktoken); + MetaToolTokenBudget { + core_bytes, + full_bytes, + core_tiktoken, + full_tiktoken, + core_claude_est, + full_claude_est, + saved_tiktoken: full_tiktoken.saturating_sub(core_tiktoken), + saved_claude_est: full_claude_est.saturating_sub(core_claude_est), + core_rmcp_bytes, + full_rmcp_bytes, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meta_tools_token_budget_report() { + let budget = measure_meta_tool_token_budget(); + let line = format!( + "META_TOOL_TOKEN_REPORT core_bytes={} full_bytes={} core_tiktoken={} full_tiktoken={} core_claude_est={} full_claude_est={} saved_tiktoken={} saved_claude_est={} core_rmcp_bytes={} full_rmcp_bytes={}", + budget.core_bytes, + budget.full_bytes, + budget.core_tiktoken, + budget.full_tiktoken, + budget.core_claude_est, + budget.full_claude_est, + budget.saved_tiktoken, + budget.saved_claude_est, + budget.core_rmcp_bytes, + budget.full_rmcp_bytes, + ); + println!("{line}"); + + assert_eq!(CORE_META_TOOLS.len(), 5); + assert_eq!(ALL_REGISTERED_META_TOOL_NAMES.len(), 12); + assert!( + budget.core_claude_est < budget.full_claude_est, + "core must be smaller than full: {budget:?}" + ); + assert!( + budget.saved_claude_est >= 500, + "expected at least ~500 Claude-est token savings, got {budget:?}" + ); + // Regression guardrails (re-measured via `pnpm count-tokens`, Jun 2026; limits doubled). + assert!( + budget.core_claude_est <= 3000, + "slim core advertised budget grew unexpectedly: {budget:?}" + ); + assert!( + budget.full_claude_est <= 5200, + "slim full registered budget grew unexpectedly: {budget:?}" + ); + } + + #[test] + fn core_tools_subset_of_registered() { + for name in CORE_META_TOOLS { + assert!( + ALL_REGISTERED_META_TOOL_NAMES.contains(name), + "{name} missing from ALL_REGISTERED_META_TOOL_NAMES" + ); + } + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs deleted file mode 100644 index e668afde..00000000 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ /dev/null @@ -1,972 +0,0 @@ -//! Built-in `mcpmux_*` meta tool implementations. -//! -//! Each tool is a unit struct implementing [`MetaTool`]. Reads execute -//! directly; writes route through the [`ApprovalBroker`] first. - -use async_trait::async_trait; -use mcpmux_core::{ - normalize_workspace_root, DomainEvent, FeatureType, MemberMode, ServerFeature, WorkspaceBinding, -}; -use rmcp::model::{CallToolResult, Content}; -use serde_json::{json, Value}; -use tokio::sync::broadcast; -use tracing::info; -use uuid::Uuid; - -use super::approval::{ApprovalPayload, ApprovalScope}; -use super::registry::{MetaTool, MetaToolCall, MetaToolError}; - -/// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a -/// `tools/list_changed` notification to every connected client in the Space. -/// Used by every write tool after a successful mutation. -fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: Uuid) { - let _ = event_tx.send(DomainEvent::FeatureSetMembersChanged { - space_id, - feature_set_id: "meta-tool-write".into(), - added_count: 0, - removed_count: 0, - }); -} - -// NOTE: MetaToolInvoked audit events are emitted centrally by -// MetaToolRegistry::call, so individual tools don't need to fire them. - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn text_result(v: Value) -> CallToolResult { - CallToolResult::success(vec![Content::text(v.to_string())]) -} - -/// Resolve the Space the caller is *actually* routed into — i.e. whichever -/// Space the resolver picks via WorkspaceBinding for this session's reported -/// roots, falling back to the default Space when no binding matches. -/// -/// Every meta tool reads (and writes) inside this Space. That keeps the -/// caller's tool/FS view aligned with the tools the gateway actually exposes -/// to them, and prevents an LLM in workspace A from mutating FSes in -/// workspace B just because both sit under the same default-Space-flagged -/// row in the DB. -async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { - let resolved = call - .ctx - .resolver - .resolve(call.session_id, Some(call.client_id)) - .await?; - if let Some(space_id) = resolved.space_id { - return Ok(space_id); - } - // Resolver returned no space — should only happen in the pathological - // "no default space configured" setup. Fail loudly so callers see why. - Err(MetaToolError::Internal( - "no Space resolved for this caller (no default Space configured?)".into(), - )) -} - -/// Resolve the Space an operation targets: an explicit `space_id` arg when the -/// caller names one (validated to exist), otherwise the caller's resolved -/// Space. This is what lets a client manage ANY Space it can discover via -/// `mcpmux_list_spaces` — writes stay gated by the approval dialog, which names -/// the target Space so cross-Space changes are a conscious user choice. -async fn target_space_id(call: &MetaToolCall<'_>) -> Result { - // When the caller's workspace is scoped to a Space by base directory, that - // Space is authoritative: the meta-tools see ONLY it (no cross-Space - // targeting). An explicit `space_id` that names a different Space is - // rejected; omitting it (or naming the scoped Space) resolves to it. - if let Some(scoped) = call - .ctx - .resolver - .scoped_space_for_session(call.session_id) - .await? - { - if let Some(s) = opt_str_arg(&call.args, "space_id") { - let id = Uuid::parse_str(&s).map_err(|_| { - MetaToolError::InvalidArgument(format!("`space_id` is not a UUID: {s}")) - })?; - if id != scoped { - return Err(MetaToolError::InvalidArgument(format!( - "This workspace is scoped to space '{scoped}' by its base directory; \ - it can't target another space ('{id}')." - ))); - } - } - return Ok(scoped); - } - - match opt_str_arg(&call.args, "space_id") { - Some(s) => { - let id = Uuid::parse_str(&s).map_err(|_| { - MetaToolError::InvalidArgument(format!("`space_id` is not a UUID: {s}")) - })?; - call.ctx.space_repo.get(&id).await?.ok_or_else(|| { - MetaToolError::InvalidArgument(format!("Space '{id}' does not exist")) - })?; - Ok(id) - } - None => caller_space_id(call).await, - } -} - -/// Human-readable Space name for approval summaries; falls back to the id. -async fn space_label(call: &MetaToolCall<'_>, space_id: Uuid) -> String { - match call.ctx.space_repo.get(&space_id).await { - Ok(Some(space)) => space.name, - _ => space_id.to_string(), - } -} - -/// The optional `space_id` input-schema property shared by every meta tool. -/// Omitted ⇒ the tool targets the caller's resolved Space (back-compatible). -fn space_id_schema_prop() -> Value { - json!({ - "type": "string", - "description": "Target Space id (from mcpmux_list_spaces). Omit to use the current workspace's resolved Space." - }) -} - -// --------------------------------------------------------------------------- -// mcpmux_list_all_tools — read -// --------------------------------------------------------------------------- - -pub struct ListAllToolsTool; - -#[async_trait] -impl MetaTool for ListAllToolsTool { - fn name(&self) -> &'static str { - "mcpmux_list_all_tools" - } - - 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. 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}." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { "space_id": space_id_schema_prop() } - }) - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let space_id = target_space_id(&call).await?; - let features = call - .ctx - .server_feature_repo - .list_for_space(&space_id.to_string()) - .await?; - let tools: Vec<_> = features - .iter() - .filter(|f| f.feature_type == FeatureType::Tool) - .map(|f| { - json!({ - "server_id": f.server_id, - "qualified_name": f.qualified_name(), - "description": f.description, - "available": f.is_available, - }) - }) - .collect(); - Ok(text_result(json!({ "space_id": space_id, "tools": tools }))) - } -} - -// --------------------------------------------------------------------------- -// mcpmux_search_tools — read -// --------------------------------------------------------------------------- - -pub struct SearchToolsTool; - -/// Default cap on returned matches — keeps the payload (and the agent's token -/// spend) bounded when a broad query matches many tools. -const SEARCH_TOOLS_DEFAULT_LIMIT: usize = 25; -/// Hard ceiling so a caller can't request an unbounded dump via `limit`. -const SEARCH_TOOLS_MAX_LIMIT: usize = 100; - -#[async_trait] -impl MetaTool for SearchToolsTool { - fn name(&self) -> &'static str { - "mcpmux_search_tools" - } - - fn description(&self) -> &'static str { - "Search the tools installed in a Space by keyword (default: the caller's \ - resolved Space; pass `space_id` to target another), without the current \ - FeatureSet filter applied. Prefer this over `mcpmux_list_all_tools` when \ - you know roughly what you're looking for — it returns only matches, so \ - it's far cheaper than dumping the whole catalog. `query` is matched \ - case-insensitively against each tool's qualified name, description, and \ - server id. Optional `limit` (default 25, max 100). Returns an array of \ - {server_id, qualified_name, description, available}." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "required": ["query"], - "properties": { - "query": { - "type": "string", - "description": "keyword(s) matched against tool name, description, and server id" - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": SEARCH_TOOLS_MAX_LIMIT, - "description": "max matches to return (default 25)" - }, - "space_id": space_id_schema_prop() - } - }) - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let query = opt_str_arg(&call.args, "query").ok_or_else(|| { - MetaToolError::InvalidArgument("search requires a non-empty `query`".into()) - })?; - let needle = query.to_lowercase(); - - // `limit`: clamp to [1, MAX]; fall back to the default when absent or - // not a positive integer. - let limit = call - .args - .get("limit") - .and_then(|v| v.as_u64()) - .map(|n| (n as usize).clamp(1, SEARCH_TOOLS_MAX_LIMIT)) - .unwrap_or(SEARCH_TOOLS_DEFAULT_LIMIT); - - let space_id = target_space_id(&call).await?; - let features = call - .ctx - .server_feature_repo - .list_for_space(&space_id.to_string()) - .await?; - - let mut matches: Vec<&ServerFeature> = features - .iter() - .filter(|f| f.feature_type == FeatureType::Tool) - .filter(|f| { - f.qualified_name().to_lowercase().contains(&needle) - || f.server_id.to_lowercase().contains(&needle) - || f.description - .as_deref() - .map(|d| d.to_lowercase().contains(&needle)) - .unwrap_or(false) - }) - .collect(); - - // Stable, predictable ordering before truncating to `limit`. - matches.sort_by_key(|f| f.qualified_name()); - let total = matches.len(); - let truncated = total > limit; - - let tools: Vec<_> = matches - .into_iter() - .take(limit) - .map(|f| { - json!({ - "server_id": f.server_id, - "qualified_name": f.qualified_name(), - "description": f.description, - "available": f.is_available, - }) - }) - .collect(); - - Ok(text_result(json!({ - "space_id": space_id, - "query": query, - "match_count": total, - "returned": tools.len(), - "truncated": truncated, - "tools": tools, - }))) - } -} - -// --------------------------------------------------------------------------- -// mcpmux_list_spaces — read -// --------------------------------------------------------------------------- - -pub struct ListSpacesTool; - -#[async_trait] -impl MetaTool for ListSpacesTool { - fn name(&self) -> &'static str { - "mcpmux_list_spaces" - } - - fn description(&self) -> &'static str { - "List every Space McpMux knows about. Returns an array of \ - {id, name, is_default, description}. Use a returned `id` as the \ - `space_id` argument to other tools to inspect or configure a specific \ - Space (e.g. compose a FeatureSet in one Space and bind the current \ - workspace to it). The Space marked `is_default` is the fallback when a \ - workspace has no binding." - } - - fn input_schema(&self) -> Value { - json!({ "type": "object", "properties": {} }) - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let mut spaces = call.ctx.space_repo.list().await?; - // When the caller's workspace is scoped to a Space by base directory, - // expose ONLY that Space — self-optimization must not reach across into - // other Spaces' tools. - if let Some(scoped) = call - .ctx - .resolver - .scoped_space_for_session(call.session_id) - .await? - { - spaces.retain(|s| s.id == scoped); - } - let spaces: Vec<_> = spaces - .iter() - .map(|s| { - json!({ - "id": s.id, - "name": s.name, - "is_default": s.is_default, - "description": s.description, - }) - }) - .collect(); - Ok(text_result(json!({ "spaces": spaces }))) - } -} - -// --------------------------------------------------------------------------- -// mcpmux_list_feature_sets — read -// --------------------------------------------------------------------------- - -pub struct ListFeatureSetsTool; - -#[async_trait] -impl MetaTool for ListFeatureSetsTool { - fn name(&self) -> &'static str { - "mcpmux_list_feature_sets" - } - - fn description(&self) -> &'static str { - "List every FeatureSet defined in a Space (default: the caller's resolved \ - Space; pass `space_id` to target another) — built-ins and custom. Each \ - entry carries `id`, `name`, `description`, `type`, and `is_builtin`. Use \ - before composing a new FeatureSet so you don't recreate one that already \ - fits." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { "space_id": space_id_schema_prop() } - }) - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let space_id = target_space_id(&call).await?; - let space = call - .ctx - .space_repo - .get(&space_id) - .await? - .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; - let sets = call - .ctx - .feature_set_repo - .list_by_space(&space_id.to_string()) - .await?; - let sets: Vec<_> = sets - .iter() - .filter(|fs| !fs.is_deleted) - .map(|fs| { - json!({ - "id": fs.id, - "name": fs.name, - "description": fs.description, - "type": fs.feature_set_type, - "is_builtin": fs.is_builtin, - }) - }) - .collect(); - Ok(text_result( - json!({ "space_id": space.id, "feature_sets": sets }), - )) - } -} - -// --------------------------------------------------------------------------- -// Writes — each goes through the ApprovalBroker before mutating state. -// --------------------------------------------------------------------------- - -/// Common path for every write tool: build payload, ask broker, run the -/// mutation. Returns the broker's decision so the caller can proceed only -/// on success. `mutate` is the thing that runs post-approval and is -/// expected to emit `tools/list_changed` when relevant. -#[allow(clippy::too_many_arguments)] -async fn with_approval( - call: &MetaToolCall<'_>, - tool_name: &'static str, - summary: String, - space_name: Option, - diff: Option, - affects_other_clients: bool, - raw_args: Value, - mutate: F, -) -> Result -where - F: FnOnce() -> Fut, - Fut: std::future::Future>, -{ - let payload = ApprovalPayload { - tool_name: tool_name.to_string(), - summary, - space_name, - diff, - raw_args, - affects_other_clients, - }; - call.ctx - .approval_broker - .request_approval(call.client_id, tool_name, payload) - .await?; - mutate().await -} - -fn parse_uuid_arg(args: &Value, field: &str) -> Result { - let s = args - .get(field) - .and_then(|v| v.as_str()) - .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`")))?; - Uuid::parse_str(s) - .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) -} - -/// Trimmed non-empty string arg, or `None` (treats whitespace as absent). -fn opt_str_arg(args: &Value, field: &str) -> Option { - args.get(field) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -/// String-array arg (e.g. a list of qualified tool names); empty when absent. -fn str_array_arg(args: &Value, field: &str) -> Vec { - args.get(field) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default() -} - -/// Resolve qualified tool names to their `ServerFeature`s within a Space. -/// Returns `(matched, unmatched)` so callers can fail with an actionable -/// message instead of silently dropping names the agent got wrong. -async fn resolve_tool_features( - call: &MetaToolCall<'_>, - space_id: Uuid, - names: &[String], -) -> Result<(Vec, Vec), MetaToolError> { - let all = call - .ctx - .server_feature_repo - .list_for_space(&space_id.to_string()) - .await?; - let matched: Vec = all - .into_iter() - .filter(|f| f.feature_type == FeatureType::Tool && names.contains(&f.qualified_name())) - .collect(); - let matched_names: Vec = matched.iter().map(|f| f.qualified_name()).collect(); - let unmatched: Vec = names - .iter() - .filter(|n| !matched_names.contains(n)) - .cloned() - .collect(); - Ok((matched, unmatched)) -} - -/// Guard: a FeatureSet targeted by update/delete must belong to the caller's -/// resolved Space (or be legacy/global with no `space_id`) and must be custom. -/// Built-in sets (the auto-seeded Starter) are not mutable via MCP. -fn ensure_custom_in_space( - fs: &mcpmux_core::FeatureSet, - space_id: Uuid, - fs_id: Uuid, -) -> Result<(), MetaToolError> { - if let Some(fs_space) = fs.space_id.as_deref() { - if fs_space != space_id.to_string() { - return Err(MetaToolError::InvalidArgument(format!( - "FeatureSet '{fs_id}' belongs to a different Space" - ))); - } - } - if fs.is_builtin { - return Err(MetaToolError::InvalidArgument(format!( - "FeatureSet '{fs_id}' is built-in and can't be modified or deleted via MCP" - ))); - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// mcpmux_manage_feature_set — write (create / update / delete a custom FS) -// --------------------------------------------------------------------------- - -pub struct ManageFeatureSetTool; - -impl ManageFeatureSetTool { - async fn create( - &self, - call: &MetaToolCall<'_>, - space_id: Uuid, - ) -> Result { - let name = opt_str_arg(&call.args, "name").ok_or_else(|| { - MetaToolError::InvalidArgument("create requires a non-empty `name`".into()) - })?; - let description = opt_str_arg(&call.args, "description"); - let add = str_array_arg(&call.args, "add"); - if add.is_empty() { - return Err(MetaToolError::InvalidArgument( - "create requires `add` with at least one qualified tool name".into(), - )); - } - let (matched, unmatched) = resolve_tool_features(call, space_id, &add).await?; - if !unmatched.is_empty() { - return Err(MetaToolError::InvalidArgument(format!( - "unknown tool name(s): {}", - unmatched.join(", ") - ))); - } - - let space = space_label(call, space_id).await; - let summary = format!( - "Create FeatureSet '{name}' in Space '{space}' with {} tool(s)", - matched.len() - ); - let diff = json!({ - "added": matched.iter().map(|f| f.qualified_name()).collect::>(), - }); - - let fs_repo = call.ctx.feature_set_repo.clone(); - let event_tx = call.ctx.domain_event_tx.clone(); - let name_c = name.clone(); - with_approval( - call, - "mcpmux_manage_feature_set", - summary, - Some(space), - Some(diff), - false, - call.args.clone(), - || async move { - let mut fs = mcpmux_core::FeatureSet::new_custom(&name_c, space_id.to_string()); - fs.description = description; - fs_repo.create(&fs).await?; - for feature in &matched { - fs_repo - .add_feature_member(&fs.id, &feature.id.to_string(), MemberMode::Include) - .await?; - } - emit_tools_list_changed(&event_tx, space_id); - info!(fs_id = %fs.id, name = %name_c, "[meta_tools] manage_feature_set create applied"); - Ok(text_result(json!({ - "ok": true, - "action": "create", - "feature_set_id": fs.id, - "tool_count": matched.len(), - }))) - }, - ) - .await - } - - async fn update( - &self, - call: &MetaToolCall<'_>, - space_id: Uuid, - ) -> Result { - let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; - let fs = call - .ctx - .feature_set_repo - .get_with_members(&fs_id.to_string()) - .await? - .ok_or_else(|| { - MetaToolError::InvalidArgument(format!("FeatureSet '{fs_id}' does not exist")) - })?; - ensure_custom_in_space(&fs, space_id, fs_id)?; - - let new_name = opt_str_arg(&call.args, "name"); - let new_description = opt_str_arg(&call.args, "description"); - let add = str_array_arg(&call.args, "add"); - let remove = str_array_arg(&call.args, "remove"); - if new_name.is_none() && new_description.is_none() && add.is_empty() && remove.is_empty() { - return Err(MetaToolError::InvalidArgument( - "update requires at least one of `name`, `description`, `add`, `remove`".into(), - )); - } - - let (add_features, add_unmatched) = resolve_tool_features(call, space_id, &add).await?; - if !add_unmatched.is_empty() { - return Err(MetaToolError::InvalidArgument(format!( - "unknown tool name(s) in `add`: {}", - add_unmatched.join(", ") - ))); - } - // Removes that don't resolve to a tool are simply no-ops (the tool - // may already be absent) — don't fail the whole update on them. - let (remove_features, _unmatched_removes) = - resolve_tool_features(call, space_id, &remove).await?; - - let rename_suffix = new_name - .as_deref() - .map(|n| format!(", rename → '{n}'")) - .unwrap_or_default(); - let space = space_label(call, space_id).await; - let summary = format!( - "Update FeatureSet '{}' in Space '{space}': +{} / -{}{}", - fs.name, - add_features.len(), - remove_features.len(), - rename_suffix - ); - let diff = json!({ - "added": add_features.iter().map(|f| f.qualified_name()).collect::>(), - "removed": remove_features.iter().map(|f| f.qualified_name()).collect::>(), - }); - - let fs_repo = call.ctx.feature_set_repo.clone(); - let event_tx = call.ctx.domain_event_tx.clone(); - let fs_id_s = fs_id.to_string(); - with_approval( - call, - "mcpmux_manage_feature_set", - summary, - Some(space), - Some(diff), - true, - call.args.clone(), - || async move { - // Rename / description first — `update` rewrites the row from - // `fs.members` (the set we loaded, unchanged here), so the - // member deltas below still land on top. - if new_name.is_some() || new_description.is_some() { - let mut updated = fs.clone(); - if let Some(n) = new_name { - updated.name = n; - } - if let Some(d) = new_description { - updated.description = Some(d); - } - fs_repo.update(&updated).await?; - } - for feature in &remove_features { - fs_repo - .remove_feature_member(&fs_id_s, &feature.id.to_string()) - .await?; - } - for feature in &add_features { - fs_repo - .add_feature_member(&fs_id_s, &feature.id.to_string(), MemberMode::Include) - .await?; - } - emit_tools_list_changed(&event_tx, space_id); - info!(fs_id = %fs_id_s, "[meta_tools] manage_feature_set update applied"); - Ok(text_result(json!({ - "ok": true, - "action": "update", - "feature_set_id": fs_id, - "added": add_features.len(), - "removed": remove_features.len(), - }))) - }, - ) - .await - } - - async fn delete( - &self, - call: &MetaToolCall<'_>, - space_id: Uuid, - ) -> Result { - let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; - let fs = call - .ctx - .feature_set_repo - .get(&fs_id.to_string()) - .await? - .ok_or_else(|| { - MetaToolError::InvalidArgument(format!("FeatureSet '{fs_id}' does not exist")) - })?; - ensure_custom_in_space(&fs, space_id, fs_id)?; - - let space = space_label(call, space_id).await; - let summary = format!("Delete FeatureSet '{}' in Space '{space}'", fs.name); - let fs_repo = call.ctx.feature_set_repo.clone(); - let event_tx = call.ctx.domain_event_tx.clone(); - let fs_id_s = fs_id.to_string(); - with_approval( - call, - "mcpmux_manage_feature_set", - summary, - Some(space), - None, - true, - call.args.clone(), - || async move { - fs_repo.delete(&fs_id_s).await?; - emit_tools_list_changed(&event_tx, space_id); - info!(fs_id = %fs_id_s, "[meta_tools] manage_feature_set delete applied"); - Ok(text_result(json!({ - "ok": true, - "action": "delete", - "feature_set_id": fs_id, - }))) - }, - ) - .await - } -} - -#[async_trait] -impl MetaTool for ManageFeatureSetTool { - fn name(&self) -> &'static str { - "mcpmux_manage_feature_set" - } - - fn description(&self) -> &'static str { - "Create, update, or delete a custom FeatureSet (a named tool bundle) in a \ - Space (default: the caller's resolved Space; pass `space_id` from \ - `mcpmux_list_spaces` to target another). `action`: 'create' (needs \ - `name` + `add` qualified tool names), 'update' (needs `feature_set_id`; \ - pass any of `name` / `description` / `add` / `remove`), or 'delete' \ - (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`. Prefer a small initial set you \ - expand later over adding everything upfront." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "required": ["action"], - "properties": { - "action": { "type": "string", "enum": ["create", "update", "delete"] }, - "name": { - "type": "string", - "description": "FeatureSet name — required for create, optional rename on update" - }, - "description": { "type": "string" }, - "feature_set_id": { - "type": "string", - "description": "required for update and delete" - }, - "add": { - "type": "array", - "items": { "type": "string" }, - "description": "qualified tool names to add (create uses this as the initial set)" - }, - "remove": { - "type": "array", - "items": { "type": "string" }, - "description": "qualified tool names to remove (update only)" - }, - "space_id": space_id_schema_prop() - } - }) - } - - fn is_write(&self) -> bool { - true - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let action = call - .args - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_lowercase(); - let space_id = target_space_id(&call).await?; - match action.as_str() { - "create" => self.create(&call, space_id).await, - "update" => self.update(&call, space_id).await, - "delete" => self.delete(&call, space_id).await, - "" => Err(MetaToolError::InvalidArgument( - "`action` is required (create | update | delete)".into(), - )), - other => Err(MetaToolError::InvalidArgument(format!( - "unknown action '{other}' (expected create | update | delete)" - ))), - } - } -} - -// --------------------------------------------------------------------------- -// mcpmux_bind_current_workspace — write (persistent, space-wide effect) -// --------------------------------------------------------------------------- - -pub struct BindCurrentWorkspaceTool; - -#[async_trait] -impl MetaTool for BindCurrentWorkspaceTool { - fn name(&self) -> &'static str { - "mcpmux_bind_current_workspace" - } - - fn description(&self) -> &'static str { - "Route the caller's current workspace (its first reported MCP root) to a \ - FeatureSet in a Space — by default the caller's resolved Space, or pass \ - `space_id` (from `mcpmux_list_spaces`) to route this workspace into a \ - different Space entirely. Idempotent: calling it again for the same \ - workspace REBINDS it (no separate unbind). Omit `feature_set_id` to bind \ - the workspace to NO Space tools (built-ins still apply). Matching is \ - exact — only a future connection reporting this EXACT root resolves \ - here, with no subdirectory/ancestor inheritance. Requires user approval \ - and a client that declared MCP roots." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "feature_set_id": { - "type": "string", - "description": "FeatureSet to route this workspace to (must live in the target Space); omit for no Space tools. Re-binding the same workspace replaces the previous mapping." - }, - "space_id": space_id_schema_prop() - } - }) - } - - fn is_write(&self) -> bool { - true - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let space_id = target_space_id(&call).await?; - let roots = call - .session_id - .and_then(|sid| call.ctx.session_roots.get(sid)) - .unwrap_or_default(); - let root = roots.into_iter().next().ok_or_else(|| { - MetaToolError::InvalidArgument( - "caller did not report any MCP roots; cannot bind".into(), - ) - })?; - let normalized = normalize_workspace_root(&root); - - // FeatureSet is optional — omitted/empty means "no Space tools here". - // When given, it MUST exist and belong to the *target* Space (a binding - // whose FS lives in another Space would resolve against the wrong Space - // and silently yield an empty tool set). Legacy/global (no space_id) - // FSes are accepted in any Space. - let (fs_ids, fs_label) = match opt_str_arg(&call.args, "feature_set_id") { - Some(s) => { - let fs_id = Uuid::parse_str(&s).map_err(|_| { - MetaToolError::InvalidArgument(format!("`feature_set_id` is not a UUID: {s}")) - })?; - let fs = call - .ctx - .feature_set_repo - .get(&fs_id.to_string()) - .await? - .ok_or_else(|| { - MetaToolError::InvalidArgument(format!( - "FeatureSet '{fs_id}' does not exist" - )) - })?; - if let Some(fs_space) = fs.space_id.as_deref() { - if fs_space != space_id.to_string() { - return Err(MetaToolError::InvalidArgument(format!( - "FeatureSet '{fs_id}' is not in the target Space — bind it within its own Space, or pass that Space's `space_id`" - ))); - } - } - (vec![fs_id.to_string()], fs.name) - } - None => (Vec::new(), "(no Space tools)".to_string()), - }; - - // Upsert: rebind if a binding for this exact root already exists. - let existing = call - .ctx - .binding_repo - .find_exact_for_roots(std::slice::from_ref(&normalized)) - .await?; - let verb = if existing.is_some() { "Rebind" } else { "Bind" }; - let space = space_label(&call, space_id).await; - let summary = format!( - "{verb} workspace '{normalized}' to FeatureSet '{fs_label}' in Space '{space}'. \ - Affects every future connection that reports this path." - ); - - let binding_repo = call.ctx.binding_repo.clone(); - let event_tx = call.ctx.domain_event_tx.clone(); - with_approval( - &call, - "mcpmux_bind_current_workspace", - summary, - Some(space), - None, - true, - call.args.clone(), - || async move { - let binding_id = match existing { - Some(mut b) => { - b.feature_set_ids = fs_ids.clone(); - b.space_id = space_id; - binding_repo.update(&b).await?; - b.id - } - None => { - let binding = WorkspaceBinding::new_multi( - normalized.clone(), - space_id, - fs_ids.clone(), - ); - binding_repo.create(&binding).await?; - binding.id - } - }; - info!( - %space_id, - workspace_root = %normalized, - feature_set_ids = ?fs_ids, - "[meta_tools] bind_current_workspace applied", - ); - // A binding change isn't a FeatureSet-membership change — emit - // the binding-specific event. It both drives MCPNotifier's - // list_changed push to peers AND is the event the desktop - // Workspaces tab refreshes on (`workspace-binding-changed`). - // Using FeatureSetMembersChanged here left that tab stale. - let _ = event_tx.send(DomainEvent::WorkspaceBindingChanged { - space_id, - workspace_root: normalized.clone(), - }); - Ok(text_result(json!({ - "ok": true, - "binding_id": binding_id, - "workspace_root": normalized, - "feature_set_ids": fs_ids, - }))) - }, - ) - .await - } -} - -// Suppress unused warning — `ApprovalScope` is re-exported for the Tauri -// surface and will land as a command argument once the dialog is wired up. -#[allow(dead_code)] -fn _unused_approval_scope(_: ApprovalScope) {} diff --git a/crates/mcpmux-gateway/src/services/mod.rs b/crates/mcpmux-gateway/src/services/mod.rs index af1edb07..50a1bcd2 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -7,25 +7,40 @@ mod authorization; mod client_metadata_service; +mod discovery_rank; +mod embedding; +mod embedding_warmer; mod event_emitter; mod feature_set_resolver; mod grant_service; pub mod meta_tools; mod notification_emitter; mod prefix_cache; +pub mod prompt_discovery; +pub mod resource_discovery; mod session_roots; mod space_resolver; +pub mod tool_discovery; pub use authorization::AuthorizationService; pub use client_metadata_service::ClientMetadataService; +pub use discovery_rank::levenshtein_suggestions; +pub use embedding::{EmbeddingService, EmbeddingState}; +pub use embedding_warmer::EmbeddingWarmer; pub use event_emitter::EventEmitter; pub use feature_set_resolver::{FeatureSetResolverService, ResolutionSource, ResolvedFeatureSet}; pub use grant_service::GrantService; pub use meta_tools::{ - is_meta_tool, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, - ApprovalRequest, ApprovalScope, MetaToolRegistry, MCPMUX_PREFIX, + is_meta_tool, routing_as_invoke_backend, ApprovalBroker, ApprovalDecision, ApprovalPayload, + ApprovalPublisher, ApprovalRequest, ApprovalScope, InvokeToolBackend, MetaToolRegistry, + ResolutionNotifier, MCPMUX_PREFIX, META_TOOL_APPROVAL_EVENT, META_TOOL_APPROVAL_RESOLVED_EVENT, }; pub use notification_emitter::NotificationEmitter; pub use prefix_cache::PrefixCacheService; +pub use prompt_discovery::{PromptDetailLevel, PromptDiscoveryService, PromptIndexEntry}; +pub use resource_discovery::{ResourceDetailLevel, ResourceDiscoveryService, ResourceIndexEntry}; pub use session_roots::SessionRootsRegistry; pub use space_resolver::SpaceResolverService; +pub use tool_discovery::{ + DetailLevel, SearchContext, ToolDiscoveryService, ToolIndex, ToolIndexEntry, +}; diff --git a/crates/mcpmux-gateway/src/services/prompt_discovery.rs b/crates/mcpmux-gateway/src/services/prompt_discovery.rs new file mode 100644 index 00000000..27a6e098 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/prompt_discovery.rs @@ -0,0 +1,172 @@ +//! In-memory prompt index for meta-gateway search and fetch lookup. + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use mcpmux_core::{FeatureType, ServerFeature, ServerFeatureRepository}; +use serde_json::{json, Value}; + +use super::discovery_rank::filter_and_rank; + +/// How much detail search results include per matched prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptDetailLevel { + Name, + Description, + Full, +} + +impl PromptDetailLevel { + /// Parse a wire-level detail level string. + pub fn parse(s: &str) -> Option { + match s { + "name" => Some(Self::Name), + "description" => Some(Self::Description), + "full" => Some(Self::Full), + _ => None, + } + } +} + +/// One searchable prompt entry in the Space index. +#[derive(Debug, Clone)] +pub struct PromptIndexEntry { + pub server_id: String, + pub feature_name: String, + pub qualified_name: String, + pub description: Option, + pub arguments: Option, + pub is_available: bool, +} + +/// Paginated prompt search output. +#[derive(Debug, Clone)] +pub struct SearchPromptsResult { + pub prompts: Vec, + pub next_cursor: Option, + pub total: usize, +} + +/// Service that builds and queries a prompt index for a Space. +pub struct PromptDiscoveryService { + server_feature_repo: Arc, +} + +impl PromptDiscoveryService { + /// Create a discovery service backed by the Space feature repository. + pub fn new(server_feature_repo: Arc) -> Self { + Self { + server_feature_repo, + } + } + + /// Build an index for `space_id`, retaining only prompts present in `fetchable`. + pub async fn build_index( + &self, + space_id: &str, + fetchable: &[ServerFeature], + ) -> Result> { + let fetchable_keys: HashSet<(String, String)> = fetchable + .iter() + .filter(|f| f.feature_type == FeatureType::Prompt) + .map(|f| (f.server_id.clone(), f.feature_name.clone())) + .collect(); + + let features = self.server_feature_repo.list_for_space(space_id).await?; + let mut index: Vec = features + .into_iter() + .filter(|f| { + f.feature_type == FeatureType::Prompt + && fetchable_keys.contains(&(f.server_id.clone(), f.feature_name.clone())) + }) + .map(|f| PromptIndexEntry { + server_id: f.server_id.clone(), + feature_name: f.feature_name.clone(), + qualified_name: f.qualified_name(), + description: f.description.clone(), + arguments: extract_prompt_arguments(f.raw_json.as_ref()), + is_available: f.is_available, + }) + .collect(); + + index.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + Ok(index) + } + + /// Search the index with optional query, server filter, and pagination. + pub fn search( + index: &[PromptIndexEntry], + query: Option<&str>, + server_id: Option<&str>, + detail_level: PromptDetailLevel, + limit: usize, + cursor: Option<&str>, + ) -> SearchPromptsResult { + let limit = limit.clamp(1, 100); + let offset = cursor.and_then(|c| c.parse::().ok()).unwrap_or(0); + + let filtered = filter_and_rank( + index, + query, + server_id, + |entry| entry.server_id.as_str(), + |entry| { + format!( + "{} {} {}", + entry.qualified_name, + entry.feature_name, + entry.description.as_deref().unwrap_or("") + ) + }, + ); + + let total = filtered.len(); + let page: Vec = filtered + .iter() + .skip(offset) + .take(limit) + .map(|entry| entry_to_json(entry, detail_level)) + .collect(); + + let next_offset = offset + page.len(); + let next_cursor = if next_offset < total { + Some(next_offset.to_string()) + } else { + None + }; + + SearchPromptsResult { + prompts: page, + next_cursor, + total, + } + } +} + +fn extract_prompt_arguments(raw_json: Option<&Value>) -> Option { + raw_json.and_then(|json| json.get("arguments").or_else(|| json.get("args")).cloned()) +} + +fn entry_to_json(entry: &PromptIndexEntry, detail_level: PromptDetailLevel) -> Value { + let mut obj = json!({ + "server_id": entry.server_id, + "qualified_name": entry.qualified_name, + "prompt": entry.feature_name, + "available": entry.is_available, + }); + match detail_level { + PromptDetailLevel::Name => {} + PromptDetailLevel::Description | PromptDetailLevel::Full => { + if let Some(desc) = &entry.description { + obj["description"] = json!(desc); + } + } + } + if detail_level == PromptDetailLevel::Full { + if let Some(args) = &entry.arguments { + obj["arguments"] = args.clone(); + } + } + obj +} diff --git a/crates/mcpmux-gateway/src/services/resource_discovery.rs b/crates/mcpmux-gateway/src/services/resource_discovery.rs new file mode 100644 index 00000000..80f1e301 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/resource_discovery.rs @@ -0,0 +1,183 @@ +//! In-memory resource index for meta-gateway search and read lookup. + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use mcpmux_core::{FeatureType, ServerFeature, ServerFeatureRepository}; +use serde_json::{json, Value}; + +use super::discovery_rank::filter_and_rank; + +/// How much detail search results include per matched resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceDetailLevel { + Name, + Description, + Full, +} + +impl ResourceDetailLevel { + /// Parse a wire-level detail level string. + pub fn parse(s: &str) -> Option { + match s { + "name" => Some(Self::Name), + "description" => Some(Self::Description), + "full" => Some(Self::Full), + _ => None, + } + } +} + +/// One searchable resource entry in the Space index. +#[derive(Debug, Clone)] +pub struct ResourceIndexEntry { + pub server_id: String, + pub uri: String, + pub name: Option, + pub description: Option, + pub mime_type: Option, + pub is_available: bool, +} + +/// Paginated resource search output. +#[derive(Debug, Clone)] +pub struct SearchResourcesResult { + pub resources: Vec, + pub next_cursor: Option, + pub total: usize, +} + +/// Service that builds and queries a resource index for a Space. +pub struct ResourceDiscoveryService { + server_feature_repo: Arc, +} + +impl ResourceDiscoveryService { + /// Create a discovery service backed by the Space feature repository. + pub fn new(server_feature_repo: Arc) -> Self { + Self { + server_feature_repo, + } + } + + /// Build an index for `space_id`, retaining only resources present in `readable`. + pub async fn build_index( + &self, + space_id: &str, + readable: &[ServerFeature], + ) -> Result> { + let readable_keys: HashSet<(String, String)> = readable + .iter() + .filter(|f| f.feature_type == FeatureType::Resource) + .map(|f| (f.server_id.clone(), f.feature_name.clone())) + .collect(); + + let features = self.server_feature_repo.list_for_space(space_id).await?; + let mut index: Vec = features + .into_iter() + .filter(|f| { + f.feature_type == FeatureType::Resource + && readable_keys.contains(&(f.server_id.clone(), f.feature_name.clone())) + }) + .map(|f| ResourceIndexEntry { + server_id: f.server_id.clone(), + uri: f.feature_name.clone(), + name: extract_resource_name(f.raw_json.as_ref()), + description: f.description.clone(), + mime_type: extract_mime_type(f.raw_json.as_ref()), + is_available: f.is_available, + }) + .collect(); + + index.sort_by(|a, b| a.uri.cmp(&b.uri)); + Ok(index) + } + + /// Search the index with optional query, server filter, and pagination. + pub fn search( + index: &[ResourceIndexEntry], + query: Option<&str>, + server_id: Option<&str>, + detail_level: ResourceDetailLevel, + limit: usize, + cursor: Option<&str>, + ) -> SearchResourcesResult { + let limit = limit.clamp(1, 100); + let offset = cursor.and_then(|c| c.parse::().ok()).unwrap_or(0); + + let filtered = filter_and_rank( + index, + query, + server_id, + |entry| entry.server_id.as_str(), + |entry| { + format!( + "{} {} {}", + entry.uri, + entry.name.as_deref().unwrap_or(""), + entry.description.as_deref().unwrap_or("") + ) + }, + ); + + let total = filtered.len(); + let page: Vec = filtered + .iter() + .skip(offset) + .take(limit) + .map(|entry| entry_to_json(entry, detail_level)) + .collect(); + + let next_offset = offset + page.len(); + let next_cursor = if next_offset < total { + Some(next_offset.to_string()) + } else { + None + }; + + SearchResourcesResult { + resources: page, + next_cursor, + total, + } + } +} + +fn extract_resource_name(raw_json: Option<&Value>) -> Option { + raw_json.and_then(|json| json.get("name").and_then(|v| v.as_str()).map(String::from)) +} + +fn extract_mime_type(raw_json: Option<&Value>) -> Option { + raw_json.and_then(|json| { + json.get("mimeType") + .or_else(|| json.get("mime_type")) + .and_then(|v| v.as_str()) + .map(String::from) + }) +} + +fn entry_to_json(entry: &ResourceIndexEntry, detail_level: ResourceDetailLevel) -> Value { + let mut obj = json!({ + "server_id": entry.server_id, + "uri": entry.uri, + "available": entry.is_available, + }); + match detail_level { + ResourceDetailLevel::Name => {} + ResourceDetailLevel::Description | ResourceDetailLevel::Full => { + if let Some(name) = &entry.name { + obj["name"] = json!(name); + } + if let Some(desc) = &entry.description { + obj["description"] = json!(desc); + } + } + } + if detail_level == ResourceDetailLevel::Full { + if let Some(mime) = &entry.mime_type { + obj["mime_type"] = json!(mime); + } + } + obj +} diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index e5a29a50..b3d79359 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -16,6 +16,8 @@ use dashmap::DashMap; use mcpmux_core::normalize_workspace_root; use tracing::debug; +use super::tool_discovery::ToolIndex; + /// Thread-safe registry mapping `mcp-session-id` to the caller's reported /// workspace roots, plus the most recently resolved feature-set id so the /// gateway can tell when a session's resolution flips and emit a per-peer @@ -79,6 +81,10 @@ pub struct SessionRootsRegistry { /// resolver, the on-demand probe skip, and the prompt-root derivation all /// honor the header with no special-casing. Already normalized on insert. pinned: DashMap, + /// Per-session active search index keyed by `(feature_set_ids fingerprint, index)`. + /// Shared with [`MetaToolContext`](crate::services::meta_tools::MetaToolContext) + /// so `mcpmux_search_tools` can reuse a session's resolved tool index. + search_cache: Arc>, } impl SessionRootsRegistry { @@ -91,9 +97,29 @@ impl SessionRootsRegistry { probe_lock: DashMap::new(), first_seen: DashMap::new(), pinned: DashMap::new(), + search_cache: Arc::new(DashMap::new()), }) } + /// Shared per-session `search_tools` active index cache. + pub fn search_cache(&self) -> Arc> { + self.search_cache.clone() + } + + /// Evict cached active indexes for sessions reporting `workspace_root`. + pub fn evict_search_cache_for_workspace_root(&self, workspace_root: &str) { + let normalized = normalize_workspace_root(workspace_root); + let session_ids: Vec = self + .map + .iter() + .filter(|entry| entry.value().iter().any(|root| root == &normalized)) + .map(|entry| entry.key().clone()) + .collect(); + for session_id in session_ids { + self.search_cache.remove(&session_id); + } + } + /// Elapsed time since this session was first observed without roots, /// stamping "now" on the first call. The resolver uses this to bound the /// `PendingRoots` wait: while the result is below the grace window it @@ -225,6 +251,7 @@ impl SessionRootsRegistry { self.probe_lock.remove(session_id); self.first_seen.remove(session_id); self.pinned.remove(session_id); + self.search_cache.remove(session_id); } /// Compare-and-set the session's resolved feature-set id. Returns `true` diff --git a/crates/mcpmux-gateway/src/services/tool_discovery.rs b/crates/mcpmux-gateway/src/services/tool_discovery.rs new file mode 100644 index 00000000..a1bfb963 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/tool_discovery.rs @@ -0,0 +1,38 @@ +//! In-memory tool index for meta-gateway search and schema lookup. +//! +//! Built from Space [`ServerFeature`] rows and filtered to the caller's +//! invokable tool set before search/schema operations run. + +use std::sync::Arc; + +use mcpmux_core::ServerFeatureRepository; + +#[path = "tool_discovery_index.rs"] +mod tool_discovery_index; +#[path = "tool_discovery_search.rs"] +mod tool_discovery_search; +#[path = "tool_discovery_types.rs"] +mod tool_discovery_types; + +pub use tool_discovery_index::entry_content_hash; +pub use tool_discovery_types::{ + DetailLevel, SearchContext, SearchToolsResult, ToolIndex, ToolIndexEntry, +}; + +/// Service that builds and queries a tool index for a Space. +pub struct ToolDiscoveryService { + server_feature_repo: Arc, +} + +impl ToolDiscoveryService { + /// Create a discovery service backed by the Space feature repository. + pub fn new(server_feature_repo: Arc) -> Self { + Self { + server_feature_repo, + } + } +} + +#[cfg(test)] +#[path = "tool_discovery_tests.rs"] +mod tests; diff --git a/crates/mcpmux-gateway/src/services/tool_discovery_index.rs b/crates/mcpmux-gateway/src/services/tool_discovery_index.rs new file mode 100644 index 00000000..fee65aa5 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/tool_discovery_index.rs @@ -0,0 +1,107 @@ +//! Tool index build helpers and embedding content-hash keys. + +use std::collections::HashSet; + +use anyhow::Result; +use mcpmux_core::{FeatureType, ServerFeature}; +use serde_json::Value; + +use crate::pool::InactiveDiscoveryEntry; +use crate::services::embedding::EmbeddingService; + +use super::tool_discovery_types::ToolIndexEntry; +use super::ToolDiscoveryService; + +/// Extract MCP `inputSchema` from a cached tool JSON blob. +fn extract_input_schema(raw_json: Option<&Value>) -> Option { + raw_json.and_then(|json| { + json.get("inputSchema") + .or_else(|| json.get("input_schema")) + .cloned() + }) +} + +/// Stable alias-free content hash for embedding vectors. +pub fn entry_content_hash(entry: &ToolIndexEntry) -> String { + EmbeddingService::content_hash(&entry.feature_name, entry.description.as_deref()) +} + +impl ToolDiscoveryService { + /// Build an index of every tool installed in `space_id` (ignores FeatureSet ACL). + pub async fn build_catalog_index(&self, space_id: &str) -> Result> { + let features = self.server_feature_repo.list_for_space(space_id).await?; + let mut index: Vec = features + .into_iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| ToolIndexEntry { + server_id: f.server_id.clone(), + feature_name: f.feature_name.clone(), + qualified_name: f.qualified_name(), + description: f.description.clone(), + input_schema: extract_input_schema(f.raw_json.as_ref()), + is_available: f.is_available, + status: None, + bindable_feature_set_id: None, + }) + .collect(); + index.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + Ok(index) + } + + /// Build an index for `space_id`, retaining only tools present in `invokable`. + pub async fn build_index( + &self, + space_id: &str, + invokable: &[ServerFeature], + ) -> Result> { + let invokable_keys: HashSet<(String, String)> = invokable + .iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| (f.server_id.clone(), f.feature_name.clone())) + .collect(); + + let features = self.server_feature_repo.list_for_space(space_id).await?; + let mut index: Vec = features + .into_iter() + .filter(|f| { + f.feature_type == FeatureType::Tool + && invokable_keys.contains(&(f.server_id.clone(), f.feature_name.clone())) + }) + .map(|f| ToolIndexEntry { + server_id: f.server_id.clone(), + feature_name: f.feature_name.clone(), + qualified_name: f.qualified_name(), + description: f.description.clone(), + input_schema: extract_input_schema(f.raw_json.as_ref()), + is_available: f.is_available, + status: None, + bindable_feature_set_id: None, + }) + .collect(); + + index.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + Ok(index) + } + + /// Build index entries for tools that exist in a FeatureSet but are not invokable yet. + pub fn build_inactive_index(entries: &[InactiveDiscoveryEntry]) -> Vec { + let mut index: Vec = entries + .iter() + .map(|entry| { + let f = &entry.feature; + ToolIndexEntry { + server_id: f.server_id.clone(), + feature_name: f.feature_name.clone(), + qualified_name: f.qualified_name(), + description: f.description.clone(), + input_schema: extract_input_schema(f.raw_json.as_ref()), + is_available: f.is_available, + status: Some("inactive".to_string()), + bindable_feature_set_id: Some(entry.bindable_feature_set_id.clone()), + } + }) + .collect(); + index.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + index + } +} diff --git a/crates/mcpmux-gateway/src/services/tool_discovery_search.rs b/crates/mcpmux-gateway/src/services/tool_discovery_search.rs new file mode 100644 index 00000000..c44e2f2d --- /dev/null +++ b/crates/mcpmux-gateway/src/services/tool_discovery_search.rs @@ -0,0 +1,624 @@ +//! Hybrid and lexical search execution for the active tool index. + +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use serde_json::{json, Map, Value}; +use tracing::{debug, info, trace}; + +use crate::services::discovery_rank::{ + build_corpus_doc_freq, filter_and_rank, filter_and_rank_traced, lexical_score_precomputed, + prepare_query_tokens, tokenize, RankTraceContext, +}; +use crate::services::embedding::{EmbeddingService, EmbeddingState}; + +use super::tool_discovery_index::entry_content_hash; +use super::tool_discovery_types::{DetailLevel, SearchContext, SearchToolsResult, ToolIndexEntry}; +use super::ToolDiscoveryService; + +/// Lexical weight for hybrid score fusion. +/// +/// Tuned against the 20-case intent→tool relevance fixture in +/// `tests/rust/tests/integration/search_relevance_eval.rs` (Phase 4). At 0.4/0.6 +/// hybrid passes all fixture cases in top-3 while lexical-only passes ~11/20; +/// lowering lexical (e.g. 0.3) risks exact-name queries losing to semantic noise, +/// raising it (e.g. 0.5) drops intent-only queries with zero token overlap. +const LEXICAL_FUSION_WEIGHT: f32 = 0.4; + +/// Semantic weight for hybrid score fusion (complement of [`LEXICAL_FUSION_WEIGHT`]). +const SEMANTIC_FUSION_WEIGHT: f32 = 0.6; + +impl ToolDiscoveryService { + /// Search the index with optional query, server filter, and pagination. + #[allow(clippy::too_many_arguments)] + pub fn search( + index: &[ToolIndexEntry], + query: Option<&str>, + server_id: Option<&str>, + detail_level: DetailLevel, + limit: usize, + cursor: Option<&str>, + query_id: Option<&str>, + hybrid: Option>, + server_readiness: Option<&HashMap>, + server_display_names: Option<&HashMap>, + prefilled_params_by_server: Option<&HashMap>>, + include_invoke_example: bool, + ) -> SearchToolsResult { + let limit = limit.clamp(1, 100); + let offset = cursor.and_then(|c| c.parse::().ok()).unwrap_or(0); + + let haystack_fn = |entry: &ToolIndexEntry| entry_search_haystack(entry); + + let lexical_started = Instant::now(); + let (mut ranked, top_lexical_score) = if let Some(query_id) = query_id { + let trace = RankTraceContext { query_id }; + filter_and_rank_traced( + index, + query, + server_id, + |entry| entry.server_id.as_str(), + haystack_fn, + &trace, + ) + } else { + ( + filter_and_rank( + index, + query, + server_id, + |entry| entry.server_id.as_str(), + haystack_fn, + ), + None, + ) + }; + let lexical_ms = lexical_started.elapsed().as_millis() as u64; + + let hybrid_started = Instant::now(); + let (ranking, top_fused_score) = + if let (Some(query), Some(query_id), Some(ctx)) = (query, query_id, hybrid) { + rank_with_hybrid( + &mut ranked, + query, + query_id, + ctx, + haystack_fn, + top_lexical_score, + ) + } else { + ("lexical", top_lexical_score) + }; + let hybrid_ms = hybrid_started.elapsed().as_millis() as u64; + + let total = ranked.len(); + + let paginate_started = Instant::now(); + let page: Vec = ranked + .iter() + .skip(offset) + .take(limit) + .map(|entry| { + let readiness = server_readiness + .map(|map| map.get(&entry.server_id).copied().unwrap_or("bindable")); + let display_name = server_display_names.and_then(|map| map.get(&entry.server_id)); + let prefilled_keys = prefilled_params_by_server + .and_then(|map| map.get(&entry.server_id)) + .map(Vec::as_slice); + entry_to_json( + entry, + detail_level, + readiness, + display_name.map(String::as_str), + prefilled_keys, + include_invoke_example, + ) + }) + .collect(); + let paginate_ms = paginate_started.elapsed().as_millis() as u64; + + if let Some(query_id) = query_id { + debug!( + query_id, + index_entries = index.len(), + ranked_count = total, + lexical_ms, + hybrid_ms, + paginate_ms, + rank_total_ms = lexical_ms + hybrid_ms + paginate_ms, + "[search] rank phase" + ); + } + + let next_offset = offset + page.len(); + let next_cursor = if next_offset < total { + Some(next_offset.to_string()) + } else { + None + }; + + SearchToolsResult { + tools: page, + next_cursor, + total, + ranking, + top_fused_score, + } + } + + /// Resolve schemas for one or more qualified tool names. + pub fn get_schemas( + index: &[ToolIndexEntry], + tool_names: &[String], + compact: bool, + ) -> Vec { + tool_names + .iter() + .filter_map(|name| { + let entry = index + .iter() + .find(|e| e.qualified_name == *name || e.feature_name == *name)?; + Some(schema_entry_to_json(entry, compact)) + }) + .collect() + } +} + +/// Haystack text for lexical and semantic ranking (`feature_name + qualified_name + description`). +fn entry_search_haystack(entry: &ToolIndexEntry) -> String { + format!( + "{} {} {}", + entry.feature_name, + entry.qualified_name, + entry.description.as_deref().unwrap_or("") + ) +} + +/// Apply hybrid fusion when the embedding model is ready; otherwise lexical-only. +fn rank_with_hybrid<'a, T, FHaystack>( + ranked: &mut Vec<&'a T>, + query: &str, + query_id: &str, + ctx: SearchContext<'_>, + haystack_fn: FHaystack, + top_lexical_score: Option, +) -> (&'static str, Option) +where + T: AsRef + 'a, + FHaystack: Fn(&T) -> String, +{ + let model_state = ctx.embeddings.state(); + let model_ready = matches!(model_state, EmbeddingState::Ready); + if !model_ready { + ctx.embeddings.ensure_init_started(); + } + + if !model_ready || ranked.is_empty() { + let skip_reason = if !model_ready { + "model_not_ready" + } else { + "empty_ranked" + }; + log_cache_decision( + query_id, + ctx.index_cache_hit, + "skipped", + Some(skip_reason), + Some(&model_state), + ctx.active_index.len(), + ranked.len(), + ); + return ("lexical", top_lexical_score); + } + + let vectors_started = Instant::now(); + let vectors_present = ctx + .active_index + .iter() + .filter(|entry| { + let content_hash = entry_content_hash(entry); + ctx.embedding_store.contains_key(&content_hash) + }) + .count(); + let vectors_scan_ms = vectors_started.elapsed().as_millis() as u64; + let lexical_only_docs = ctx.active_index.len().saturating_sub(vectors_present); + debug!( + query_id, + active_tools = ctx.active_index.len(), + vectors_present, + lexical_only_docs, + vectors_scan_ms, + "[search] read" + ); + + let active_keys: HashSet<&str> = ctx + .active_index + .iter() + .map(|e| e.qualified_name.as_str()) + .collect(); + + log_cache_decision( + query_id, + ctx.index_cache_hit, + if vectors_present > 0 { "hit" } else { "miss" }, + None, + None, + ctx.active_index.len(), + ranked.len(), + ); + + let inline_embed_started = Instant::now(); + let Some(query_vector) = ctx.embeddings.embed_query(query, Some(query_id)) else { + debug!( + query_id, + model_state = ?ctx.embeddings.state(), + embed_ms = inline_embed_started.elapsed().as_millis() as u64, + skip_reason = "query_embed_failed", + "[search] hybrid abort" + ); + return ("lexical", top_lexical_score); + }; + info!( + target: "embed", + query_id, + docs_embedded = 1, + embed_ms = inline_embed_started.elapsed().as_millis() as u64, + "[embed] inline query embed" + ); + + // Precompute corpus statistics and per-doc tokens once. The public + // `lexical_score` helper rebuilt the corpus doc-frequency map on every + // call, making this loop O(N^2) in tokenization; building the stats a + // single time keeps it O(N) (matches the lexical pass in discovery_rank). + let corpus_started = Instant::now(); + let haystacks: Vec = ranked.iter().map(|entry| haystack_fn(entry)).collect(); + let (corpus_size, corpus_doc_freq) = build_corpus_doc_freq(&haystacks); + let query_tokens = prepare_query_tokens(query); + let corpus_ms = corpus_started.elapsed().as_millis() as u64; + + let lexical_scores_started = Instant::now(); + let lexical_scores: Vec = haystacks + .iter() + .map(|haystack| { + let doc_tokens = tokenize(haystack); + lexical_score_precomputed(&query_tokens, &doc_tokens, corpus_size, &corpus_doc_freq) + }) + .collect(); + let lexical_scores_ms = lexical_scores_started.elapsed().as_millis() as u64; + + let max_lexical = lexical_scores + .iter() + .copied() + .fold(0.0_f64, f64::max) + .max(1e-9); + + let fusion_started = Instant::now(); + let mut fused_scores: Vec = Vec::with_capacity(ranked.len()); + for (idx, entry) in ranked.iter().enumerate() { + let tool_entry = entry.as_ref(); + let norm_lexical = (lexical_scores[idx] / max_lexical) as f32; + let maybe_doc_vector = if active_keys.contains(tool_entry.qualified_name.as_str()) { + let content_hash = entry_content_hash(tool_entry); + ctx.embedding_store.get(&content_hash) + } else { + None + }; + let semantic = maybe_doc_vector + .as_ref() + .map(|doc_vector| EmbeddingService::cosine(&query_vector, doc_vector.value())) + .unwrap_or(0.0); + let has_vector = maybe_doc_vector.is_some(); + let fused = if active_keys.contains(tool_entry.qualified_name.as_str()) && has_vector { + (LEXICAL_FUSION_WEIGHT * norm_lexical + SEMANTIC_FUSION_WEIGHT * semantic) as f64 + } else { + lexical_scores[idx] + }; + trace!( + query_id, + qualified_name = %tool_entry.qualified_name, + lexical_score = lexical_scores[idx], + semantic_score = semantic, + fused_score = fused, + "[search] entry score" + ); + fused_scores.push(fused); + } + let fusion_ms = fusion_started.elapsed().as_millis() as u64; + + let sort_started = Instant::now(); + let mut scored: Vec<(&T, f64)> = ranked.drain(..).zip(fused_scores).collect(); + scored.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| haystack_fn(a.0).cmp(&haystack_fn(b.0))) + }); + let top_fused_score = scored.first().map(|(_, score)| *score); + *ranked = scored.into_iter().map(|(entry, _)| entry).collect(); + let sort_ms = sort_started.elapsed().as_millis() as u64; + + if vectors_present == 0 { + debug!( + query_id, + ranked_count = ranked.len(), + corpus_ms, + lexical_scores_ms, + fusion_ms, + sort_ms, + skip_reason = "vectors_present_zero", + "[search] hybrid abort" + ); + return ("lexical", top_lexical_score); + } + + debug!( + query_id, + ranking = "hybrid", + ranked_count = ranked.len(), + corpus_ms, + lexical_scores_ms, + fusion_ms, + sort_ms, + hybrid_compute_ms = corpus_ms + lexical_scores_ms + fusion_ms + sort_ms, + lexical_weight = LEXICAL_FUSION_WEIGHT, + semantic_weight = SEMANTIC_FUSION_WEIGHT, + "[search] fusion" + ); + + ("hybrid", top_fused_score) +} + +fn log_cache_decision( + query_id: &str, + index_cache_hit: bool, + embedding_store: &str, + skip_reason: Option<&str>, + model_state: Option<&EmbeddingState>, + active_tools: usize, + ranked_count: usize, +) { + let model_state_label = model_state.map(|s| match s { + EmbeddingState::NotDownloaded => "not_downloaded", + EmbeddingState::Downloading => "downloading", + EmbeddingState::Ready => "ready", + EmbeddingState::Failed { .. } => "failed", + }); + debug!( + query_id, + index_cache = if index_cache_hit { "hit" } else { "miss" }, + embedding_store, + skip_reason, + model_state = model_state_label, + active_tools, + ranked_count, + "[search] cache decision" + ); +} + +/// JSON Schema `type` for one property (string or first element of a type array). +fn schema_property_type(prop: &Value) -> String { + match prop.get("type") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(arr)) => arr + .first() + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + _ => "unknown".to_string(), + } +} + +/// Max optional params inlined in search hits (token budget guard). +const OPTIONAL_PARAM_CAP: usize = 8; + +/// Required parameter name + type for search results (minimal schema-lite). +fn extract_required_param_specs(input_schema: Option<&Value>) -> Vec { + let Some(schema) = input_schema else { + return Vec::new(); + }; + let Some(required) = schema.get("required").and_then(|r| r.as_array()) else { + return Vec::new(); + }; + let properties = schema.get("properties").and_then(|p| p.as_object()); + + required + .iter() + .filter_map(|v| v.as_str()) + .map(|name| { + let param_type = properties + .and_then(|props| props.get(name)) + .map(schema_property_type) + .unwrap_or_else(|| "unknown".to_string()); + json!({ "name": name, "type": param_type }) + }) + .collect() +} + +/// Optional parameter name + type for search results (minimal schema-lite, capped). +fn extract_optional_param_specs(input_schema: Option<&Value>) -> Vec { + let Some(schema) = input_schema else { + return Vec::new(); + }; + let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else { + return Vec::new(); + }; + let required: HashSet<&str> = schema + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let mut optional: Vec<(String, &Value)> = properties + .iter() + .filter(|(name, _)| !required.contains(name.as_str())) + .map(|(name, prop)| (name.clone(), prop)) + .collect(); + optional.sort_by(|a, b| a.0.cmp(&b.0)); + + optional + .into_iter() + .take(OPTIONAL_PARAM_CAP) + .map(|(name, prop)| { + let param_type = schema_property_type(prop); + json!({ "name": name, "type": param_type }) + }) + .collect() +} + +/// Whether a property schema exceeds shallow type resolution (oneOf, $ref, nested object, …). +fn schema_property_is_complex(prop: &Value) -> bool { + if prop.get("oneOf").is_some() || prop.get("anyOf").is_some() || prop.get("$ref").is_some() { + return true; + } + match prop.get("type") { + Some(Value::String(t)) if t == "object" => prop.get("properties").is_some(), + Some(Value::Array(types)) => types + .iter() + .any(|t| t.as_str() == Some("object") && prop.get("properties").is_some()), + _ => false, + } +} + +/// Whether the input schema needs a full read via get_tool_schema. +fn input_schema_is_complex(input_schema: Option<&Value>) -> bool { + let Some(schema) = input_schema else { + return false; + }; + if schema.get("oneOf").is_some() + || schema.get("anyOf").is_some() + || schema.get("$ref").is_some() + { + return true; + } + let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else { + return false; + }; + properties + .values() + .any(|prop| schema_property_type(prop) == "unknown" || schema_property_is_complex(prop)) +} + +/// Placeholder value for one required param in a copy-paste `invoke_example`. +fn param_invoke_placeholder(param_type: &str) -> String { + format!("<{param_type}>") +} + +/// Copy-paste-ready `mcpmux_invoke_tool` shape for browse hits. +fn build_invoke_example(entry: &ToolIndexEntry, required_params: &[Value]) -> Value { + let mut args = Map::new(); + for param in required_params { + let Some(name) = param.get("name").and_then(|v| v.as_str()) else { + continue; + }; + let param_type = param + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("string"); + args.insert( + name.to_string(), + json!(param_invoke_placeholder(param_type)), + ); + } + json!({ + "server_id": entry.server_id, + "tool": entry.feature_name, + "args": Value::Object(args), + }) +} + +fn entry_to_json( + entry: &ToolIndexEntry, + detail_level: DetailLevel, + server_readiness: Option<&str>, + server_display_name: Option<&str>, + prefilled_keys: Option<&[String]>, + include_invoke_example: bool, +) -> Value { + let required_params = extract_required_param_specs(entry.input_schema.as_ref()); + let optional_params = extract_optional_param_specs(entry.input_schema.as_ref()); + let schema_complex = input_schema_is_complex(entry.input_schema.as_ref()); + let required_params = mark_prefilled_required_params(&required_params, prefilled_keys); + let mut obj = json!({ + "server_id": entry.server_id, + "qualified_name": entry.qualified_name, + "bare_name": entry.feature_name, + "available": entry.is_available, + "required_params": required_params, + "optional_params": optional_params, + "schema_complex": schema_complex, + }); + if let Some(display_name) = server_display_name { + obj["display_name"] = json!(display_name); + } + if include_invoke_example { + obj["invoke_example"] = build_invoke_example(entry, &required_params); + } + if let Some(readiness) = server_readiness { + obj["server_readiness"] = json!(readiness); + } + if let Some(status) = &entry.status { + obj["status"] = json!(status); + } + if let Some(fs_id) = &entry.bindable_feature_set_id { + obj["bindable_feature_set_id"] = json!(fs_id); + } + match detail_level { + DetailLevel::Name => {} + DetailLevel::Description | DetailLevel::Schema => { + if let Some(desc) = &entry.description { + obj["description"] = json!(desc); + } + } + } + if detail_level == DetailLevel::Schema { + if let Some(schema) = &entry.input_schema { + obj["input_schema"] = schema.clone(); + } + } + obj +} + +/// Annotate required params that are auto-filled from server `default_params`. +fn mark_prefilled_required_params( + required_params: &[Value], + prefilled_keys: Option<&[String]>, +) -> Vec { + let Some(prefilled_keys) = prefilled_keys else { + return required_params.to_vec(); + }; + if prefilled_keys.is_empty() { + return required_params.to_vec(); + } + + required_params + .iter() + .map(|param| { + let name = param.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if prefilled_keys.iter().any(|key| key == name) { + let mut marked = param.clone(); + marked["prefilled"] = json!(true); + marked + } else { + param.clone() + } + }) + .collect() +} + +fn schema_entry_to_json(entry: &ToolIndexEntry, compact: bool) -> Value { + let mut obj = json!({ + "qualified_name": entry.qualified_name, + "server_id": entry.server_id, + "feature_name": entry.feature_name, + }); + if !compact { + if let Some(desc) = &entry.description { + obj["description"] = json!(desc); + } + } + if let Some(schema) = &entry.input_schema { + obj["input_schema"] = schema.clone(); + } else { + obj["input_schema"] = json!({"type": "object", "properties": {}}); + } + obj +} diff --git a/crates/mcpmux-gateway/src/services/tool_discovery_tests.rs b/crates/mcpmux-gateway/src/services/tool_discovery_tests.rs new file mode 100644 index 00000000..3692c175 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/tool_discovery_tests.rs @@ -0,0 +1,28 @@ +use super::{entry_content_hash, ToolIndexEntry}; + +fn test_entry(qualified_name: &str, description: &str) -> ToolIndexEntry { + ToolIndexEntry { + server_id: "server-a".to_string(), + feature_name: "search_issues".to_string(), + qualified_name: qualified_name.to_string(), + description: Some(description.to_string()), + input_schema: None, + is_available: true, + status: None, + bindable_feature_set_id: None, + } +} + +#[test] +fn alias_change_leaves_content_hash_unchanged() { + let before = test_entry("jira_search_issues", "Find Jira issues"); + let after = test_entry("atlassian_search_issues", "Find Jira issues"); + assert_eq!(entry_content_hash(&before), entry_content_hash(&after)); +} + +#[test] +fn description_change_changes_content_hash() { + let before = test_entry("jira_search_issues", "Find Jira issues"); + let after = test_entry("jira_search_issues", "Find open Jira issues"); + assert_ne!(entry_content_hash(&before), entry_content_hash(&after)); +} diff --git a/crates/mcpmux-gateway/src/services/tool_discovery_types.rs b/crates/mcpmux-gateway/src/services/tool_discovery_types.rs new file mode 100644 index 00000000..ed39cbe8 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/tool_discovery_types.rs @@ -0,0 +1,70 @@ +//! Shared types for tool index build and hybrid search. + +use dashmap::DashMap; +use serde_json::Value; + +use crate::services::embedding::EmbeddingService; + +/// How much detail search results include per matched tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailLevel { + Name, + Description, + Schema, +} + +impl DetailLevel { + /// Parse a wire-level detail level string. + pub fn parse(s: &str) -> Option { + match s { + "name" => Some(Self::Name), + "description" => Some(Self::Description), + "schema" => Some(Self::Schema), + _ => None, + } + } +} + +/// In-memory active tool index for a resolved binding (search cache value). +pub type ToolIndex = Vec; + +/// Per-binding hybrid search inputs (global embedding store + active corpus). +pub struct SearchContext<'a> { + pub embeddings: &'a EmbeddingService, + pub embedding_store: &'a DashMap>, + /// Active-only index used as the semantic embedding corpus. + pub active_index: &'a [ToolIndexEntry], + pub index_cache_hit: bool, +} + +/// One searchable tool entry in the Space index. +#[derive(Debug, Clone)] +pub struct ToolIndexEntry { + pub server_id: String, + pub feature_name: String, + pub qualified_name: String, + pub description: Option, + pub input_schema: Option, + pub is_available: bool, + /// `inactive` when matched via `include_inactive` discovery widening. + pub status: Option, + pub bindable_feature_set_id: Option, +} + +/// Paginated search output. +#[derive(Debug, Clone)] +pub struct SearchToolsResult { + pub tools: Vec, + pub next_cursor: Option, + pub total: usize, + /// Ranking mode used for this result set (`hybrid` or `lexical`). + pub ranking: &'static str, + /// Fused or lexical score of the top-ranked match when a query was provided. + pub top_fused_score: Option, +} + +impl AsRef for ToolIndexEntry { + fn as_ref(&self) -> &ToolIndexEntry { + self + } +} diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml index d77b31ae..6397c0b2 100644 --- a/tests/rust/Cargo.toml +++ b/tests/rust/Cargo.toml @@ -8,7 +8,7 @@ description = "Integration tests for McpMux" [dependencies] # Internal crates mcpmux-core = { path = "../../crates/mcpmux-core" } -mcpmux-gateway = { path = "../../crates/mcpmux-gateway" } +mcpmux-gateway = { path = "../../crates/mcpmux-gateway", features = ["test-utils"] } mcpmux-storage = { path = "../../crates/mcpmux-storage" } mcpmux-mcp = { path = "../../crates/mcpmux-mcp" } diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 68042407..b775aa7a 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -11,50 +11,174 @@ use std::time::Duration; use futures::FutureExt; use mcpmux_core::{ - normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetRepository, - InboundMcpClientRepository, ServerFeature, ServerFeatureRepository, SpaceRepository, + normalize_workspace_root, Client, DomainEvent, EmbeddingRecord, EmbeddingRepository, + FeatureSet, FeatureSetMember, FeatureSetRepository, InboundMcpClientRepository, + InputDefinition, InstalledServer, InstalledServerRepository, LogConfig, MemberMode, MemberType, + ServerDefinition, ServerFeature, ServerFeatureRepository, ServerLogManager, ServerSource, + SpaceRepository, TransportConfig, TransportMetadata, WorkspaceBinding, WorkspaceBindingRepository, }; -use mcpmux_core::{SpaceBuiltinConfigRepository, TOOL_OPTIMIZATION_SERVER_ID}; -use mcpmux_gateway::pool::FeatureService; +use mcpmux_gateway::pool::{ + CachedFeatures, ConnectionService, FeatureService, OutboundOAuthManager, ServerKey, + ServerManager, TokenService, +}; use mcpmux_gateway::services::{ meta_tools, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, - FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionRootsRegistry, + EmbeddingWarmer, FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, + SessionRootsRegistry, META_TOOL_APPROVAL_EVENT, }; +use mcpmux_gateway::MCPNotifier; use mcpmux_storage::{ - Database, InboundClientRepository, SqliteFeatureSetRepository, - SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, - SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + generate_master_key, Database, FieldEncryptor, InboundClientRepository, + SqliteEmbeddingRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, + SqliteInstalledServerRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, + SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use serde_json::{json, Value}; +use tests::mocks::{MockCredentialRepository, MockOutboundOAuthRepository}; use tokio::sync::{broadcast, Mutex}; use uuid::Uuid; -struct Fixture { - registry: Arc, +pub(crate) struct Fixture { + pub(crate) registry: Arc, broker: Arc, #[allow(dead_code)] client_repo: Arc, - space_repo: Arc, - feature_set_repo: Arc, - binding_repo: Arc, - server_feature_repo: Arc, - session_roots: Arc, - /// Domain-event sender the registry writes to; tests subscribe to assert - /// the events the desktop UI / MCPNotifier react to are actually emitted. - event_tx: broadcast::Sender, - space_id: Uuid, + pub(crate) feature_set_repo: Arc, + pub(crate) server_feature_repo: Arc, + pub(crate) binding_repo: Arc, + installed_server_repo: Arc, + pub(crate) session_roots: Arc, + feature_service: Arc, + pub(crate) space_id: Uuid, /// Opaque client identity (UUID-as-string here; in production for DCR /// clients this can be a `client_metadata` URL). - client_id: String, - session_id: String, + pub(crate) client_id: String, + pub(crate) session_id: String, fs_android_id: Uuid, + github_tool_id: Uuid, + event_rx: broadcast::Receiver, + server_manager: Arc, +} + +fn test_encryptor() -> Arc { + let key = generate_master_key().expect("generate key"); + Arc::new(FieldEncryptor::new(&key).expect("create encryptor")) +} + +fn test_log_manager() -> Arc { + let base_dir = std::env::temp_dir().join(format!("mcpmux-meta-tools-logs-{}", Uuid::new_v4())); + Arc::new(ServerLogManager::new(LogConfig { + base_dir, + max_file_size: 1024 * 1024, + max_files: 5, + compress: false, + })) +} + +fn test_server_manager( + event_tx: broadcast::Sender, + feature_service: Arc, + prefix_cache: Arc, +) -> Arc { + let credential_repo = Arc::new(MockCredentialRepository::new()); + let oauth_repo = Arc::new(MockOutboundOAuthRepository::new()); + let token_service = Arc::new(TokenService::new( + credential_repo.clone(), + oauth_repo.clone(), + )); + let oauth_manager = Arc::new(OutboundOAuthManager::new()); + let connection_service = Arc::new(ConnectionService::new( + token_service, + oauth_manager, + credential_repo, + oauth_repo, + prefix_cache.clone(), + )); + Arc::new(ServerManager::new( + event_tx, + feature_service, + connection_service, + prefix_cache, + )) +} + +fn stdio_definition_with_required_input(server_id: &str, input_id: &str) -> ServerDefinition { + ServerDefinition { + id: server_id.to_string(), + name: server_id.to_string(), + description: None, + alias: None, + auth: None, + icon: None, + transport: TransportConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "pkg".to_string()], + env: Default::default(), + metadata: TransportMetadata { + inputs: vec![InputDefinition { + id: input_id.to_string(), + label: input_id.to_string(), + r#type: "text".to_string(), + required: true, + secret: true, + description: None, + default: None, + placeholder: None, + obtain_url: None, + obtain_instructions: None, + }], + }, + }, + categories: vec![], + publisher: None, + source: ServerSource::Bundled, + badges: vec![], + hosting_type: Default::default(), + license: None, + license_url: None, + installation: None, + capabilities: None, + sponsored: None, + media: None, + changelog_url: None, + } +} + +async fn seed_diagnose_servers(f: &Fixture) { + let space_id = f.space_id.to_string(); + + let github_def = stdio_definition_with_required_input("github", "github_token"); + let github = InstalledServer::new(&space_id, "github") + .with_definition(&github_def) + .with_input("github_token", "secret"); + f.installed_server_repo.install(&github).await.unwrap(); + f.server_manager + .set_connected( + &ServerKey::new(f.space_id, "github"), + CachedFeatures::default(), + ) + .await; + + let firebase_def = stdio_definition_with_required_input("firebase", "api_key"); + let firebase = InstalledServer::new(&space_id, "firebase") + .with_definition(&firebase_def) + .with_input("api_key", "key"); + f.installed_server_repo.install(&firebase).await.unwrap(); + f.server_manager + .set_error( + &ServerKey::new(f.space_id, "firebase"), + "Connection refused".to_string(), + ) + .await; } impl Fixture { - async fn new() -> Self { - let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + pub(crate) async fn new() -> Self { + Self::new_with_db(Arc::new(Mutex::new(Database::open_in_memory().unwrap()))).await + } + pub(crate) async fn new_with_db(db: Arc>) -> Self { let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); let feature_set_repo: Arc = Arc::new(SqliteFeatureSetRepository::new(db.clone())); @@ -64,6 +188,9 @@ impl Fixture { Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; @@ -88,6 +215,7 @@ impl Fixture { feature2.description = Some("Deploy to Firebase".into()); server_feature_repo.upsert(&feature1).await.unwrap(); server_feature_repo.upsert(&feature2).await.unwrap(); + let github_tool_id = feature1.id; // The space's auto-seeded Default FS is the resolver's baseline // when no binding matches — no "set active FS" step needed. @@ -115,15 +243,16 @@ impl Fixture { let feature_service = Arc::new(FeatureService::new( server_feature_repo.clone(), feature_set_repo.clone(), - prefix_cache, + prefix_cache.clone(), )); let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); - let (tx, _rx) = broadcast::channel::(32); - let event_tx = tx.clone(); - - let builtin_config_repo: Arc = - Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone())); + let (tx, event_rx) = broadcast::channel::(32); + let log_manager = test_log_manager(); + let server_manager = + test_server_manager(tx.clone(), feature_service.clone(), prefix_cache.clone()); + let embedding_repo: Arc = + Arc::new(SqliteEmbeddingRepository::new(db.clone())); let registry = meta_tools::build_default_registry( client_repo.clone(), @@ -131,38 +260,41 @@ impl Fixture { feature_set_repo.clone(), binding_repo.clone(), server_feature_repo.clone(), + installed_server_repo.clone(), resolver, - feature_service, + feature_service.clone(), + None, + None, session_roots.clone(), broker.clone(), tx, None, - Some(builtin_config_repo), + server_manager.clone(), + log_manager, + std::env::temp_dir().join(format!("mcpmux-meta-tools-{}", Uuid::new_v4())), + embedding_repo, ); Self { registry, broker, client_repo, - space_repo, feature_set_repo, - binding_repo, server_feature_repo, + binding_repo, + installed_server_repo, session_roots, - event_tx, + feature_service, space_id, client_id, session_id, fs_android_id, + github_tool_id, + event_rx, + server_manager, } } - /// Subscribe to the registry's domain-event stream. Subscribe BEFORE the - /// call under test — broadcast only delivers messages sent after subscribe. - fn subscribe(&self) -> broadcast::Receiver { - self.event_tx.subscribe() - } - /// Attach a publisher that always auto-approves with the given decision. fn attach_auto_publisher(&self, decision: ApprovalDecision) { let broker = self.broker.clone(); @@ -193,7 +325,27 @@ impl Fixture { }); } - fn result_json(result: &rmcp::model::CallToolResult) -> Value { + /// Attach a publisher that fans approval requests into the admin SSE bus. + fn attach_sse_publisher(&self, ui_bus: Arc) { + let publisher: ApprovalPublisher = Arc::new(move |req| { + let bus = ui_bus.clone(); + async move { + if let Ok(payload) = serde_json::to_value(&req) { + bus.publish(META_TOOL_APPROVAL_EVENT, payload); + } + true + } + .boxed() + }); + let b = self.broker.clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + b.set_publisher(publisher).await; + }); + }); + } + + pub(crate) fn result_json(result: &rmcp::model::CallToolResult) -> Value { // CallToolResult's Content is opaque; round-trip through JSON and // pluck out the first text payload. let raw = serde_json::to_value(result).unwrap(); @@ -234,28 +386,44 @@ impl Fixture { // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread")] -async fn list_all_tools_returns_unfiltered_across_servers() { +async fn list_all_tools_not_in_agent_registry() { + let f = Fixture::new().await; + let names: Vec<_> = f + .registry + .list_as_tools() + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert!( + !names.iter().any(|n| n == "mcpmux_list_all_tools"), + "catalog firehose removed from agent surface: {names:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_feature_sets_returns_space_contents() { let f = Fixture::new().await; let result = f .registry .call( - "mcpmux_list_all_tools", + "mcpmux_list_feature_sets", &f.client_id, Some(&f.session_id), json!({}), ) .await .unwrap(); - assert!(!Fixture::is_error(&result)); let body = Fixture::result_json(&result); - let tools = body.get("tools").unwrap().as_array().unwrap(); - // Both seeded tools show up regardless of FS. - assert_eq!(tools.len(), 2); + let sets = body.get("feature_sets").unwrap().as_array().unwrap(); + // Seed created 2 custom FSes + the auto-seeded Default. + assert_eq!(sets.len(), 3, "Default + 2 custom expected"); } #[tokio::test(flavor = "multi_thread")] -async fn list_feature_sets_returns_space_contents() { +async fn list_feature_sets_marks_bound_vs_inactive() { let f = Fixture::new().await; + let fs_id = bind_github_only_to_session_root(&f).await; + let result = f .registry .call( @@ -268,715 +436,1440 @@ async fn list_feature_sets_returns_space_contents() { .unwrap(); let body = Fixture::result_json(&result); let sets = body.get("feature_sets").unwrap().as_array().unwrap(); - // Seed created 2 custom FSes + the auto-seeded Default. - assert_eq!(sets.len(), 3, "Default + 2 custom expected"); -} - -#[tokio::test(flavor = "multi_thread")] -async fn search_tools_matches_name_server_and_description() { - let f = Fixture::new().await; - - // By qualified name / server id. - let by_name = Fixture::result_json( - &f.registry - .call( - "mcpmux_search_tools", - &f.client_id, - Some(&f.session_id), - json!({ "query": "github" }), - ) - .await - .unwrap(), - ); - assert_eq!(by_name.get("match_count").unwrap().as_u64().unwrap(), 1); - let tools = by_name.get("tools").unwrap().as_array().unwrap(); - assert_eq!( - tools[0].get("qualified_name").unwrap().as_str().unwrap(), - "github_create_issue" - ); - - // By description text ("Deploy to Firebase"). - let by_desc = Fixture::result_json( - &f.registry - .call( - "mcpmux_search_tools", - &f.client_id, - Some(&f.session_id), - json!({ "query": "deploy" }), - ) - .await - .unwrap(), - ); - let desc_tools = by_desc.get("tools").unwrap().as_array().unwrap(); - assert_eq!(desc_tools.len(), 1); - assert_eq!( - desc_tools[0] - .get("qualified_name") - .unwrap() - .as_str() - .unwrap(), - "firebase_deploy" - ); - - // No match → empty, not an error. - let none = Fixture::result_json( - &f.registry - .call( - "mcpmux_search_tools", - &f.client_id, - Some(&f.session_id), - json!({ "query": "zzzznotathing" }), - ) - .await - .unwrap(), - ); - assert_eq!(none.get("match_count").unwrap().as_u64().unwrap(), 0); - assert!(none.get("tools").unwrap().as_array().unwrap().is_empty()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn search_tools_requires_query() { - let f = Fixture::new().await; - let res = f - .call_tool_as_handler_would("mcpmux_search_tools", json!({})) - .await; - assert!(Fixture::is_error(&res)); - assert_eq!( - Fixture::result_json(&res) - .get("error") - .unwrap() - .as_str() - .unwrap(), - "invalid_argument" - ); + let github_fs = sets + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some(fs_id.as_str())) + .unwrap(); + assert_eq!(github_fs.get("status"), Some(&json!("active"))); + let android = sets + .iter() + .find(|s| s.get("name").and_then(|v| v.as_str()) == Some("Android Dev")) + .unwrap(); + assert_eq!(android.get("status"), Some(&json!("inactive"))); } #[tokio::test(flavor = "multi_thread")] -async fn search_tools_caps_results_at_limit_and_flags_truncation() { +async fn search_default_empty_suggests_widen_or_bind() { let f = Fixture::new().await; - // "e" appears in both seeded tools' names/descriptions → 2 matches. - let body = Fixture::result_json( - &f.registry - .call( - "mcpmux_search_tools", - &f.client_id, - Some(&f.session_id), - json!({ "query": "e", "limit": 1 }), - ) - .await - .unwrap(), - ); - assert_eq!(body.get("match_count").unwrap().as_u64().unwrap(), 2); - assert_eq!(body.get("returned").unwrap().as_u64().unwrap(), 1); - assert!(body.get("truncated").unwrap().as_bool().unwrap()); - assert_eq!(body.get("tools").unwrap().as_array().unwrap().len(), 1); -} - -// `describe_resolution` and `describe_workspace` were both removed at the -// user's request — the read surface is now `list_all_tools`, `search_tools`, -// and `list_feature_sets`. Behavior previously asserted here is covered by -// `FeatureSetResolverService`'s own tests in -// `tests/rust/tests/integration/feature_set_resolver.rs`. - -// --------------------------------------------------------------------------- -// Writes — gated by ApprovalBroker -// --------------------------------------------------------------------------- + let _fs_id = github_only_fs(&f).await; -#[tokio::test(flavor = "multi_thread")] -async fn write_without_publisher_returns_approval_required() { - let f = Fixture::new().await; - let input = if cfg!(windows) { - "D:\\Projects\\Approval\\" - } else { - "/proj/approval" - }; - f.session_roots.set(&f.session_id, [input]); let result = f - .call_tool_as_handler_would( - "mcpmux_bind_current_workspace", - json!({ "feature_set_id": f.fs_android_id.to_string() }), + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), ) - .await; - assert!(Fixture::is_error(&result)); + .await + .unwrap(); let body = Fixture::result_json(&result); - assert_eq!( - body.get("error").unwrap().as_str().unwrap(), - "approval_required" - ); + assert_eq!(body.get("total"), Some(&json!(0))); + assert_eq!(body.get("scope"), Some(&json!("active_only"))); + let hint = body.get("hint").and_then(|v| v.as_str()).unwrap_or(""); + assert!(hint.contains("mcpmux_list_servers")); + assert!(hint.contains("include_inactive")); + assert!(body.get("inactive_preview").is_none()); } #[tokio::test(flavor = "multi_thread")] -async fn write_rejected_on_deny_leaves_state_unchanged() { +async fn search_zero_result_surfaces_ready_inactive_preview() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::Deny); + let inactive_fs_id = bind_ready_github_with_inactive_create_issue(&f).await; - let before_bindings = f.binding_repo.list().await.unwrap().len(); - - let input = if cfg!(windows) { - "D:\\Projects\\Denied\\" - } else { - "/proj/denied" - }; - f.session_roots.set(&f.session_id, [input]); let result = f - .call_tool_as_handler_would( - "mcpmux_bind_current_workspace", - json!({ "feature_set_id": f.fs_android_id.to_string() }), + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "create issue" }), ) - .await; - assert!(Fixture::is_error(&result)); + .await + .unwrap(); let body = Fixture::result_json(&result); + assert_eq!(body.get("total"), Some(&json!(0))); + assert_eq!(body.get("scope"), Some(&json!("active_only"))); + + let preview = body + .get("inactive_preview") + .and_then(|v| v.as_array()) + .expect("expected inactive_preview for ready-but-inactive tools"); + assert!(!preview.is_empty()); + assert!(preview.len() <= 3); + let tool = preview + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))) + .expect("expected inactive github_create_issue in preview"); + assert_eq!(tool.get("status"), Some(&json!("inactive"))); assert_eq!( - body.get("error").unwrap().as_str().unwrap(), - "approval_denied" + tool.get("bindable_feature_set_id"), + Some(&json!(inactive_fs_id)) ); + assert_eq!(tool.get("server_readiness"), Some(&json!("ready"))); - let after_bindings = f.binding_repo.list().await.unwrap().len(); - assert_eq!(after_bindings, before_bindings); + let hint = body.get("hint").and_then(|v| v.as_str()).unwrap_or(""); + assert!(hint.contains("inactive_preview")); + assert!(hint.contains("mcpmux_bind_current_workspace")); } #[tokio::test(flavor = "multi_thread")] -async fn manage_feature_set_create_persists_members_on_approval() { +async fn search_zero_result_generic_hint_when_no_ready_inactive() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); + let _fs_id = github_only_fs(&f).await; let result = f .registry .call( - "mcpmux_manage_feature_set", + "mcpmux_search_tools", &f.client_id, Some(&f.session_id), - json!({ - "action": "create", - "name": "Tiny Set", - "add": ["github_create_issue"], - }), + json!({ "query": "create issue" }), ) .await .unwrap(); - assert!(!Fixture::is_error(&result)); - let body = Fixture::result_json(&result); - let new_fs_id = body.get("feature_set_id").unwrap().as_str().unwrap(); - - let fs = f - .feature_set_repo - .get_with_members(new_fs_id) - .await - .unwrap() - .unwrap(); - assert_eq!(fs.name, "Tiny Set"); - assert_eq!(fs.members.len(), 1); + assert_eq!(body.get("total"), Some(&json!(0))); + assert!(body.get("inactive_preview").is_none()); + let hint = body.get("hint").and_then(|v| v.as_str()).unwrap_or(""); + assert!(hint.contains("mcpmux_list_servers")); + assert!(hint.contains("include_inactive")); } -/// create → update (add + remove + rename) → delete, all on approval. #[tokio::test(flavor = "multi_thread")] -async fn manage_feature_set_update_and_delete() { +async fn search_tools_first_meta_call_resolves_bound_workspace() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); + let root = "/tmp/mcpmux-root-race-first-search"; + let fs_id = github_only_fs(&f).await; - // create with one tool - let created = Fixture::result_json( - &f.registry - .call( - "mcpmux_manage_feature_set", - &f.client_id, - Some(&f.session_id), - json!({ "action": "create", "name": "Set A", "add": ["github_create_issue"] }), - ) - .await - .unwrap(), - ); - let fs_id = created - .get("feature_set_id") - .unwrap() - .as_str() - .unwrap() - .to_string(); + f.session_roots.set_roots_capable(&f.session_id, true); + let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id.clone()); + f.binding_repo.create(&binding).await.unwrap(); + // Outcome of ensure_roots_probed before meta-tool dispatch (no prior tools/list). + f.session_roots.set(&f.session_id, [root]); - // update: add the firebase tool, remove the github tool, rename - let res = f - .call_tool_as_handler_would( - "mcpmux_manage_feature_set", - json!({ - "action": "update", - "feature_set_id": fs_id, - "name": "Set B", - "add": ["firebase_deploy"], - "remove": ["github_create_issue"], - }), + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), ) - .await; - assert!(!Fixture::is_error(&res), "update should succeed: {res:?}"); - let fs = f - .feature_set_repo - .get_with_members(&fs_id) .await - .unwrap() .unwrap(); - assert_eq!(fs.name, "Set B", "renamed"); - let member_ids: Vec = fs.members.iter().map(|m| m.member_id.clone()).collect(); - assert_eq!(member_ids.len(), 1, "github removed, firebase added"); - - // delete - let res = f - .call_tool_as_handler_would( - "mcpmux_manage_feature_set", - json!({ "action": "delete", "feature_set_id": fs_id }), - ) - .await; - assert!(!Fixture::is_error(&res), "delete should succeed: {res:?}"); - let after = f.feature_set_repo.get(&fs_id).await.unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(body.get("scope"), Some(&json!("active_only"))); assert!( - after.map(|fs| fs.is_deleted).unwrap_or(true), - "FS should be soft-deleted" + body.get("total").and_then(|v| v.as_u64()).unwrap_or(0) >= 1, + "bound workspace should surface active tools on first search: {body}" + ); + let tools = body.get("tools").unwrap().as_array().unwrap(); + assert!( + tools + .iter() + .any(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))), + "expected bound github tool in first search_tools result: {tools:?}" ); } -/// Built-in (Starter) FeatureSets are not mutable via MCP. #[tokio::test(flavor = "multi_thread")] -async fn manage_feature_set_rejects_builtin() { +async fn search_surfaces_display_name_and_prefilled_required_params() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); - - // Ensure the auto-seeded Starter exists, then try to delete it. - f.feature_set_repo - .ensure_builtin_for_space(&f.space_id.to_string()) - .await - .unwrap(); - let starter = f - .feature_set_repo - .get_starter_for_space(&f.space_id.to_string()) + bind_github_only_to_session_root(&f).await; + + let mut github = InstalledServer::new(&f.space_id.to_string(), "github") + .with_definition(&stdio_definition_with_required_input( + "github", + "github_token", + )) + .with_input("github_token", "secret") + .with_display_name_override(Some("Jira - S2H")); + github + .default_params + .insert("cloudId".to_string(), json!("site-uuid")); + f.installed_server_repo.install(&github).await.unwrap(); + + let mut create_issue = f + .server_feature_repo + .list_for_space(&f.space_id.to_string()) .await .unwrap() - .expect("starter exists"); + .into_iter() + .find(|feature| feature.feature_name == "create_issue") + .expect("github create_issue feature"); + create_issue.raw_json = Some(json!({ + "name": "create_issue", + "inputSchema": { + "type": "object", + "properties": { + "cloudId": { "type": "string" }, + "title": { "type": "string" } + }, + "required": ["cloudId", "title"] + } + })); + f.server_feature_repo.upsert(&create_issue).await.unwrap(); - let res = f - .call_tool_as_handler_would( - "mcpmux_manage_feature_set", - json!({ "action": "delete", "feature_set_id": starter.id }), + f.server_manager + .set_connected( + &ServerKey::new(f.space_id, "github"), + CachedFeatures::default(), ) .await; - assert!(Fixture::is_error(&res)); - let body = Fixture::result_json(&res); - assert_eq!( - body.get("error").unwrap().as_str().unwrap(), - "invalid_argument" - ); -} -/// Unknown action is rejected with an actionable error. -#[tokio::test(flavor = "multi_thread")] -async fn manage_feature_set_unknown_action_rejected() { - let f = Fixture::new().await; - let res = f - .call_tool_as_handler_would( - "mcpmux_manage_feature_set", - json!({ "action": "frobnicate" }), + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "create issue" }), ) - .await; - assert!(Fixture::is_error(&res)); - assert_eq!( - Fixture::result_json(&res) - .get("error") - .unwrap() - .as_str() - .unwrap(), - "invalid_argument" - ); + .await + .unwrap(); + let body = Fixture::result_json(&result); + let tool = body + .get("tools") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))) + .expect("github create_issue in search results"); + assert_eq!(tool.get("display_name"), Some(&json!("Jira - S2H"))); + let cloud_id = tool + .get("required_params") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|param| param.get("name") == Some(&json!("cloudId"))) + .expect("cloudId required param"); + assert_eq!(cloud_id.get("prefilled"), Some(&json!(true))); } #[tokio::test(flavor = "multi_thread")] -async fn bind_current_workspace_fails_when_no_roots_reported() { +async fn search_tools_pending_roots_returns_empty_active_only() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); - // NOTE: session_roots intentionally NOT populated. + let root = "/tmp/mcpmux-root-race-pending"; + let fs_id = github_only_fs(&f).await; + + f.session_roots.set_roots_capable(&f.session_id, true); + let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id); + f.binding_repo.create(&binding).await.unwrap(); + // Binding exists but roots not probed yet — PendingRoots → empty grants. let result = f - .call_tool_as_handler_would( - "mcpmux_bind_current_workspace", - json!({ "feature_set_id": f.fs_android_id.to_string() }), + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), ) - .await; - assert!(Fixture::is_error(&result)); + .await + .unwrap(); let body = Fixture::result_json(&result); - assert_eq!( - body.get("error").unwrap().as_str().unwrap(), - "invalid_argument" - ); + assert_eq!(body.get("total"), Some(&json!(0))); + assert_eq!(body.get("scope"), Some(&json!("active_only"))); } #[tokio::test(flavor = "multi_thread")] -async fn bind_current_workspace_creates_binding_with_normalized_root() { +async fn search_include_inactive_surfaces_bindable_github() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); - let input = if cfg!(windows) { - "D:\\Projects\\Android\\MyApp\\" - } else { - "/home/me/projects/android/myapp/" - }; - f.session_roots.set(&f.session_id, [input]); + let fs_id = github_only_fs(&f).await; + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue", "include_inactive": true }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(body.get("scope"), Some(&json!("active_and_inactive"))); + assert!(body.get("total").and_then(|v| v.as_u64()).unwrap_or(0) >= 1); + let tool = body + .get("tools") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))) + .expect("inactive github tool in results"); + assert_eq!(tool.get("status"), Some(&json!("inactive"))); + assert_eq!(tool.get("bindable_feature_set_id"), Some(&json!(fs_id))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_include_inactive_no_bundle_suggests_author_in_mux() { + let f = Fixture::new().await; + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "deploy", "include_inactive": true }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(body.get("total"), Some(&json!(0))); + let hint = body.get("hint").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + hint.contains("create a bundle") || hint.contains("Feature Sets"), + "expected author-bundle hint, got: {hint}" + ); +} + +/// Seed a PostHog-scale bundle (`tool_count` tools on one server) for inactive-scan perf tests. +async fn seed_large_inactive_bundle(f: &Fixture, server_id: &str, tool_count: usize) -> String { + let space_id = f.space_id.to_string(); + let features: Vec = (0..tool_count) + .map(|i| ServerFeature::tool(&space_id, server_id, format!("capture_event_{i}"))) + .collect(); + f.server_feature_repo.upsert_many(&features).await.unwrap(); + + let mut fs = FeatureSet::new_custom("PostHog clone", space_id.clone()); + for feature in &features { + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: feature.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + } + let fs_id = fs.id.clone(); + f.feature_set_repo.create(&fs).await.unwrap(); + fs_id +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_include_inactive_large_bundle_completes_under_two_seconds() { + let f = Fixture::new().await; + let tool_count = 450; + seed_large_inactive_bundle(&f, "posthog", tool_count).await; + + let start = std::time::Instant::now(); + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "include_inactive": true, "limit": 100 }), + ) + .await + .unwrap(); + let elapsed = start.elapsed(); + + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!(body.get("scope"), Some(&json!("active_and_inactive"))); + assert!( + body.get("total").and_then(|v| v.as_u64()).unwrap_or(0) >= tool_count as u64, + "expected at least {tool_count} inactive tools" + ); + assert!( + elapsed < Duration::from_secs(2), + "inactive scan took {elapsed:?}, expected < 2s" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_include_inactive_large_set_suggests_server_id_filter() { + let f = Fixture::new().await; + seed_large_inactive_bundle(&f, "analytics", 51).await; + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "include_inactive": true, "limit": 10 }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let hint = body.get("hint").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + hint.contains("server_id"), + "expected server_id filter hint, got: {hint}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_tools_second_call_hits_active_index_cache() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + let args = json!({ "query": "issue" }); + let result1 = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + args.clone(), + ) + .await + .unwrap(); + let body1 = Fixture::result_json(&result1); + assert!( + body1.get("total").and_then(|v| v.as_u64()).unwrap_or(0) >= 1, + "first search should return active tools: {body1}" + ); + assert!(f.registry.search_cache_contains(&f.session_id)); + + f.server_feature_repo + .delete(&f.github_tool_id) + .await + .unwrap(); + + let result2 = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + args, + ) + .await + .unwrap(); + let body2 = Fixture::result_json(&result2); + assert!( + body2.get("total").and_then(|v| v.as_u64()).unwrap_or(0) >= 1, + "cache hit should return cached tools despite DB deletion: {body2}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_tools_cache_evicted_on_workspace_binding_changed() { + let f = Fixture::new().await; + let root = "/tmp/mcpmux-list-servers-test"; + bind_github_only_to_session_root(&f).await; + + f.registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), + ) + .await + .unwrap(); + assert!(f.registry.search_cache_contains(&f.session_id)); + + f.session_roots.evict_search_cache_for_workspace_root(root); + + assert!(!f.registry.search_cache_contains(&f.session_id)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_tools_cache_evicted_on_session_disconnect() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + f.registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), + ) + .await + .unwrap(); + assert!(f.registry.search_cache_contains(&f.session_id)); + + f.session_roots.remove(&f.session_id); + + assert!(!f.registry.search_cache_contains(&f.session_id)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_tools_ranking_lexical_when_model_absent() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "create issue" }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("ranking").and_then(|v| v.as_str()), + Some("lexical"), + "without a ready embedding model search must label itself lexical: {body}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_tools_reuses_global_embeddings_across_sessions_without_reembedding_docs() { + let f = Fixture::new().await; + let root = "/tmp/mcpmux-list-servers-test"; + let _ = bind_github_only_to_session_root(&f).await; + let content_hash = mcpmux_gateway::services::EmbeddingService::content_hash( + "create_issue", + Some("Create an issue"), + ); + f.registry + .context() + .embedding_repo + .upsert_many(&[EmbeddingRecord { + content_hash: content_hash.clone(), + model_version: f.registry.context().embeddings.model_version().to_string(), + vector: vec![1.0, 0.0, 0.0], + }]) + .await + .unwrap(); + f.registry.context().embeddings.install_test_vectors( + [("query: issue".to_string(), vec![1.0, 0.0, 0.0])] + .into_iter() + .collect(), + ); + + f.registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), + ) + .await + .unwrap(); + assert_eq!( + f.registry.context().embedding_store.len(), + 1, + "first session should hydrate one shared vector" + ); + + let second_session_id = "sess-meta-reuse-2"; + f.session_roots.set_roots_capable(second_session_id, true); + f.session_roots.set(second_session_id, [root]); + f.registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(second_session_id), + json!({ "query": "issue" }), + ) + .await + .unwrap(); + assert_eq!( + f.registry.context().embedding_store.len(), + 1, + "second session should reuse the shared vector" + ); + assert!( + f.registry + .context() + .embedding_store + .contains_key(&content_hash), + "global embedding store should keep the content hash" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_tools_reuses_persisted_embeddings_after_registry_restart() { + let shared_db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let seeded = Fixture::new_with_db(shared_db.clone()).await; + let _ = bind_github_only_to_session_root(&seeded).await; + let content_hash = mcpmux_gateway::services::EmbeddingService::content_hash( + "create_issue", + Some("Create an issue"), + ); + seeded + .registry + .context() + .embedding_repo + .upsert_many(&[EmbeddingRecord { + content_hash: content_hash.clone(), + model_version: seeded + .registry + .context() + .embeddings + .model_version() + .to_string(), + vector: vec![1.0, 0.0, 0.0], + }]) + .await + .unwrap(); + + let restarted = Fixture::new_with_db(shared_db).await; + let root = "/tmp/mcpmux-list-servers-test"; + restarted + .session_roots + .set_roots_capable(&restarted.session_id, true); + restarted.session_roots.set(&restarted.session_id, [root]); + restarted + .registry + .context() + .embeddings + .install_test_vectors( + [("query: issue".to_string(), vec![1.0, 0.0, 0.0])] + .into_iter() + .collect(), + ); + assert_eq!( + restarted.registry.context().embedding_store.len(), + 0, + "new registry starts with an empty in-memory embedding store" + ); + + let result = restarted + .registry + .call( + "mcpmux_search_tools", + &restarted.client_id, + Some(&restarted.session_id), + json!({ "query": "issue" }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("ranking").and_then(|value| value.as_str()), + Some("hybrid"), + "persisted vectors should be rehydrated after restart: {body}" + ); + assert!( + restarted + .registry + .context() + .embedding_store + .contains_key(&content_hash), + "restart should hydrate persisted vectors by content hash" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn connect_event_warms_server_catalog_embeddings_before_search() { + let f = Fixture::new().await; + let _ = bind_github_only_to_session_root(&f).await; + let content_hash = mcpmux_gateway::services::EmbeddingService::content_hash( + "create_issue", + Some("Create an issue"), + ); + f.registry.context().embeddings.install_test_vectors( + [ + ( + "passage: create_issue Create an issue".to_string(), + vec![1.0, 0.0, 0.0], + ), + ("query: issue".to_string(), vec![1.0, 0.0, 0.0]), + ] + .into_iter() + .collect(), + ); + + let warmer = Arc::new(EmbeddingWarmer::new( + f.server_feature_repo.clone(), + f.registry.context().embedding_repo.clone(), + f.registry.context().embedding_store.clone(), + f.registry.context().embeddings.clone(), + )); + let notifier = Arc::new(MCPNotifier::new( + f.registry.context().resolver.clone(), + f.feature_service.clone(), + )); + notifier.set_embedding_warmer(warmer); + notifier.clone().start(f.event_rx.resubscribe()); + + let server_key = ServerKey::new(f.space_id, "github"); + f.server_manager + .set_connected(&server_key, CachedFeatures::default()) + .await; + + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while !f + .registry + .context() + .embedding_store + .contains_key(&content_hash) + && std::time::Instant::now() < deadline + { + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!( + f.registry + .context() + .embedding_store + .contains_key(&content_hash), + "expected connect warmer to populate in-memory vector map" + ); + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue" }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("ranking").and_then(|value| value.as_str()), + Some("hybrid"), + "search should find pre-warmed vectors after server connect: {body}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bind_does_not_promote_tools_into_advertised_list() { + let f = Fixture::new().await; + let meta_count = f.registry.list_as_tools().len(); + let fs_id = bind_github_only_to_session_root(&f).await; + + let advertised = f + .feature_service + .get_advertised_tools_for_grants(&f.space_id.to_string(), &[fs_id]) + .await + .unwrap(); + assert!( + advertised.is_empty(), + "binding must not surface backend tools into tools/list" + ); + assert_eq!(f.registry.list_as_tools().len(), meta_count); +} + +fn server_readiness(body: &Value, server_id: &str) -> String { + body.get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some(server_id)) + .unwrap() + .get("readiness") + .unwrap() + .as_str() + .unwrap() + .to_string() +} + +async fn github_only_fs(f: &Fixture) -> String { + let mut fs = FeatureSet::new_custom("GitHub only", f.space_id.to_string()); + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: f.github_tool_id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + let id = fs.id.clone(); + f.feature_set_repo.create(&fs).await.unwrap(); + id +} + +async fn bind_github_only_to_session_root(f: &Fixture) -> String { + use mcpmux_core::WorkspaceBinding; + + let fs_id = github_only_fs(f).await; + let root = "/tmp/mcpmux-list-servers-test"; + f.session_roots.set_roots_capable(&f.session_id, true); + f.session_roots.set(&f.session_id, [root]); + let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id.clone()); + f.binding_repo.create(&binding).await.unwrap(); + fs_id +} + +/// Bind a workspace to a FeatureSet that includes only `list_repos`, leaving +/// `create_issue` in an unbound FeatureSet so the server is ready but the issue +/// tool stays inactive. +async fn bind_ready_github_with_inactive_create_issue(f: &Fixture) -> String { + use mcpmux_core::WorkspaceBinding; + + let mut list_repos = ServerFeature::tool(f.space_id, "github", "list_repos"); + list_repos.description = Some("List repositories".into()); + f.server_feature_repo.upsert(&list_repos).await.unwrap(); + + let mut bound_fs = FeatureSet::new_custom("GitHub bound slice", f.space_id.to_string()); + bound_fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: bound_fs.id.clone(), + member_type: MemberType::Feature, + member_id: list_repos.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + let bound_fs_id = bound_fs.id.clone(); + f.feature_set_repo.create(&bound_fs).await.unwrap(); + let inactive_fs_id = github_only_fs(f).await; + + seed_diagnose_servers(f).await; + + let root = "/tmp/mcpmux-ready-inactive-preview"; + f.session_roots.set_roots_capable(&f.session_id, true); + f.session_roots.set(&f.session_id, [root]); + let binding = WorkspaceBinding::new( + normalize_workspace_root(root), + f.space_id, + bound_fs_id.clone(), + ); + f.binding_repo.create(&binding).await.unwrap(); + inactive_fs_id +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_includes_prefilled_params_when_default_params_set() { + let f = Fixture::new().await; + let mut github = InstalledServer::new(&f.space_id.to_string(), "github").with_definition( + &stdio_definition_with_required_input("github", "github_token"), + ); + github + .default_params + .insert("cloudId".to_string(), json!("site-uuid")); + f.installed_server_repo.install(&github).await.unwrap(); + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let github_entry = body + .get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id") == Some(&json!("github"))) + .expect("github server in list"); + assert_eq!( + github_entry.get("prefilled_params"), + Some(&json!(["cloudId"])) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_marks_unbound_servers_bindable() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let servers = body.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(server_readiness(&body, "github"), "bindable"); + assert_eq!(server_readiness(&body, "firebase"), "bindable"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_inactive_includes_bindable_feature_set_ids() { + let f = Fixture::new().await; + let fs_id = github_only_fs(&f).await; + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let github = body + .get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some("github")) + .unwrap(); + assert_eq!(github.get("readiness"), Some(&json!("bindable"))); + let bindable = github + .get("bindable_feature_set_ids") + .unwrap() + .as_array() + .unwrap(); + assert!(bindable.iter().any(|v| v.as_str() == Some(fs_id.as_str()))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_bound_server_reports_bound_when_disconnected() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(server_readiness(&body, "github"), "bound"); + assert_eq!( + body.get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id") == Some(&json!("github"))) + .unwrap() + .get("blocking_reason"), + Some(&json!("disconnected")) + ); + assert_eq!(server_readiness(&body, "firebase"), "bindable"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_includes_cloned_from_for_clone_installs() { + let f = Fixture::new().await; + let space_id = f.space_id.to_string(); + + let posthog = InstalledServer::new(&space_id, "posthog"); + f.installed_server_repo.install(&posthog).await.unwrap(); + let posthog_work = InstalledServer::new(&space_id, "posthog-work").with_cloned_from("posthog"); + f.installed_server_repo + .install(&posthog_work) + .await + .unwrap(); + + let mut clone_tool = ServerFeature::tool(f.space_id, "posthog-work", "capture"); + clone_tool.display_name = Some("PostHog (work)".into()); + f.registry + .context() + .server_feature_repo + .upsert(&clone_tool) + .await + .unwrap(); + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let clone_entry = body + .get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some("posthog-work")) + .expect("clone server in manifest"); + assert_eq!( + clone_entry.get("cloned_from").and_then(|v| v.as_str()), + Some("posthog") + ); + + let github_entry = body + .get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some("github")) + .expect("github in manifest"); + assert!(github_entry.get("cloned_from").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_shows_installed_server_with_no_tool_features() { + let f = Fixture::new().await; + let space_id = f.space_id.to_string(); + + // Install a server that has a required input but no server_feature rows + // (simulates a freshly installed server whose tool catalog has not been + // discovered yet, e.g. waiting for the user to supply credentials). + let def = stdio_definition_with_required_input("brand-new", "api_key"); + let server = InstalledServer::new(&space_id, "brand-new").with_definition(&def); + f.installed_server_repo.install(&server).await.unwrap(); let result = f .registry .call( - "mcpmux_bind_current_workspace", + "mcpmux_list_servers", &f.client_id, Some(&f.session_id), - json!({ "feature_set_id": f.fs_android_id.to_string() }), + json!({}), ) .await .unwrap(); assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); - let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); - assert_eq!(bindings.len(), 1); - let stored = &bindings[0].workspace_root; - // Drive-letter lowercased, trailing separator trimmed. - assert_eq!(stored, &normalize_workspace_root(input)); - assert!(!stored.ends_with('/') && !stored.ends_with('\\')); - // Binding points at the concrete FS we passed in. - assert_eq!(bindings[0].space_id, f.space_id); + let servers = body.get("servers").unwrap().as_array().unwrap(); + let entry = servers + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some("brand-new")) + .expect("installed server with no tool features must appear in list_servers"); + + assert_eq!(entry.get("tool_count"), Some(&json!(0))); + assert_eq!(entry.get("health"), Some(&json!("needs_setup"))); + let missing = entry + .get("missing_inputs") + .and_then(|v| v.as_array()) + .expect("missing_inputs must be present for a needs_setup server"); + assert!( + missing.iter().any(|v| v.as_str() == Some("api_key")), + "api_key must appear in missing_inputs: {missing:?}" + ); +} + +// --------------------------------------------------------------------------- +// Writes — gated by ApprovalBroker +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn write_without_publisher_returns_approval_required() { + let f = Fixture::new().await; + let input = if cfg!(windows) { + "D:\\Projects\\Approval\\" + } else { + "/proj/approval" + }; + f.session_roots.set(&f.session_id, [input]); + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); assert_eq!( - bindings[0].feature_set_ids, - vec![f.fs_android_id.to_string()] + body.get("error").unwrap().as_str().unwrap(), + "approval_required" ); } -/// A successful bind must emit `WorkspaceBindingChanged` (not a generic -/// FeatureSet-members event) — that's the event the desktop Workspaces tab -/// refreshes on, and the one MCPNotifier turns into a list_changed push. -/// Regression guard for "workspace mapping didn't refresh in the UI". #[tokio::test(flavor = "multi_thread")] -async fn bind_current_workspace_emits_workspace_binding_changed() { +async fn write_rejected_on_deny_leaves_state_unchanged() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); - let mut rx = f.subscribe(); + f.attach_auto_publisher(ApprovalDecision::Deny); + + let before_bindings = f.binding_repo.list().await.unwrap().len(); let input = if cfg!(windows) { - "D:\\Projects\\Notify\\" + "D:\\Projects\\Denied\\" } else { - "/proj/notify" + "/proj/denied" }; f.session_roots.set(&f.session_id, [input]); - - f.registry - .call( + let result = f + .call_tool_as_handler_would( "mcpmux_bind_current_workspace", - &f.client_id, - Some(&f.session_id), json!({ "feature_set_id": f.fs_android_id.to_string() }), ) - .await - .unwrap(); - - // The stream also carries the central MetaToolInvoked audit event, so scan - // for the binding-changed signal specifically rather than asserting on the - // first event received. - let expected_root = normalize_workspace_root(input); - let mut found = false; - for _ in 0..8 { - match tokio::time::timeout(Duration::from_millis(300), rx.recv()).await { - Ok(Ok(DomainEvent::WorkspaceBindingChanged { - space_id, - workspace_root, - })) => { - assert_eq!(space_id, f.space_id); - assert_eq!(workspace_root, expected_root); - found = true; - break; - } - Ok(Ok(_other)) => continue, // e.g. MetaToolInvoked — skip - Ok(Err(_)) | Err(_) => break, // channel closed/lagged or timed out - } - } - assert!( - found, - "bind must emit WorkspaceBindingChanged so the Workspaces UI refreshes" + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "approval_denied" ); + + let after_bindings = f.binding_repo.list().await.unwrap().len(); + assert_eq!(after_bindings, before_bindings); } -/// The Tool Optimization built-in descriptor (`builtin_servers()`) is the -/// single source of truth the desktop UI renders and per-tool toggles read -/// from. It must stay in lockstep with the tools the gateway actually -/// registers — otherwise the shelf shows a tool the gateway won't dispatch -/// (or hides one it will). Guards both directions, including write flags. #[tokio::test(flavor = "multi_thread")] -async fn builtin_descriptor_matches_registered_meta_tools() { - use std::collections::BTreeMap; - +async fn bind_approval_surfaces_on_admin_sse_and_approve_writes_binding() { let f = Fixture::new().await; + let ui_bus = Arc::new(mcpmux_gateway::admin::AdminUiEventBus::new()); + let mut sse_rx = ui_bus.subscribe(); + f.attach_sse_publisher(ui_bus); - // name -> is_write, from what the gateway advertises (writes carry the - // destructive_hint annotation). - let registered: BTreeMap = f - .registry - .list_as_tools() - .iter() - .map(|t| { - let is_write = t - .annotations - .as_ref() - .and_then(|a| a.destructive_hint) - .unwrap_or(false); - (t.name.to_string(), is_write) - }) - .collect(); + let input = if cfg!(windows) { + "D:\\Projects\\WebAdmin\\" + } else { + "/proj/web-admin-bind" + }; + f.session_roots.set(&f.session_id, [input]); - let descriptor = mcpmux_core::builtin_server(TOOL_OPTIMIZATION_SERVER_ID) - .expect("Tool Optimization descriptor exists"); - let described: BTreeMap = descriptor - .tools - .iter() - .map(|t| (t.name.to_string(), t.write)) - .collect(); + let registry = f.registry.clone(); + let client_id = f.client_id.clone(); + let session_id = f.session_id.clone(); + let fs_id = f.fs_android_id.to_string(); + let broker = f.broker.clone(); + + let bind_task = tokio::spawn(async move { + registry + .call( + "mcpmux_bind_current_workspace", + &client_id, + Some(&session_id), + json!({ "feature_set_id": fs_id }), + ) + .await + }); + + let ui_event = tokio::time::timeout(Duration::from_secs(2), async { + loop { + match sse_rx.recv().await { + Ok(ev) if ev.channel == META_TOOL_APPROVAL_EVENT => return ev.payload, + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("SSE bus closed before approval request"); + } + } + } + }) + .await + .expect("approval request on admin SSE"); + + let request_id = ui_event + .get("request_id") + .and_then(|v| v.as_str()) + .expect("request_id in SSE payload"); + broker.respond( + request_id, + &f.client_id, + "mcpmux_bind_current_workspace", + ApprovalDecision::AllowOnce, + ); + + let result = bind_task.await.expect("bind task").expect("bind call"); + assert!(!Fixture::is_error(&result)); + let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].workspace_root, normalize_workspace_root(input)); assert_eq!( - registered, described, - "registered meta-tools and the Tool Optimization descriptor drifted \ - (name set or write flags differ)" + bindings[0].feature_set_ids, + vec![f.fs_android_id.to_string()] ); - // Sanity: the new search tool is present and read-only. - assert_eq!(described.get("mcpmux_search_tools"), Some(&false)); - assert_eq!(described.get("mcpmux_list_spaces"), Some(&false)); } -// --------------------------------------------------------------------------- -// space_id targeting — a client may inspect/configure any Space it can see -// --------------------------------------------------------------------------- - #[tokio::test(flavor = "multi_thread")] -async fn list_spaces_returns_all_spaces_including_default() { +async fn bind_deny_via_admin_sse_leaves_state_unchanged() { let f = Fixture::new().await; - let other = mcpmux_core::Space::new("Second Space"); - f.space_repo.create(&other).await.unwrap(); + let ui_bus = Arc::new(mcpmux_gateway::admin::AdminUiEventBus::new()); + let mut sse_rx = ui_bus.subscribe(); + f.attach_sse_publisher(ui_bus); + + let before_bindings = f.binding_repo.list().await.unwrap().len(); + let input = if cfg!(windows) { + "D:\\Projects\\WebDenied\\" + } else { + "/proj/web-admin-deny" + }; + f.session_roots.set(&f.session_id, [input]); + + let registry = f.registry.clone(); + let client_id = f.client_id.clone(); + let session_id = f.session_id.clone(); + let fs_id = f.fs_android_id.to_string(); + let broker = f.broker.clone(); - let body = Fixture::result_json( - &f.registry + let bind_task = tokio::spawn(async move { + registry .call( - "mcpmux_list_spaces", - &f.client_id, - Some(&f.session_id), - json!({}), + "mcpmux_bind_current_workspace", + &client_id, + Some(&session_id), + json!({ "feature_set_id": fs_id }), ) .await - .unwrap(), + }); + + let ui_event = tokio::time::timeout(Duration::from_secs(2), async { + loop { + match sse_rx.recv().await { + Ok(ev) if ev.channel == META_TOOL_APPROVAL_EVENT => return ev.payload, + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("SSE bus closed before approval request"); + } + } + } + }) + .await + .expect("approval request on admin SSE"); + + let request_id = ui_event + .get("request_id") + .and_then(|v| v.as_str()) + .expect("request_id in SSE payload"); + broker.respond( + request_id, + &f.client_id, + "mcpmux_bind_current_workspace", + ApprovalDecision::Deny, ); - let spaces = body.get("spaces").unwrap().as_array().unwrap(); - assert!(spaces - .iter() - .any(|s| s.get("id").and_then(|v| v.as_str()) == Some(f.space_id.to_string().as_str()))); - assert!(spaces - .iter() - .any(|s| s.get("name").and_then(|v| v.as_str()) == Some("Second Space"))); - assert!(spaces - .iter() - .any(|s| s.get("is_default").and_then(|v| v.as_bool()) == Some(true))); + + let result = match bind_task.await.expect("bind task") { + Ok(r) => r, + Err(e) => e.into_call_tool_result(), + }; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "approval_denied" + ); + + let after_bindings = f.binding_repo.list().await.unwrap().len(); + assert_eq!(after_bindings, before_bindings); } #[tokio::test(flavor = "multi_thread")] -async fn explicit_unknown_space_id_is_rejected() { +async fn bind_current_workspace_fails_when_no_roots_reported() { let f = Fixture::new().await; - let res = f + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + // NOTE: session_roots intentionally NOT populated. + + let result = f .call_tool_as_handler_would( - "mcpmux_list_all_tools", - json!({ "space_id": Uuid::new_v4().to_string() }), + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": f.fs_android_id.to_string() }), ) .await; - assert!(Fixture::is_error(&res)); + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); assert_eq!( - Fixture::result_json(&res) - .get("error") - .unwrap() - .as_str() - .unwrap(), + body.get("error").unwrap().as_str().unwrap(), "invalid_argument" ); } -/// A client can compose a FeatureSet in a Space *other* than its resolved one -/// by passing `space_id` — the write lands there, not in the caller's Space. #[tokio::test(flavor = "multi_thread")] -async fn manage_feature_set_create_targets_explicit_space() { +async fn bind_current_workspace_creates_binding_with_normalized_root() { let f = Fixture::new().await; f.attach_auto_publisher(ApprovalDecision::AllowOnce); + let input = if cfg!(windows) { + "D:\\Projects\\Android\\MyApp\\" + } else { + "/home/me/projects/android/myapp/" + }; + f.session_roots.set(&f.session_id, [input]); - // A second Space with its own tool to add. - let other = mcpmux_core::Space::new("Other Space"); - f.space_repo.create(&other).await.unwrap(); - let mut tool = ServerFeature::tool(other.id, "linear", "create_ticket"); - tool.description = Some("Create a Linear ticket".into()); - f.server_feature_repo.upsert(&tool).await.unwrap(); - - let res = f + let result = f .registry .call( - "mcpmux_manage_feature_set", + "mcpmux_bind_current_workspace", &f.client_id, Some(&f.session_id), - json!({ - "action": "create", - "space_id": other.id.to_string(), - "name": "Linear", - "add": ["linear_create_ticket"], - }), + json!({ "feature_set_id": f.fs_android_id.to_string() }), ) .await .unwrap(); - assert!( - !Fixture::is_error(&res), - "cross-space create allowed: {res:?}" - ); + assert!(!Fixture::is_error(&result)); - // Landed in the targeted Space, not the caller's resolved (default) Space. - let in_other = f - .feature_set_repo - .list_by_space(&other.id.to_string()) - .await - .unwrap(); - assert!(in_other.iter().any(|fs| fs.name == "Linear")); - let in_default = f - .feature_set_repo - .list_by_space(&f.space_id.to_string()) - .await - .unwrap(); - assert!(!in_default.iter().any(|fs| fs.name == "Linear")); + let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); + assert_eq!(bindings.len(), 1); + let stored = &bindings[0].workspace_root; + // Drive-letter lowercased, trailing separator trimmed. + assert_eq!(stored, &normalize_workspace_root(input)); + assert!(!stored.ends_with('/') && !stored.ends_with('\\')); + // Binding points at the concrete FS we passed in. + assert_eq!(bindings[0].space_id, f.space_id); + assert_eq!( + bindings[0].feature_set_ids, + vec![f.fs_android_id.to_string()] + ); } -/// A client can bind its current workspace into a *different* Space via -/// `space_id`; the binding is created in that Space (gated by approval). #[tokio::test(flavor = "multi_thread")] -async fn bind_current_workspace_targets_explicit_space() { +async fn bind_current_workspace_layers_onto_existing_binding() { let f = Fixture::new().await; f.attach_auto_publisher(ApprovalDecision::AllowOnce); - - let other = mcpmux_core::Space::new("Bind Target"); - f.space_repo.create(&other).await.unwrap(); - let input = if cfg!(windows) { - "D:\\Projects\\CrossSpace" + "D:\\Projects\\Android\\MyApp\\" } else { - "/proj/crossspace" + "/home/me/projects/android/myapp/" }; + let normalized = normalize_workspace_root(input); f.session_roots.set(&f.session_id, [input]); - // Omit feature_set_id (bind to no Space tools) but target the OTHER Space. - let res = f + let fs_full_id = { + let sets = f + .feature_set_repo + .list_by_space(&f.space_id.to_string()) + .await + .unwrap(); + let full = sets + .iter() + .find(|fs| fs.name == "Full Access") + .expect("Full Access FS"); + Uuid::parse_str(&full.id).unwrap() + }; + + // Seed an existing binding (simulates Workspaces UI or prior bind). + let starter = + WorkspaceBinding::new(normalized.clone(), f.space_id, f.fs_android_id.to_string()); + f.binding_repo.create(&starter).await.unwrap(); + + let result = f .registry .call( "mcpmux_bind_current_workspace", &f.client_id, Some(&f.session_id), - json!({ "space_id": other.id.to_string() }), + json!({ "feature_set_id": fs_full_id.to_string() }), ) .await .unwrap(); - assert!( - !Fixture::is_error(&res), - "cross-space bind allowed: {res:?}" - ); + assert!(!Fixture::is_error(&result)); - let bindings = f.binding_repo.list_for_space(&other.id).await.unwrap(); - assert_eq!(bindings.len(), 1, "binding created in the targeted Space"); - assert_eq!(bindings[0].space_id, other.id); - // Nothing leaked into the caller's resolved (default) Space. - assert!(f - .binding_repo - .list_for_space(&f.space_id) - .await - .unwrap() - .is_empty()); + let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); + assert_eq!(bindings.len(), 1, "must not insert a second binding row"); + assert_eq!(bindings[0].id, starter.id, "must reuse existing binding id"); + assert_eq!(bindings[0].workspace_root, normalized); + assert_eq!(bindings[0].feature_set_ids.len(), 2); + assert!(bindings[0] + .feature_set_ids + .contains(&f.fs_android_id.to_string())); + assert!(bindings[0] + .feature_set_ids + .contains(&fs_full_id.to_string())); } #[tokio::test(flavor = "multi_thread")] -async fn invalid_feature_set_argument_rejected() { +async fn bind_current_workspace_rebind_is_idempotent() { let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); let input = if cfg!(windows) { - "D:\\Projects\\Invalid\\" + "D:\\Projects\\Android\\Rebind\\" } else { - "/proj/invalid" + "/home/me/projects/android/rebind/" }; f.session_roots.set(&f.session_id, [input]); + let fs_id = github_only_fs(&f).await; + let args = json!({ "feature_set_id": fs_id }); + + f.registry + .call( + "mcpmux_bind_current_workspace", + &f.client_id, + Some(&f.session_id), + args.clone(), + ) + .await + .unwrap(); + let result = f - .call_tool_as_handler_would( + .registry + .call( "mcpmux_bind_current_workspace", - json!({ "feature_set_id": "not-a-uuid" }), + &f.client_id, + Some(&f.session_id), + args, ) - .await; - assert!(Fixture::is_error(&result)); + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); let body = Fixture::result_json(&result); - assert_eq!( - body.get("error").unwrap().as_str().unwrap(), - "invalid_argument" - ); + assert_eq!(body.get("already_bound"), Some(&json!(true))); + + let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].feature_set_ids, vec![fs_id]); } -/// Binding the same workspace twice REBINDS (upsert) instead of erroring — -/// no separate unbind needed. #[tokio::test(flavor = "multi_thread")] -async fn bind_current_workspace_rebinds_on_second_call() { +async fn bind_current_workspace_second_session_inherits_binding() { let f = Fixture::new().await; f.attach_auto_publisher(ApprovalDecision::AllowOnce); let input = if cfg!(windows) { - "D:\\Projects\\Rebind" + "D:\\Projects\\Android\\Persist\\" } else { - "/proj/rebind" + "/home/me/projects/android/persist/" }; + f.session_roots.set_roots_capable(&f.session_id, true); f.session_roots.set(&f.session_id, [input]); + let fs_id = github_only_fs(&f).await; - // Make a second FS to rebind to. - let other = FeatureSet::new_custom("Other", f.space_id.to_string()); - f.feature_set_repo.create(&other).await.unwrap(); - - // First bind → fs_android. f.registry .call( "mcpmux_bind_current_workspace", &f.client_id, Some(&f.session_id), - json!({ "feature_set_id": f.fs_android_id.to_string() }), + json!({ "feature_set_id": fs_id }), ) .await .unwrap(); - // Rebind same root → other FS. - f.registry - .call( - "mcpmux_bind_current_workspace", - &f.client_id, - Some(&f.session_id), - json!({ "feature_set_id": other.id }), - ) + + let new_session = "sess-bind-inherit"; + f.session_roots.set_roots_capable(new_session, true); + f.session_roots.set(new_session, [input]); + + let resolved = f + .registry + .context() + .resolver + .resolve(Some(new_session), Some(&f.client_id)) .await .unwrap(); + assert!( + resolved.feature_set_ids.iter().any(|id| id == &fs_id), + "second session should resolve the bound FeatureSet" + ); - let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); - assert_eq!(bindings.len(), 1, "still one binding for the root (upsert)"); - assert_eq!(bindings[0].feature_set_ids, vec![other.id]); + let tools = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &resolved.feature_set_ids) + .await + .unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].server_id, "github"); } -/// Omitting `feature_set_id` binds the workspace to NO Space tools (a valid -/// empty mapping), without erroring. #[tokio::test(flavor = "multi_thread")] -async fn bind_current_workspace_to_empty_is_allowed() { +async fn invalid_feature_set_argument_rejected() { let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); let input = if cfg!(windows) { - "D:\\Projects\\Empty" + "D:\\Projects\\Invalid\\" } else { - "/proj/empty" + "/proj/invalid" }; f.session_roots.set(&f.session_id, [input]); - - let res = f - .call_tool_as_handler_would("mcpmux_bind_current_workspace", json!({})) + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": "not-a-uuid" }), + ) .await; - assert!(!Fixture::is_error(&res), "empty bind allowed: {res:?}"); - - let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); - assert_eq!(bindings.len(), 1); - assert!( - bindings[0].feature_set_ids.is_empty(), - "empty FeatureSet list = no Space tools" + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "invalid_argument" ); } @@ -985,41 +1878,95 @@ async fn bind_current_workspace_to_empty_is_allowed() { // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread")] -async fn registry_advertises_every_default_tool_with_annotations() { +async fn registry_advertises_core_tools_read_only_in_list() { let f = Fixture::new().await; let tools = f.registry.list_as_tools(); let names: Vec<_> = tools.iter().map(|t| t.name.to_string()).collect(); - for expected in [ - "mcpmux_list_spaces", - "mcpmux_list_all_tools", - "mcpmux_search_tools", + assert_eq!(names.len(), meta_tools::CORE_META_TOOLS.len()); + for core in meta_tools::CORE_META_TOOLS { + assert!(names.iter().any(|n| n == *core), "missing {core}"); + } + for hidden in [ "mcpmux_list_feature_sets", - "mcpmux_manage_feature_set", "mcpmux_bind_current_workspace", + "mcpmux_search_resources", + "mcpmux_read_resource", + "mcpmux_search_prompts", + "mcpmux_fetch_prompt", + "mcpmux_diagnose_server", ] { - assert!(names.iter().any(|n| n == expected), "missing {expected}"); + assert!( + !names.iter().any(|n| n == hidden), + "{hidden} must not be advertised; got {names:?}" + ); } - // The old single-purpose create tool was consolidated into manage. - assert!( - !names.iter().any(|n| n == "mcpmux_create_feature_set"), - "create_feature_set should be gone (folded into manage): {names:?}" - ); - // Both describe_* tools were removed — they must NOT be advertised. - for removed in ["mcpmux_describe_resolution", "mcpmux_describe_workspace"] { + for tool in &tools { + let destructive = tool + .annotations + .as_ref() + .and_then(|a| a.destructive_hint) + .unwrap_or(false); assert!( - !names.iter().any(|n| n == removed), - "{removed} should be removed; got {names:?}" + !destructive, + "advertised core tools must be read-only hints: {:?}", + tool.name ); } - // Writes carry the destructive_hint annotation. - let bind = tools +} + +#[tokio::test(flavor = "multi_thread")] +async fn hidden_bind_tool_callable_but_not_advertised() { + let f = Fixture::new().await; + let advertised: Vec<_> = f + .registry + .list_as_tools() + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert!(!advertised .iter() - .find(|t| t.name == "mcpmux_bind_current_workspace") + .any(|n| n == "mcpmux_bind_current_workspace")); + assert!(f.registry.contains("mcpmux_bind_current_workspace")); + + let result = f + .registry + .call( + "mcpmux_list_feature_sets", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await; + assert!(result.is_ok(), "hidden read tool must remain callable"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_scope_all_matches_include_inactive() { + let f = Fixture::new().await; + let fs_id = github_only_fs(&f).await; + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue", "scope": "all" }), + ) + .await .unwrap(); - assert_eq!( - bind.annotations.as_ref().and_then(|a| a.destructive_hint), - Some(true) - ); + let body = Fixture::result_json(&result); + assert_eq!(body.get("scope"), Some(&json!("active_and_inactive"))); + let tool = body + .get("tools") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))) + .expect("inactive github tool in results"); + assert_eq!(tool.get("status"), Some(&json!("inactive"))); + assert_eq!(tool.get("bindable_feature_set_id"), Some(&json!(fs_id))); } // --------------------------------------------------------------------------- @@ -1046,6 +1993,9 @@ async fn bare_registry( Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let _space = space_repo.get_default().await.unwrap().unwrap(); let client = Client::new("c", "t"); @@ -1065,22 +2015,30 @@ async fn bare_registry( let feature_service = Arc::new(FeatureService::new( server_feature_repo.clone(), feature_set_repo.clone(), - prefix_cache, + prefix_cache.clone(), )); let (tx, rx) = broadcast::channel::(32); + let log_manager = test_log_manager(); + let server_manager = test_server_manager(tx.clone(), feature_service.clone(), prefix_cache); let registry = meta_tools::build_default_registry( client_repo, space_repo, feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, + None, + None, SessionRootsRegistry::new(), Arc::new(ApprovalBroker::new()), tx.clone(), settings_repo, - None, + server_manager, + log_manager, + std::env::temp_dir().join(format!("mcpmux-bare-registry-{}", Uuid::new_v4())), + Arc::new(SqliteEmbeddingRepository::new(db.clone())), ); (registry, client_id, tx, rx) } @@ -1090,7 +2048,7 @@ async fn read_tool_emits_meta_tool_invoked_with_decision_read() { let (registry, client_id, _tx, mut rx) = bare_registry(None).await; registry - .call("mcpmux_list_all_tools", &client_id, Some("s"), json!({})) + .call("mcpmux_list_servers", &client_id, Some("s"), json!({})) .await .unwrap(); @@ -1104,7 +2062,7 @@ async fn read_tool_emits_meta_tool_invoked_with_decision_read() { decision, .. } => { - assert_eq!(tool_name, "mcpmux_list_all_tools"); + assert_eq!(tool_name, "mcpmux_list_servers"); assert_eq!(decision, "read"); } other => panic!("unexpected event: {other:?}"), @@ -1146,11 +2104,17 @@ async fn denied_write_emits_meta_tool_invoked_with_decision_deny() { } #[tokio::test(flavor = "multi_thread")] -async fn per_space_config_controls_registry_visibility() { - // Same DB so the per-Space config repo and the registry see one another. +async fn master_switch_toggles_registry_visibility() { + use mcpmux_storage::SqliteAppSettingsRepository; + + // Same DB so the settings repo and the registry see one another. let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); - let builtin_config_repo: Arc = - Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone())); + let settings_repo: Arc = + Arc::new(SqliteAppSettingsRepository::new(db.clone())); + settings_repo + .set("gateway.meta_tools_enabled", "false") + .await + .unwrap(); let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); let feature_set_repo: Arc = @@ -1161,10 +2125,10 @@ async fn per_space_config_controls_registry_visibility() { Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); - - let space_id = space_repo.get_default().await.unwrap().unwrap().id; - let resolver = Arc::new(FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), @@ -1177,87 +2141,373 @@ async fn per_space_config_controls_registry_visibility() { let feature_service = Arc::new(FeatureService::new( server_feature_repo.clone(), feature_set_repo.clone(), - prefix_cache, + prefix_cache.clone(), )); let (tx, _) = broadcast::channel::(16); + let log_manager = test_log_manager(); + let server_manager = test_server_manager(tx.clone(), feature_service.clone(), prefix_cache); let registry = meta_tools::build_default_registry( client_repo, space_repo, feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, + None, + None, SessionRootsRegistry::new(), Arc::new(ApprovalBroker::new()), tx, - None, - Some(builtin_config_repo.clone()), + Some(settings_repo.clone()), + server_manager, + log_manager, + std::env::temp_dir().join(format!("mcpmux-meta-switch-{}", Uuid::new_v4())), + Arc::new(SqliteEmbeddingRepository::new(db.clone())), ); - let sid = space_id.to_string(); + assert!(!registry.is_enabled().await, "initially disabled"); - // Default: the Tool Optimization server is enabled for the Space. - assert!( - registry.is_server_enabled_for_space(&space_id).await, - "enabled by default" + settings_repo + .set("gateway.meta_tools_enabled", "true") + .await + .unwrap(); + assert!(registry.is_enabled().await, "flipped back on"); + + // Missing key → default on (fresh install). + settings_repo + .delete("gateway.meta_tools_enabled") + .await + .unwrap(); + assert!(registry.is_enabled().await, "missing key defaults on"); +} + +// Silence unused-import warnings from helper imports that only some tests exercise. +#[allow(dead_code)] +fn _unused(_: ApprovalPayload) {} + +// --------------------------------------------------------------------------- +// Readiness, browse mode, invoke_example (agent UX path) +// --------------------------------------------------------------------------- + +/// Seed `tool_count` alphabetically named tools on one server for browse pagination tests. +async fn seed_browse_tools(f: &Fixture, server_id: &str, tool_count: usize) -> String { + let space_id = f.space_id.to_string(); + let features: Vec = (0..tool_count) + .map(|i| { + let mut tool = ServerFeature::tool(&space_id, server_id, format!("tool_{i:03}")); + tool.raw_json = Some(json!({ + "name": format!("tool_{i:03}"), + "inputSchema": { + "type": "object", + "properties": { "id": { "type": "integer" } }, + "required": ["id"] + } + })); + tool + }) + .collect(); + f.server_feature_repo.upsert_many(&features).await.unwrap(); + + let mut fs = FeatureSet::new_custom("Browse bundle", space_id.clone()); + for feature in &features { + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: feature.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + } + let fs_id = fs.id.clone(); + f.feature_set_repo.create(&fs).await.unwrap(); + fs_id +} + +#[tokio::test(flavor = "multi_thread")] +async fn browse_mode_default_limit_fifty_and_alphabetical() { + let f = Fixture::new().await; + let fs_id = seed_browse_tools(&f, "catalog", 55).await; + let root = "/tmp/mcpmux-browse-limit"; + f.session_roots.set_roots_capable(&f.session_id, true); + f.session_roots.set(&f.session_id, [root]); + let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id); + f.binding_repo.create(&binding).await.unwrap(); + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "catalog" }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(body.get("mode"), Some(&json!("browse"))); + assert_eq!(body.get("total"), Some(&json!(55))); + let tools = body.get("tools").unwrap().as_array().unwrap(); + assert_eq!(tools.len(), 50); + assert!(body.get("next_cursor").is_some()); + assert_eq!( + tools[0].get("qualified_name"), + Some(&json!("catalog_tool_000")) ); - assert!( - !registry.list_as_tools_for_space(&space_id).await.is_empty(), - "tools advertised by default" + assert_eq!( + tools[1].get("qualified_name"), + Some(&json!("catalog_tool_001")) ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn browse_hits_include_invoke_example_and_server_readiness() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "github", "mode": "browse" }), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let tool = body + .get("tools") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))) + .expect("create_issue in browse"); + assert_eq!(tool.get("server_readiness"), Some(&json!("bound"))); + let example = tool + .get("invoke_example") + .expect("invoke_example on browse"); + assert_eq!(example.get("server_id"), Some(&json!("github"))); + assert_eq!(example.get("tool"), Some(&json!("create_issue"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn browse_mode_without_server_id_lists_whole_space() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; - // Disable the whole server for this Space → no tools advertised. - builtin_config_repo - .set_server_enabled(&sid, TOOL_OPTIMIZATION_SERVER_ID, false) + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "mode": "browse" }), + ) .await .unwrap(); - assert!(!registry.is_server_enabled_for_space(&space_id).await); + let body = Fixture::result_json(&result); + assert_eq!(body.get("mode"), Some(&json!("browse"))); + let tools = body.get("tools").unwrap().as_array().expect("browse page"); + assert!( + !tools.is_empty(), + "whole-space browse must return tools: {body}" + ); assert!( - registry.list_as_tools_for_space(&space_id).await.is_empty(), - "no tools when the server is disabled for the Space" + tools + .iter() + .any(|t| t.get("server_id") == Some(&json!("github"))), + "expected github tools in whole-space browse: {tools:?}" ); + let names: Vec<_> = tools + .iter() + .filter_map(|t| t.get("qualified_name").and_then(|v| v.as_str())) + .collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted, "browse must be alphabetical"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ranked_search_omits_invoke_example() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; - // Re-enable, then disable a single tool → that tool drops, others remain. - builtin_config_repo - .set_server_enabled(&sid, TOOL_OPTIMIZATION_SERVER_ID, true) + let result = f + .registry + .call( + "mcpmux_search_tools", + &f.client_id, + Some(&f.session_id), + json!({ "query": "issue", "server_id": "github" }), + ) .await .unwrap(); - builtin_config_repo - .set_tool_enabled( - &sid, - TOOL_OPTIMIZATION_SERVER_ID, - "mcpmux_list_all_tools", - false, + let body = Fixture::result_json(&result); + assert!(body.get("mode").is_none()); + let tool = body.get("tools").unwrap().as_array().unwrap()[0].clone(); + assert!(tool.get("invoke_example").is_none()); + assert!(tool.get("server_readiness").is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_ready_when_bound_and_connected() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + let space_id = f.space_id.to_string(); + let github = InstalledServer::new(&space_id, "github"); + f.installed_server_repo.install(&github).await.unwrap(); + f.server_manager + .set_connected( + &ServerKey::new(f.space_id, "github"), + CachedFeatures::default(), + ) + .await; + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), ) .await .unwrap(); - let names: Vec = registry - .list_as_tools_for_space(&space_id) - .await - .into_iter() + let body = Fixture::result_json(&result); + assert_eq!(server_readiness(&body, "github"), "ready"); +} + +// --------------------------------------------------------------------------- +// Diagnose server (mcpmux_diagnose_server) +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn diagnose_server_callable_but_not_in_tools_list() { + let f = Fixture::new().await; + let names: Vec<_> = f + .registry + .list_as_tools() + .iter() .map(|t| t.name.to_string()) .collect(); assert!( - !names.iter().any(|n| n == "mcpmux_list_all_tools"), - "the disabled tool is hidden: {names:?}" + !names.iter().any(|n| n == "mcpmux_diagnose_server"), + "diagnose_server is hidden from tools/list: {names:?}" ); - assert!( - names.iter().any(|n| n == "mcpmux_list_feature_sets"), - "other tools remain: {names:?}" + assert!(f.registry.contains("mcpmux_diagnose_server")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn diagnose_no_arg_returns_only_unhealthy_servers() { + let f = Fixture::new().await; + seed_diagnose_servers(&f).await; + + let result = f + .registry + .call( + "mcpmux_diagnose_server", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let servers = body.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!( + servers[0].get("server_id").unwrap().as_str().unwrap(), + "firebase" ); - assert!( - !registry - .is_tool_enabled_for_space(&space_id, "mcpmux_list_all_tools") - .await + assert_eq!(servers[0].get("health").unwrap().as_str().unwrap(), "error"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn diagnose_explicit_server_id_returns_target_regardless_of_health() { + let f = Fixture::new().await; + seed_diagnose_servers(&f).await; + + let result = f + .registry + .call( + "mcpmux_diagnose_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "github" }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let servers = body.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!( + servers[0].get("server_id").unwrap().as_str().unwrap(), + "github" ); - assert!( - registry - .is_tool_enabled_for_space(&space_id, "mcpmux_list_feature_sets") - .await + assert_eq!( + servers[0].get("health").unwrap().as_str().unwrap(), + "healthy" ); } -// Silence unused-import warnings from helper imports that only some tests exercise. -#[allow(dead_code)] -fn _unused(_: ApprovalPayload) {} +#[tokio::test(flavor = "multi_thread")] +async fn diagnose_include_logs_false_omits_logs_block() { + let f = Fixture::new().await; + seed_diagnose_servers(&f).await; + + let result = f + .registry + .call( + "mcpmux_diagnose_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "firebase", "include_logs": false }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let entry = &body.get("servers").unwrap().as_array().unwrap()[0]; + assert!(entry.get("logs").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn diagnose_surfaces_missing_required_inputs() { + let f = Fixture::new().await; + let space_id = f.space_id.to_string(); + + let def = stdio_definition_with_required_input("needs-setup", "github_token"); + let server = InstalledServer::new(&space_id, "needs-setup").with_definition(&def); + f.installed_server_repo.install(&server).await.unwrap(); + + let result = f + .registry + .call( + "mcpmux_diagnose_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "needs-setup" }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let entry = &body.get("servers").unwrap().as_array().unwrap()[0]; + assert_eq!( + entry.get("health").unwrap().as_str().unwrap(), + "needs_setup" + ); + let missing = entry + .get("missing_required_inputs") + .unwrap() + .as_array() + .unwrap(); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].as_str().unwrap(), "github_token"); +} From 2e7256a6ddbacfb0a06cb163ccd7f10b7b170dd0 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 20:59:56 -0600 Subject: [PATCH 006/148] =?UTF-8?q?feat(port):=20Phase=206=20=E2=80=94=20S?= =?UTF-8?q?erver=20features:=20cloning=20+=20update=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Rust services: package_version, server_version_probe, server_clone - New domain events: ServerVersionChecked, ServerUpdateAvailable - InstalledServerRepository: set_display_name_override, update_version_cache - ServerAppService: clone_server, is_clone_id_available, suggest_clone_suffix, list_clone_dependents, set_display_name_override, update_config extended with update_policy + pinned_version - pool/transport/resolution: TransportResolutionOptions with update-policy - New Tauri commands: get_build_info, set_server_display_name, clone_server, is_clone_id_available, suggest_clone_suffix, list_clone_dependents, update_server_package, get/update_server_update_settings, check_all_server_updates, check_server_version - Frontend: CloneAccountModal, UninstallSourceWithClonesDialog, ServerActionMenu (clone + update actions), ServerUpdatesSection, ServerPendingUpdatesList, BuildStampPanel, StaleBuildBanner, use-build-stamp.hook, server-update-policy.helpers, server-pending-updates.helpers, server-display-name.helpers - ServersPage: clone-aware uninstall, handleLockToCurrentVersion, handleUpdateNow, handleCheckForUpdate wired end-to-end - build.rs: embed git SHA, branch, commit/build timestamps Signed-off-by: crimsonsunset --- apps/desktop/src-tauri/build.rs | 66 +- .../desktop/src-tauri/src/commands/gateway.rs | 28 + apps/desktop/src-tauri/src/commands/mod.rs | 2 + apps/desktop/src-tauri/src/commands/server.rs | 29 + .../src-tauri/src/commands/server_clone.rs | 99 +++ .../src-tauri/src/commands/server_manager.rs | 115 ++- .../src-tauri/src/commands/settings.rs | 313 +++++--- apps/desktop/src-tauri/src/lib.rs | 35 +- .../src-tauri/src/services/admin_server.rs | 5 +- .../src/components/StaleBuildBanner.tsx | 69 ++ .../features/servers/CloneAccountModal.tsx | 329 ++++++++ .../src/features/servers/ServerActionMenu.tsx | 306 ++++---- .../src/features/servers/ServersPage.tsx | 300 +++++++- .../UninstallSourceWithClonesDialog.tsx | 97 +++ apps/desktop/src/features/servers/index.ts | 3 + .../servers/server-display-name.helpers.ts | 32 + .../servers/server-pending-updates.helpers.ts | 91 +++ .../servers/server-update-policy.helpers.ts | 244 ++++++ .../src/features/settings/BuildStampPanel.tsx | 120 +++ .../settings/ServerPendingUpdatesList.tsx | 101 +++ .../settings/ServerUpdatesSection.tsx | 316 ++++++++ .../src/features/settings/SettingsPage.tsx | 7 + apps/desktop/src/features/settings/index.ts | 4 + .../features/settings/use-build-stamp.hook.ts | 71 ++ apps/desktop/src/hooks/useDomainEvents.ts | 22 +- apps/desktop/src/lib/api/registry.ts | 15 +- apps/desktop/src/lib/api/serverManager.ts | 7 + apps/desktop/src/types/registry.ts | 30 + crates/mcpmux-core/src/application/server.rs | 204 ++++- crates/mcpmux-core/src/domain/config.rs | 4 +- crates/mcpmux-core/src/domain/event.rs | 19 + crates/mcpmux-core/src/repository/mod.rs | 14 + .../src/service/app_settings_service.rs | 13 +- .../src/admin/command_bridge/write.rs | 2 + crates/mcpmux-gateway/src/admin/ui_events.rs | 24 + .../mcpmux-gateway/src/admin/write_runtime.rs | 3 +- crates/mcpmux-gateway/src/pool/routing.rs | 137 ++-- .../src/pool/transport/resolution.rs | 710 +++++++++++++++++- crates/mcpmux-gateway/src/server/startup.rs | 1 + crates/mcpmux-gateway/src/services/mod.rs | 10 + .../src/services/package_version.rs | 143 ++++ .../src/services/server_version_probe.rs | 676 +++++++++++++++++ .../installed_server_repository.rs | 37 + tests/rust/src/mocks.rs | 22 + 44 files changed, 4517 insertions(+), 358 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/server_clone.rs create mode 100644 apps/desktop/src/components/StaleBuildBanner.tsx create mode 100644 apps/desktop/src/features/servers/CloneAccountModal.tsx create mode 100644 apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx create mode 100644 apps/desktop/src/features/servers/server-display-name.helpers.ts create mode 100644 apps/desktop/src/features/servers/server-pending-updates.helpers.ts create mode 100644 apps/desktop/src/features/servers/server-update-policy.helpers.ts create mode 100644 apps/desktop/src/features/settings/BuildStampPanel.tsx create mode 100644 apps/desktop/src/features/settings/ServerPendingUpdatesList.tsx create mode 100644 apps/desktop/src/features/settings/ServerUpdatesSection.tsx create mode 100644 apps/desktop/src/features/settings/use-build-stamp.hook.ts create mode 100644 crates/mcpmux-gateway/src/services/package_version.rs create mode 100644 crates/mcpmux-gateway/src/services/server_version_probe.rs diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index f32d35f3..cb14abfe 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,9 +1,8 @@ use std::fs; use std::path::Path; +use std::process::Command; fn main() { - // Read tauri.conf.json to extract the app identifier - // This ensures a single source of truth for the identifier let config_path = Path::new("tauri.conf.json"); if let Ok(contents) = fs::read_to_string(config_path) { if let Ok(json) = serde_json::from_str::(&contents) { @@ -12,9 +11,68 @@ fn main() { } } } - - // Tell Cargo to re-run this script if tauri.conf.json changes println!("cargo:rerun-if-changed=tauri.conf.json"); + // Stamp git/build metadata into the binary so the admin UI can detect a stale + // SPA build (web-admin serves a pre-built bundle from `apps/desktop/dist`). + let git_sha = git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default(); + let git_branch = + git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); + let commit_time = + git_output(&["log", "-1", "--format=%ci"]).unwrap_or_else(|| "unknown".to_string()); + let build_time = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse::().ok()) + .map(format_epoch) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format_epoch(d.as_secs())) + .unwrap_or_else(|_| "unknown".to_string()) + }); + + println!("cargo:rustc-env=MCPMUX_BUILD_GIT_SHA={}", git_sha); + println!("cargo:rustc-env=MCPMUX_BUILD_GIT_BRANCH={}", git_branch); + println!("cargo:rustc-env=MCPMUX_BUILD_COMMIT_TIME={}", commit_time); + println!("cargo:rustc-env=MCPMUX_BUILD_TIME={}", build_time); + println!("cargo:rerun-if-changed=../../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../../.git/logs/HEAD"); + tauri_build::build() } + +fn git_output(args: &[&str]) -> Option { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Format a Unix timestamp as a naive UTC datetime string (no external deps). +fn format_epoch(secs: u64) -> String { + let days = secs / 86400; + let rem = secs % 86400; + let hh = rem / 3600; + let mm = (rem % 3600) / 60; + let ss = rem % 60; + + let jdn = days + 2440588; + let a = jdn + 32044; + let b = (4 * a + 3) / 146097; + let c = a - (146097 * b) / 4; + let d = (4 * c + 3) / 1461; + let e = c - (1461 * d) / 4; + let m = (5 * e + 2) / 153; + let day = e - (153 * m + 2) / 5 + 1; + let month = m + 3 - 12 * (m / 10); + let year = 100 * b + d - 4800 + m / 10; + + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + year, month, day, hh, mm, ss + ) +} diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 08ba87a8..5d20f9fb 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -830,6 +830,32 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "workspace-appearance-changed", serde_json::json!({ "workspace_root": workspace_root }), ), + + DomainEvent::ServerVersionChecked { + space_id, + server_id, + } => ( + "server-version-checked", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + }), + ), + + DomainEvent::ServerUpdateAvailable { + space_id, + server_id, + current_version, + latest_version, + } => ( + "server-update-available", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + "current_version": current_version, + "latest_version": latest_version, + }), + ), } } @@ -1661,6 +1687,7 @@ pub async fn connect_server( &server_definition.transport, &installed, Some(app_state.data_dir()), + mcpmux_gateway::pool::transport::resolution::TransportResolutionOptions::default(), ); // Connect using pool service (manual connect from API) @@ -1899,6 +1926,7 @@ pub async fn connect_all_enabled_servers( &server_definition.transport, &installed, Some(app_state.data_dir()), + mcpmux_gateway::pool::transport::resolution::TransportResolutionOptions::default(), ); servers_to_connect.push((server_info, transport, server_definition, installed)); diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 7fcb9630..2aabe3bd 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod logs; pub mod meta_tool_approval; pub mod oauth; pub mod server; +pub mod server_clone; pub mod server_discovery; pub mod server_feature; pub mod server_manager; @@ -36,6 +37,7 @@ pub use logs::*; pub use meta_tool_approval::*; pub use oauth::*; pub use server::*; +pub use server_clone::*; pub use server_discovery::*; pub use server_feature::*; pub use server_manager::*; diff --git a/apps/desktop/src-tauri/src/commands/server.rs b/apps/desktop/src-tauri/src/commands/server.rs index e2c8f44a..45f09031 100644 --- a/apps/desktop/src-tauri/src/commands/server.rs +++ b/apps/desktop/src-tauri/src/commands/server.rs @@ -128,6 +128,7 @@ pub async fn set_server_oauth_connected( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn save_server_inputs( app_service: State<'_, Arc>>>, id: String, @@ -136,6 +137,8 @@ pub async fn save_server_inputs( env_overrides: Option>, args_append: Option>, extra_headers: Option>, + update_policy: Option, + pinned_version: Option, ) -> Result { let service_lock = app_service.read().await; let service = service_lock @@ -152,7 +155,33 @@ pub async fn save_server_inputs( env_overrides, args_append, extra_headers, + update_policy.map(|p| mcpmux_core::domain::UpdatePolicy::from_db_str(&p)), + pinned_version, ) .await .map_err(|e| e.to_string()) } + +/// Set (or clear) the display name override for an installed server. +/// +/// Empty/whitespace clears the override and the UI falls back to the cached +/// definition name. Does not change `server_id`, alias, or tool prefixes. +#[tauri::command] +pub async fn set_server_display_name( + app_service: State<'_, Arc>>>, + id: String, + space_id: String, + display_name: Option, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .set_display_name_override(space_uuid, &id, display_name) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/server_clone.rs b/apps/desktop/src-tauri/src/commands/server_clone.rs new file mode 100644 index 00000000..ac7b30ca --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/server_clone.rs @@ -0,0 +1,99 @@ +//! Server clone commands + +use mcpmux_core::application::ServerAppService; +use mcpmux_core::domain::InstalledServer; +use std::sync::Arc; +use tauri::State; +use tokio::sync::RwLock; + +/// Clone an installed server into a new suffixed manual-entry install in the same space. +/// +/// `display_name` (optional) is stored as `display_name_override` so the user-supplied +/// label survives later definition refreshes (e.g. user-config sync). When omitted, the +/// auto `"Source (suffix)"` label on the cached definition is used as fallback. +#[tauri::command] +pub async fn clone_server( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, + suffix: String, + alias: Option, + display_name: Option, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .clone_server( + space_uuid, + &source_server_id, + &suffix, + alias.as_deref(), + display_name.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// Return whether a suffixed clone ID is available in the given space. +#[tauri::command] +pub async fn is_clone_id_available( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, + suffix: String, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .is_clone_id_available(space_uuid, &source_server_id, &suffix) + .await + .map_err(|e| e.to_string()) +} + +/// Suggest the first available default suffix for cloning a server. +#[tauri::command] +pub async fn suggest_clone_suffix( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .suggest_clone_suffix(space_uuid, &source_server_id) + .await + .map_err(|e| e.to_string()) +} + +/// List installed servers in a space that were cloned from the given source. +#[tauri::command] +pub async fn list_clone_dependents( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, +) -> Result, String> { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + service + .list_clone_dependents(&space_id, &source_server_id) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/server_manager.rs b/apps/desktop/src-tauri/src/commands/server_manager.rs index 91715514..1f206c71 100644 --- a/apps/desktop/src-tauri/src/commands/server_manager.rs +++ b/apps/desktop/src-tauri/src/commands/server_manager.rs @@ -7,7 +7,12 @@ //! - Connect/Reconnect button based on connection history use crate::AppState; -use mcpmux_gateway::pool::transport::resolution::build_transport_config; // Import from gateway +use chrono::Utc; +use mcpmux_core::{ApplicationServices, DomainEvent}; +use mcpmux_gateway::pool::transport::resolution::{ + build_transport_config, TransportResolutionOptions, +}; +use mcpmux_gateway::services::ServerVersionProbeService; use mcpmux_gateway::{ ConnectionContext, ConnectionResult, ConnectionStatus, ServerKey, ServerManager, }; @@ -72,6 +77,12 @@ pub async fn get_server_statuses( .collect()) } +/// Options for enable / reconnect server manager flows. +#[derive(Debug, Clone, Copy, Default)] +struct ConnectServerOptions { + apply_package_update: bool, +} + /// Enable a server and attempt connection /// /// This replaces the old `set_server_enabled(true)` + `connect_server()` pattern. @@ -89,6 +100,92 @@ pub async fn enable_server_v2( state: State<'_, Arc>>, gateway_state: State<'_, Arc>>, app_state: State<'_, AppState>, +) -> Result<(), String> { + connect_enabled_server( + space_id, + server_id, + ConnectServerOptions::default(), + state, + gateway_state, + app_state, + ) + .await +} + +/// Reconnect an enabled server and apply latest package resolution (notify/auto npx/uvx). +#[tauri::command] +pub async fn update_server_package( + space_id: String, + server_id: String, + state: State<'_, Arc>>, + gateway_state: State<'_, Arc>>, + app_state: State<'_, AppState>, + application_services: State<'_, Arc>, +) -> Result<(), String> { + let space_uuid = Uuid::parse_str(&space_id).map_err(|e| format!("Invalid space_id: {}", e))?; + + { + let manager_state = state.read().await; + if let Some(pool_service) = manager_state.pool_service.as_ref() { + pool_service.remove_instance(space_uuid, &server_id); + info!( + "[ServerManager] Removed existing instance for package update: {}", + server_id + ); + } + } + + let installed_repo = app_state.installed_server_repository.clone(); + let settings_repo = app_state.settings_repository.clone(); + let event_bus = application_services.event_bus.clone(); + + connect_enabled_server( + space_id.clone(), + server_id.clone(), + ConnectServerOptions { + apply_package_update: true, + }, + state, + gateway_state, + app_state, + ) + .await?; + + // Immediately record current_version = latest_available_version so the + // badge clears before the probe runs. The probe will confirm or refine + // this value from the npx cache / uv tool list. + if let Ok(Some(server)) = installed_repo.get_by_server_id(&space_id, &server_id).await { + if let Some(latest) = server.latest_available_version { + let _ = installed_repo + .update_version_cache(&server.id, Some(latest.clone()), Some(latest), Utc::now()) + .await; + } + } + + let probe = ServerVersionProbeService::new(installed_repo, settings_repo, event_bus.clone()); + if let Err(error) = probe.probe_server(&space_id, &server_id).await { + warn!( + "[ServerManager] Post-update version probe failed for {}: {}", + server_id, error + ); + } + + event_bus.sender().emit(DomainEvent::ServerVersionChecked { + space_id: space_uuid, + server_id: server_id.clone(), + }); + + Ok(()) +} + +/// Connect an enabled installed server with optional one-shot package update resolution. +async fn connect_enabled_server( + space_id: String, + server_id: String, + options: ConnectServerOptions, + state: State<'_, Arc>>, + gateway_state: State<'_, Arc>>, + app_state: State<'_, AppState>, ) -> Result<(), String> { let space_uuid = Uuid::parse_str(&space_id).map_err(|e| format!("Invalid space_id: {}", e))?; @@ -135,6 +232,9 @@ pub async fn enable_server_v2( &server_definition.transport, &installed, Some(app_state.data_dir()), + TransportResolutionOptions { + apply_package_update: options.apply_package_update, + }, ); // Attempt connection with auto_reconnect=true to avoid starting OAuth flow @@ -324,6 +424,7 @@ pub async fn start_auth_v2( &server_definition.transport, &installed, Some(app_state.data_dir()), + TransportResolutionOptions::default(), ); let ctx = ConnectionContext::new(space_uuid, server_id.clone(), transport); let result = pool_service.connect_server(&ctx).await; @@ -433,8 +534,16 @@ pub async fn retry_connection( } } - // Now enable_server_v2 will create a fresh connection with current config - enable_server_v2(space_id, server_id, state, gateway_state, app_state).await + // Fresh connection with current config (no one-shot package update). + connect_enabled_server( + space_id, + server_id, + ConnectServerOptions::default(), + state, + gateway_state, + app_state, + ) + .await } /// Logout server - Clear OAuth tokens but keep enabled diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index 8cc46c40..ac9f011a 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -5,7 +5,14 @@ use tauri::State; use tauri_plugin_autostart::AutoLaunchManager; use tracing::{debug, info}; +use crate::services::admin_server::reload_admin_server; use crate::state::AppState; +use crate::{commands::gateway::GatewayAppState, commands::server_manager::ServerManagerState}; +use mcpmux_core::{AppSettingsService, ApplicationServices, UpdatePolicy}; +use mcpmux_gateway::services::ServerVersionProbeService; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; /// Startup and system tray settings #[derive(Debug, Clone, Serialize, Deserialize)] @@ -122,101 +129,117 @@ pub async fn update_startup_settings( Ok(()) } -/// App-settings key for the "auto-install updates on launch" switch. -const AUTO_INSTALL_UPDATES_KEY: &str = "updates.auto_install"; +/// Default update policy applied to newly installed servers. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerUpdateSettings { + pub default_update_policy: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_checked_at: Option, +} -/// Whether the app downloads + installs updates automatically on launch -/// (then relaunches into the new version). Default **true** — a missing -/// setting means auto-install is on. +/// Get the app-wide default server update policy. #[tauri::command] -pub async fn get_auto_install_updates(app_state: State<'_, AppState>) -> Result { - let stored = app_state +pub async fn get_server_update_settings( + app_state: State<'_, AppState>, +) -> Result { + let policy = app_state .settings_repository - .get(AUTO_INSTALL_UPDATES_KEY) + .get("servers.default_update_policy") .await - .map_err(|e| e.to_string())?; - Ok(stored.map(|v| v != "false").unwrap_or(true)) + .ok() + .flatten() + .unwrap_or_else(|| UpdatePolicy::Notify.as_db_str().to_string()); + + Ok(ServerUpdateSettings { + default_update_policy: policy, + last_checked_at: app_state + .settings_repository + .get("servers.last_version_probe_at") + .await + .ok() + .flatten(), + }) } -/// Enable/disable automatic update installation on launch. Persisted. +/// Persist the app-wide default server update policy. #[tauri::command] -pub async fn set_auto_install_updates( - enabled: bool, +pub async fn update_server_update_settings( + settings: ServerUpdateSettings, app_state: State<'_, AppState>, -) -> Result { +) -> Result<(), String> { + let policy = UpdatePolicy::from_db_str(&settings.default_update_policy); app_state .settings_repository - .set(AUTO_INSTALL_UPDATES_KEY, &enabled.to_string()) + .set("servers.default_update_policy", policy.as_db_str()) .await - .map_err(|e| e.to_string())?; - info!("[Settings] Auto-install updates set to {}", enabled); - Ok(enabled) + .map_err(|e| format!("Failed to save default update policy: {}", e))?; + Ok(()) } -/// App-settings key for the update channel ("stable" | "prerelease"). -const UPDATE_CHANNEL_KEY: &str = "updates.channel"; -/// Default update channel when the setting is missing. -const UPDATE_CHANNEL_STABLE: &str = "stable"; -const UPDATE_CHANNEL_PRERELEASE: &str = "prerelease"; - -/// Normalize an arbitrary stored/incoming value to a known channel, defaulting -/// to "stable". Keeps the gateway between the frontend and the updater header -/// strict so a corrupt setting can never select an unknown channel. -fn normalize_channel(raw: &str) -> &'static str { - if raw.eq_ignore_ascii_case(UPDATE_CHANNEL_PRERELEASE) { - UPDATE_CHANNEL_PRERELEASE - } else { - UPDATE_CHANNEL_STABLE - } +/// Build a version probe service wired to the desktop app state and event bus. +fn build_version_probe( + app_state: &AppState, + application_services: &ApplicationServices, +) -> ServerVersionProbeService { + ServerVersionProbeService::new( + app_state.installed_server_repository.clone(), + app_state.settings_repository.clone(), + application_services.event_bus.clone(), + ) } -/// Which update channel the app follows. The frontend sends this as the -/// `X-Mcpmux-Channel` header on update checks so the resolver returns the -/// newest stable or pre-release manifest. Default **stable** — a missing -/// setting means the stable channel. +/// Probe all notify/auto package-managed servers for available updates. #[tauri::command] -pub async fn get_update_channel(app_state: State<'_, AppState>) -> Result { - let stored = app_state - .settings_repository - .get(UPDATE_CHANNEL_KEY) - .await - .map_err(|e| e.to_string())?; - Ok(stored - .map(|v| normalize_channel(&v).to_string()) - .unwrap_or_else(|| UPDATE_CHANNEL_STABLE.to_string())) +pub async fn check_all_server_updates( + app_state: State<'_, AppState>, + application_services: State<'_, Arc>, +) -> Result { + let probe = build_version_probe(&app_state, &application_services); + let summary = probe.probe_all().await.map_err(|e| e.to_string())?; + Ok(json!({ + "checked": summary.checked, + "updatesAvailable": summary.updates_available, + "checkedAt": summary.checked_at.to_rfc3339(), + })) } -/// Set the update channel ("stable" | "prerelease"). Unknown values are -/// coerced to "stable". Persisted; returns the normalized value actually saved. +/// Probe one installed server for package updates. #[tauri::command] -pub async fn set_update_channel( - channel: String, +pub async fn check_server_version( + space_id: String, + server_id: String, app_state: State<'_, AppState>, -) -> Result { - let normalized = normalize_channel(&channel); - app_state - .settings_repository - .set(UPDATE_CHANNEL_KEY, normalized) + application_services: State<'_, Arc>, +) -> Result { + let probe = build_version_probe(&app_state, &application_services); + let result = probe + .probe_server(&space_id, &server_id) .await .map_err(|e| e.to_string())?; - info!("[Settings] Update channel set to {}", normalized); - Ok(normalized.to_string()) + Ok(json!({ + "spaceId": result.space_id, + "serverId": result.server_id, + "currentVersion": result.current_version, + "latestVersion": result.latest_version, + "updateAvailable": result.update_available, + "checkedAt": result.checked_at.to_rfc3339(), + })) +} + +/// Check if app should start hidden (for auto-launch with --hidden flag) +pub fn should_start_hidden() -> bool { + let args: Vec = std::env::args().collect(); + args.contains(&"--hidden".to_string()) } -/// App-settings key for the "ask to map new folders" prompt switch. const WORKSPACE_MAPPING_PROMPT_KEY: &str = "workspaces.mapping_prompt_enabled"; -/// Interpret a stored value for the workspace mapping-prompt toggle. Missing or -/// any non-`"false"` value means **enabled** — the prompt is on by default, so -/// only an explicit opt-out turns it off. fn mapping_prompt_enabled_from(stored: Option<&str>) -> bool { - stored.map(|v| v != "false").unwrap_or(true) + stored.map(|s| s != "false").unwrap_or(true) } -/// Whether McpMux pops the "map this folder?" sheet when a connected client -/// opens a folder that has no explicit binding (it's on the default Starter -/// set). Default **true**. Users who find the prompt noisy can turn it off -/// here or via the link in the sheet itself. +/// Get whether the "map this folder?" prompt is enabled. Default true. #[tauri::command] pub async fn get_workspace_mapping_prompt_enabled( app_state: State<'_, AppState>, @@ -229,8 +252,7 @@ pub async fn get_workspace_mapping_prompt_enabled( Ok(mapping_prompt_enabled_from(stored.as_deref())) } -/// Enable/disable the "map this folder?" prompt. Persisted; returns the value -/// actually saved. +/// Enable/disable the "map this folder?" prompt. Persisted; returns the value actually saved. #[tauri::command] pub async fn set_workspace_mapping_prompt_enabled( enabled: bool, @@ -245,16 +267,119 @@ pub async fn set_workspace_mapping_prompt_enabled( Ok(enabled) } -/// Check if app should start hidden (for auto-launch with --hidden flag) -pub fn should_start_hidden() -> bool { - let args: Vec = std::env::args().collect(); - args.contains(&"--hidden".to_string()) +/// Get the current value of the meta-tools master switch. +/// +/// When disabled, the gateway hides the entire `mcpmux_*` namespace from +/// connected MCP clients — no introspection, no self-management. Default +/// ON. +#[tauri::command] +pub async fn get_meta_tools_enabled(app_state: State<'_, AppState>) -> Result { + match app_state + .settings_repository + .get("gateway.meta_tools_enabled") + .await + { + Ok(Some(v)) => Ok(!matches!(v.as_str(), "false" | "0")), + _ => Ok(true), + } +} + +/// Flip the meta-tools master switch. The change takes effect on the NEXT +/// `list_tools` / `call_tool` from any connected client — existing cached +/// tool lists are invalidated by the usual `tools/list_changed` push. +#[tauri::command] +pub async fn set_meta_tools_enabled( + enabled: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + "gateway.meta_tools_enabled", + if enabled { "true" } else { "false" }, + ) + .await + .map_err(|e| format!("Failed to save meta_tools_enabled: {}", e))?; + info!("[Settings] meta_tools_enabled = {}", enabled); + Ok(()) +} + +/// Web admin HTTP server settings (loopback-only remote UI). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminWebSettings { + pub enabled: bool, + pub port: u16, + pub trust_cf_access: bool, + pub cf_team_domain: String, } -// The meta-tools master switch moved out of global app-settings into per-Space -// built-in-server config — see `commands::builtin_servers` -// (`list_builtin_servers` / `set_builtin_server_enabled` / -// `set_builtin_tool_enabled`). +/// Load web admin settings from the app settings store. +#[tauri::command] +pub async fn get_admin_web_settings( + app_state: State<'_, AppState>, +) -> Result { + let settings = AppSettingsService::new(app_state.settings_repository.clone()); + Ok(AdminWebSettings { + enabled: settings.get_admin_enabled().await, + port: settings.get_admin_port().await, + trust_cf_access: settings.get_admin_trust_cf_access().await, + cf_team_domain: settings + .get_admin_cf_team_domain() + .await + .unwrap_or_default(), + }) +} + +/// Persist web admin settings and restart the admin server to apply. +#[tauri::command] +pub async fn update_admin_web_settings( + settings: AdminWebSettings, + app: tauri::AppHandle, + app_state: State<'_, AppState>, + admin_state: State<'_, Arc>>, + gateway_state: State<'_, Arc>>, + server_manager_state: State<'_, Arc>>, + application_services: State<'_, Arc>, +) -> Result<(), String> { + if settings.port < 1024 { + return Err("Admin port must be between 1024 and 65535".to_string()); + } + if settings.trust_cf_access && settings.cf_team_domain.trim().is_empty() { + return Err( + "Cloudflare team domain is required when Trust CF Access is enabled".to_string(), + ); + } + + let store = AppSettingsService::new(app_state.settings_repository.clone()); + store + .set_admin_enabled(settings.enabled) + .await + .map_err(|e| format!("Failed to save admin_enabled: {}", e))?; + store + .set_admin_port(settings.port) + .await + .map_err(|e| format!("Failed to save admin_port: {}", e))?; + store + .set_admin_trust_cf_access(settings.trust_cf_access) + .await + .map_err(|e| format!("Failed to save admin_trust_cf_access: {}", e))?; + store + .set_admin_cf_team_domain(Some(settings.cf_team_domain.trim())) + .await + .map_err(|e| format!("Failed to save admin_cf_team_domain: {}", e))?; + + reload_admin_server( + app, + admin_state.inner().clone(), + gateway_state.inner().clone(), + server_manager_state.inner().clone(), + application_services.event_bus.clone(), + ) + .await; + + Ok(()) +} #[cfg(test)] mod tests { @@ -350,40 +475,4 @@ mod tests { assert!(!settings.start_minimized); assert!(!settings.close_to_tray); } - - #[test] - fn test_normalize_channel_prerelease_variants() { - assert_eq!(normalize_channel("prerelease"), UPDATE_CHANNEL_PRERELEASE); - assert_eq!(normalize_channel("Prerelease"), UPDATE_CHANNEL_PRERELEASE); - assert_eq!(normalize_channel("PRERELEASE"), UPDATE_CHANNEL_PRERELEASE); - } - - #[test] - fn test_normalize_channel_defaults_to_stable() { - assert_eq!(normalize_channel("stable"), UPDATE_CHANNEL_STABLE); - assert_eq!(normalize_channel(""), UPDATE_CHANNEL_STABLE); - assert_eq!(normalize_channel("beta"), UPDATE_CHANNEL_STABLE); - assert_eq!(normalize_channel("garbage"), UPDATE_CHANNEL_STABLE); - } - - #[test] - fn test_normalize_channel_returns_canonical_static() { - // Always returns one of the two canonical lowercase tokens. - for input in ["StAbLe", "pre", "prerelease", "x"] { - let out = normalize_channel(input); - assert!(out == UPDATE_CHANNEL_STABLE || out == UPDATE_CHANNEL_PRERELEASE); - } - } - - #[test] - fn test_mapping_prompt_enabled_defaults_on() { - // Missing setting → on by default. - assert!(mapping_prompt_enabled_from(None)); - // Only an explicit "false" disables it. - assert!(!mapping_prompt_enabled_from(Some("false"))); - assert!(mapping_prompt_enabled_from(Some("true"))); - // Any unexpected value is treated as enabled (fail-open to the default). - assert!(mapping_prompt_enabled_from(Some(""))); - assert!(mapping_prompt_enabled_from(Some("garbage"))); - } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ca7a8fb5..e909acd5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -131,6 +131,21 @@ fn get_version() -> String { env!("CARGO_PKG_VERSION").to_string() } +/// Return git/build metadata stamped into the binary by `build.rs`. +/// +/// The admin UI uses this to detect a stale SPA bundle — it compares the +/// SHA here against the one baked into the on-disk `web-admin` bundle, and +/// prompts the user to run `pnpm build:web:admin` after pulling new UI changes. +#[tauri::command] +fn get_build_info() -> serde_json::Value { + serde_json::json!({ + "git_sha": env!("MCPMUX_BUILD_GIT_SHA"), + "git_branch": env!("MCPMUX_BUILD_GIT_BRANCH"), + "commit_time": env!("MCPMUX_BUILD_COMMIT_TIME"), + "build_time": env!("MCPMUX_BUILD_TIME"), + }) +} + /// Get the on-disk bundle version (macOS only). /// /// After a Homebrew Cask upgrade, the `.app` bundle on disk has the new version @@ -883,6 +898,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_version, get_bundle_version, + get_build_info, // Space commands commands::list_spaces, commands::get_space, @@ -911,6 +927,12 @@ pub fn run() { commands::set_server_enabled, commands::set_server_oauth_connected, commands::save_server_inputs, + commands::set_server_display_name, + commands::clone_server, + commands::is_clone_id_available, + commands::suggest_clone_suffix, + commands::list_clone_dependents, + commands::update_server_package, // FeatureSet commands commands::list_feature_sets, commands::list_feature_sets_by_space, @@ -959,6 +981,9 @@ pub fn run() { commands::delete_workspace_appearance, commands::upload_workspace_icon, commands::resolve_workspace_icon_path, + // Meta-tools toggle + commands::get_meta_tools_enabled, + commands::set_meta_tools_enabled, // Meta-tool approval (self-management mcpmux_* tools) commands::respond_to_meta_tool_approval, commands::list_meta_tool_grants, @@ -1037,10 +1062,12 @@ pub fn run() { // Startup settings commands commands::get_startup_settings, commands::update_startup_settings, - commands::get_auto_install_updates, - commands::set_auto_install_updates, - commands::get_update_channel, - commands::set_update_channel, + commands::get_server_update_settings, + commands::update_server_update_settings, + commands::check_all_server_updates, + commands::check_server_version, + commands::get_admin_web_settings, + commands::update_admin_web_settings, commands::get_workspace_mapping_prompt_enabled, commands::set_workspace_mapping_prompt_enabled, ]) diff --git a/apps/desktop/src-tauri/src/services/admin_server.rs b/apps/desktop/src-tauri/src/services/admin_server.rs index 9e45ee8d..4823d0ee 100644 --- a/apps/desktop/src-tauri/src/services/admin_server.rs +++ b/apps/desktop/src-tauri/src/services/admin_server.rs @@ -415,10 +415,7 @@ pub async fn start_admin_server_if_enabled( return; } - let port = settings - .get_admin_port() - .await - .unwrap_or(mcpmux_gateway::DEFAULT_ADMIN_PORT); + let port = settings.get_admin_port().await; let trust_cf_access = settings.get_admin_trust_cf_access().await; let cf_team_domain = settings.get_admin_cf_team_domain().await; diff --git a/apps/desktop/src/components/StaleBuildBanner.tsx b/apps/desktop/src/components/StaleBuildBanner.tsx new file mode 100644 index 00000000..580ff7f0 --- /dev/null +++ b/apps/desktop/src/components/StaleBuildBanner.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react'; +import { AlertTriangle, X } from 'lucide-react'; +import { getBuildInfo } from '@/lib/api/app'; +import { isTauri } from '@/lib/backend/shell'; + +/** + * Warns when the web-admin static bundle was built from a different commit than + * the running backend — the usual cause of a stale or incomplete dashboard. + */ +export function StaleBuildBanner() { + const [isStale, setIsStale] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (import.meta.env.DEV || isTauri()) { + return; + } + + const spaSha = import.meta.env.VITE_BUILD_GIT_SHA; + if (!spaSha) { + return; + } + + getBuildInfo() + .then(({ git_sha }) => { + if (git_sha && git_sha !== spaSha) { + setIsStale(true); + } + }) + .catch(() => {}); + }, []); + + if (!isStale || dismissed) { + return null; + } + + return ( +
+
+ +
+

+ UI bundle is out of date +

+

+ Run{' '} + + pnpm build:web:admin + {' '} + to rebuild the dashboard. +

+
+
+ +
+ ); +} diff --git a/apps/desktop/src/features/servers/CloneAccountModal.tsx b/apps/desktop/src/features/servers/CloneAccountModal.tsx new file mode 100644 index 00000000..21d8329b --- /dev/null +++ b/apps/desktop/src/features/servers/CloneAccountModal.tsx @@ -0,0 +1,329 @@ +/** + * CloneAccountModal — wizard for adding another account of an installed MCP server. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { Copy, Loader2, X } from 'lucide-react'; +import type { ServerViewModel } from '@/types/registry'; +import { + CLONE_SUFFIX_SUGGESTIONS, + cloneServer, + deriveCloneAlias, + deriveCloneServerId, + isCloneIdAvailable, + suggestCloneSuffix, + type ClonedInstalledServer, +} from '@/lib/api/serverClone'; + +export interface CloneAccountModalProps { + open: boolean; + spaceId: string; + sourceServer: ServerViewModel; + onClose: () => void; + /** Called after a successful clone with the new install row. */ + onCloned: (cloned: ClonedInstalledServer) => void; +} + +/** + * Modal for creating a suffixed clone of an installed server in the same space. + */ +export function CloneAccountModal({ + open, + spaceId, + sourceServer, + onClose, + onCloned, +}: CloneAccountModalProps) { + const [suffix, setSuffix] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [isChecking, setIsChecking] = useState(false); + const [isAvailable, setIsAvailable] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false); + + const trimmedSuffix = suffix.trim(); + const trimmedDisplayName = displayName.trim(); + const displayNamePlaceholder = trimmedSuffix + ? `${sourceServer.name} (${trimmedSuffix})` + : sourceServer.name; + + const previewId = deriveCloneServerId(sourceServer.id, suffix); + const previewAlias = deriveCloneAlias(suffix); + const hasSuffix = suffix.trim().length > 0; + const hasCollision = hasSuffix && isAvailable === false; + + useEffect(() => { + if (!open) { + return; + } + + let cancelled = false; + + const loadSuggestion = async () => { + setIsLoadingSuggestion(true); + setSubmitError(null); + try { + const suggested = await suggestCloneSuffix(spaceId, sourceServer.id); + if (!cancelled) { + setSuffix(suggested); + } + } catch (e) { + if (!cancelled) { + setSuffix(CLONE_SUFFIX_SUGGESTIONS[0]); + setSubmitError(String(e)); + } + } finally { + if (!cancelled) { + setIsLoadingSuggestion(false); + } + } + }; + + loadSuggestion(); + + return () => { + cancelled = true; + }; + }, [open, spaceId, sourceServer.id]); + + useEffect(() => { + if (!open || !hasSuffix) { + setIsAvailable(null); + setIsChecking(false); + return; + } + + let cancelled = false; + setIsChecking(true); + + const timer = setTimeout(async () => { + try { + const available = await isCloneIdAvailable(spaceId, sourceServer.id, suffix); + if (!cancelled) { + setIsAvailable(available); + } + } catch { + if (!cancelled) { + setIsAvailable(null); + } + } finally { + if (!cancelled) { + setIsChecking(false); + } + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [open, spaceId, sourceServer.id, suffix, hasSuffix]); + + /** Submit the clone request. */ + const handleSubmit = useCallback(async () => { + if (!hasSuffix || hasCollision || isChecking) { + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + const cloned = await cloneServer( + spaceId, + sourceServer.id, + suffix, + undefined, + trimmedDisplayName.length > 0 ? trimmedDisplayName : undefined + ); + onCloned(cloned); + onClose(); + } catch (e) { + setSubmitError(String(e)); + } finally { + setIsSubmitting(false); + } + }, [ + hasSuffix, + hasCollision, + isChecking, + spaceId, + sourceServer.id, + suffix, + trimmedDisplayName, + onCloned, + onClose, + ]); + + if (!open) { + return null; + } + + const canSubmit = + hasSuffix && !hasCollision && !isChecking && !isSubmitting && !isLoadingSuggestion; + + return ( +
+
+
+
+
+ +
+
+

+ Add another account +

+

+ Create a separate copy of {sourceServer.name} +

+
+
+ +
+ +
+
+ +

+ The name shown in the server list. Leave blank to use the default. +

+ setDisplayName(e.target.value)} + placeholder={displayNamePlaceholder} + className="input w-full" + disabled={isSubmitting} + data-testid="clone-display-name-input" + /> +
+ +
+ +

+ A short tag appended to the server ID to distinguish this account. +

+ setSuffix(e.target.value)} + placeholder="e.g. work, personal, prod" + className={`input w-full ${hasCollision ? 'border-[rgb(var(--error))]' : ''}`} + disabled={isLoadingSuggestion || isSubmitting} + data-testid="clone-suffix-input" + /> + {hasCollision && ( +

+ This account label is already taken in this space. +

+ )} +
+ +
+

Suggestions

+
+ {CLONE_SUFFIX_SUGGESTIONS.map((suggestion) => ( + + ))} +
+
+ + {hasSuffix && ( +
+
+ Server ID + + {previewId || '—'} + +
+
+ Tool prefix + + {previewAlias ? `${previewAlias}_*` : '—'} + +
+ {isChecking && ( +
+ + Checking availability… +
+ )} +
+ )} + +

+ The clone starts disabled. Configure its credentials before enabling. +

+ + {submitError && ( +

+ {submitError} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/features/servers/ServerActionMenu.tsx b/apps/desktop/src/features/servers/ServerActionMenu.tsx index bbec31e9..2435675b 100644 --- a/apps/desktop/src/features/servers/ServerActionMenu.tsx +++ b/apps/desktop/src/features/servers/ServerActionMenu.tsx @@ -1,33 +1,60 @@ -/** - * ServerActionMenu - Overflow menu for server actions - * - * Actions: - * - Configure: Edit server inputs - * - Refresh: Quick reconnect with existing credentials - * - Reconnect: Logout + re-authenticate (OAuth only) - * - View Logs: Open log viewer - * - View Definition: View server definition JSON - * - Uninstall: Remove server - */ - -import { useState, useRef, useEffect } from 'react'; -import { MoreVertical, Settings, RefreshCw, RotateCcw, FileText, Code, Trash2 } from 'lucide-react'; +import { + MoreVertical, + Settings, + RefreshCw, + RotateCcw, + FileText, + Code, + Trash2, + Copy, + Download, + ArrowUpCircle, + Search, + Lock, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuAction, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@mcpmux/ui'; +import type { UpdatePolicy } from '@/lib/api/settings'; export interface ServerActionMenuProps { serverId: string; serverName: string; + /** Whether the server has credential / config inputs. Servers with no inputs still show + * Configure so the display name can be edited. */ hasInputs: boolean; isOAuth: boolean; isEnabled: boolean; isConnected: boolean; + /** npx/uvx stdio transport — eligible for package update actions. */ + isPackageManaged?: boolean; + /** Per-server update policy from installed state. */ + updatePolicy?: UpdatePolicy; + /** Whether a newer package version is available. */ + hasUpdateAvailable?: boolean; + /** Latest registry version when an update is available. */ + latestVersion?: string | null; + /** Show "Add another account…" for registry/manual installs (not clones-of-clones). */ + canCloneAccount?: boolean; onConfigure: () => void; onRefresh: () => void; onReconnect: () => void; + onUpdateNow?: () => void; + onCheckForUpdate?: () => void; + onLockToCurrentVersion?: () => void; onViewLogs: () => void; onViewDefinition: () => void; + onCloneAccount?: () => void; onUninstall: () => void; } +/** + * Overflow menu for per-server actions (configure, logs, uninstall, etc.). + */ export function ServerActionMenu({ serverId, serverName: _serverName, @@ -35,149 +62,134 @@ export function ServerActionMenu({ isOAuth, isEnabled, isConnected: _isConnected, + isPackageManaged = false, + updatePolicy = 'notify', + hasUpdateAvailable = false, + latestVersion, + canCloneAccount = false, onConfigure, onRefresh, onReconnect, + onUpdateNow, + onCheckForUpdate, + onLockToCurrentVersion, onViewLogs, onViewDefinition, + onCloneAccount, onUninstall, }: ServerActionMenuProps) { - const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); - const buttonRef = useRef(null); - - // Close menu when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - menuRef.current && - !menuRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - } - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); - - // Close menu on escape - useEffect(() => { - function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { - setIsOpen(false); - } - } - - if (isOpen) { - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - } - }, [isOpen]); - - const handleAction = (action: () => void) => { - setIsOpen(false); - action(); - }; + const showUpdateNow = + isPackageManaged && + isEnabled && + onUpdateNow != null && + (updatePolicy === 'auto' || hasUpdateAvailable); + const showCheckForUpdate = + isPackageManaged && onCheckForUpdate != null && updatePolicy !== 'pinned'; + const showLockToCurrentVersion = + isPackageManaged && onLockToCurrentVersion != null && updatePolicy !== 'pinned'; + const updateLabel = latestVersion + ? `Update available: v${latestVersion}` + : 'Update available'; return ( -
- - - {isOpen && ( -
+ + - )} - - {/* Refresh - visible when enabled (quick reconnect with existing creds) */} - {isEnabled && ( - - )} - - {/* Reconnect - OAuth only (logout + re-auth) */} - {isOAuth && isEnabled && ( - + + {hasUpdateAvailable && ( + )} - - {/* View Logs - always visible */} - - - {/* View Definition - always visible */} - - - {/* Separator */} -
- - {/* Uninstall - always visible, destructive */} - -
- )} -
+ + + + {hasUpdateAvailable && showUpdateNow && ( + + )} + + {isEnabled && ( + + )} + {showUpdateNow && !hasUpdateAvailable && ( + + )} + {showCheckForUpdate && ( + + )} + {showLockToCurrentVersion && ( + + )} + {isOAuth && isEnabled && ( + + )} + + + {canCloneAccount && onCloneAccount && ( + + )} + + + + ); } diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index fec0bdf4..533933af 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -44,6 +44,17 @@ import { ServerLogViewer } from '@/components/ServerLogViewer'; import { ConfigEditorModal } from '@/components/ConfigEditorModal'; import { ServerDefinitionModal } from '@/components/ServerDefinitionModal'; import { SourceBadge } from '@/components/SourceBadge'; +import { CloneAccountModal } from './CloneAccountModal'; +import { UninstallSourceWithClonesDialog } from './UninstallSourceWithClonesDialog'; +import { + shouldShowPackageUpdate, + resolveCurrentPackageVersion, + isPackageManagedTransport, + isValidSemver, +} from './server-update-policy.helpers'; +import type { ClonedInstalledServer } from '@/lib/api/serverClone'; +import { listCloneDependents } from '@/lib/api/serverClone'; +import { checkServerVersion } from '@/lib/api/settings'; // Helper to merge definitions with states (same as registryStore) function mergeDefinitionsWithStates( @@ -81,6 +92,13 @@ function mergeDefinitionsWithStates( env_overrides: state?.env_overrides ?? {}, args_append: state?.args_append ?? [], extra_headers: state?.extra_headers ?? {}, + display_name_override: state?.display_name_override, + cloned_from: state?.cloned_from, + update_policy: state?.update_policy, + pinned_version: state?.pinned_version, + latest_available_version: state?.latest_available_version, + current_version: state?.current_version, + version_checked_at: state?.version_checked_at, } as ServerViewModel; }); } @@ -111,6 +129,13 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo env_overrides: state.env_overrides ?? {}, args_append: state.args_append ?? [], extra_headers: state.extra_headers ?? {}, + display_name_override: state.display_name_override, + cloned_from: state.cloned_from, + update_policy: state.update_policy, + pinned_version: state.pinned_version, + latest_available_version: state.latest_available_version, + current_version: state.current_version, + version_checked_at: state.version_checked_at, } as ServerViewModel; } catch (e) { console.warn('[ServersPage] Failed to parse cached_definition, using minimal fallback:', e); @@ -147,6 +172,13 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo env_overrides: state.env_overrides ?? {}, args_append: state.args_append ?? [], extra_headers: state.extra_headers ?? {}, + display_name_override: state.display_name_override, + cloned_from: state.cloned_from, + update_policy: state.update_policy, + pinned_version: state.pinned_version, + latest_available_version: state.latest_available_version, + current_version: state.current_version, + version_checked_at: state.version_checked_at, } as ServerViewModel; } @@ -198,6 +230,15 @@ export function ServersPage() { null ); + // Clone account wizard state + const [cloneModalServer, setCloneModalServer] = useState(null); + + // Uninstall source-with-clones confirmation + const [uninstallClonesDialog, setUninstallClonesDialog] = useState<{ + server: ServerViewModel; + dependents: ClonedInstalledServer[]; + } | null>(null); + // Config editor state const [editConfigSpace, setEditConfigSpace] = useState<{ id: string; name: string } | null>(null); @@ -766,28 +807,62 @@ export function ServersPage() { } }; + const performUninstall = async (serverIds: string[]) => { + const { uninstallServer } = await import('@/lib/api/registry'); + const { disconnectServer } = await import('@/lib/api/gateway'); + + for (const serverId of serverIds) { + const srv = installedServers.find((s) => s.id === serverId); + if (gatewayRunning && srv?.enabled && viewSpace) { + try { + await disconnectServer(serverId, viewSpace.id); + } catch (e) { + console.warn(`[ServersPage] Failed to disconnect server from gateway:`, e); + } + } + await uninstallServer(serverId, viewSpace?.id ?? ''); + } + await loadData(); + }; + const handleUninstall = async (server: ServerViewModel) => { + if (!viewSpace) { + return; + } + + const dependents = await listCloneDependents(viewSpace.id, server.id); + if (dependents.length > 0) { + setUninstallClonesDialog({ server, dependents }); + return; + } + const { getUninstallLabel } = await import('@/components/SourceBadge'); const actionLabel = getUninstallLabel(server.installation_source); setActionLoading(`uninstall-${server.id}`); try { - const { uninstallServer } = await import('@/lib/api/registry'); - const { disconnectServer } = await import('@/lib/api/gateway'); + await performUninstall([server.id]); + showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); + } catch (e) { + showToast(String(e), 'error'); + } finally { + setActionLoading(null); + } + }; - if (gatewayRunning && server.enabled && viewSpace) { - try { - await disconnectServer(server.id, viewSpace.id); - } catch (e) { - console.warn(`[ServersPage] Failed to disconnect server from gateway:`, e); - } - } + const handleUninstallSourceOnly = async () => { + if (!uninstallClonesDialog) { + return; + } - // ServerAppService handles source-aware cleanup automatically: - // - UserConfig: removes from JSON file + DB - // - Registry/ManualEntry: just removes from DB - await uninstallServer(server.id, viewSpace?.id ?? ''); - await loadData(); + const { server } = uninstallClonesDialog; + const { getUninstallLabel } = await import('@/components/SourceBadge'); + const actionLabel = getUninstallLabel(server.installation_source); + + setUninstallClonesDialog(null); + setActionLoading(`uninstall-${server.id}`); + try { + await performUninstall([server.id]); showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); } catch (e) { showToast(String(e), 'error'); @@ -796,6 +871,150 @@ export function ServersPage() { } }; + const handleUninstallAllWithClones = async () => { + if (!uninstallClonesDialog) { + return; + } + + const { server, dependents } = uninstallClonesDialog; + const serverIds = [...dependents.map((d) => d.server_id), server.id]; + + setUninstallClonesDialog(null); + setActionLoading(`uninstall-${server.id}`); + try { + await performUninstall(serverIds); + showToast( + `${server.name} and ${dependents.length} clone${dependents.length === 1 ? '' : 's'} removed`, + 'success' + ); + } catch (e) { + showToast(String(e), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleCloneComplete = async (cloned: ClonedInstalledServer) => { + await loadData(); + showToast(`${cloned.server_name ?? cloned.server_id} created`, 'success'); + }; + + const handleUpdateNow = async (server: ServerViewModel) => { + if (!viewSpace) { + return; + } + + setActionLoading(`update-${server.id}`); + try { + const { updateServerPackage } = await import('@/lib/api/serverManager'); + await updateServerPackage(viewSpace.id, server.id); + showToast(`Updating ${server.name}…`, 'info'); + await loadData(); + } catch (e) { + showToast(String(e), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleCheckForUpdate = async (server: ServerViewModel) => { + if (!viewSpace) { + return; + } + + setActionLoading(`check-update-${server.id}`); + try { + const result = await checkServerVersion(viewSpace.id, server.id); + setInstalledServers((current) => + current.map((entry) => { + if (entry.id !== server.id) { + return entry; + } + return { + ...entry, + latest_available_version: result.latestVersion, + version_checked_at: result.checkedAt, + }; + }) + ); + + if (result.updateAvailable && result.latestVersion) { + showToast(`Update available: v${result.latestVersion}`, 'info'); + } else { + showToast(`${server.name} is up to date`, 'success'); + } + } catch (e) { + showToast(String(e), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleLockToCurrentVersion = async (server: ServerViewModel) => { + if (!viewSpace) { + return; + } + + setActionLoading(`lock-version-${server.id}`); + try { + const { saveServerInputs } = await import('@/lib/api/registry'); + + let version = + resolveCurrentPackageVersion({ + pinnedVersion: server.pinned_version, + transportCommand: + server.transport.type === 'stdio' ? server.transport.command : undefined, + transportArgs: + server.transport.type === 'stdio' ? server.transport.args : undefined, + installedVersion: server.current_version, + }) ?? server.latest_available_version; + + if (!version) { + const probe = await checkServerVersion(viewSpace.id, server.id); + version = probe.currentVersion ?? probe.latestVersion; + setInstalledServers((current) => + current.map((entry) => { + if (entry.id !== server.id) { + return entry; + } + return { + ...entry, + latest_available_version: probe.latestVersion ?? entry.latest_available_version, + version_checked_at: probe.checkedAt, + }; + }) + ); + } + + if (!version || !isValidSemver(version)) { + showToast('Cannot pin: version could not be determined', 'error'); + return; + } + + await saveServerInputs( + server.id, + server.input_values, + viewSpace.id, + server.env_overrides, + server.args_append, + server.extra_headers, + 'pinned', + version + ); + + if (server.enabled) { + await retryConnectionV2(server.id); + } + + showToast(`Locked to v${version}`, 'success'); + await loadData(); + } catch (error) { + showToast(String(error), 'error'); + } finally { + setActionLoading(null); + } + }; + const handleStartGateway = async () => { try { const outcome = await gatewayControl.start(); @@ -1284,13 +1503,43 @@ export function ServersPage() { isConnected={ serverAction === 'running' || serverAction === 'connected_auto' } + isPackageManaged={ + server.transport.type === 'stdio' && + isPackageManagedTransport(server.transport.command) + } + updatePolicy={server.update_policy ?? 'notify'} + hasUpdateAvailable={ + server.transport.type === 'stdio' && + shouldShowPackageUpdate({ + updatePolicy: server.update_policy ?? 'notify', + latestVersion: server.latest_available_version, + currentVersion: resolveCurrentPackageVersion({ + pinnedVersion: server.pinned_version, + transportCommand: server.transport.command, + transportArgs: server.transport.args, + installedVersion: server.current_version, + }), + transportCommand: server.transport.command, + transportArgs: server.transport.args, + }) + } + latestVersion={server.latest_available_version} + canCloneAccount={ + !server.cloned_from && + (server.installation_source?.type === 'registry' || + server.installation_source?.type === 'manual_entry') + } onConfigure={() => handleConfigureClick(server)} onRefresh={() => handleRefresh(server)} onReconnect={() => handleReconnect(server)} + onUpdateNow={() => handleUpdateNow(server)} + onCheckForUpdate={() => handleCheckForUpdate(server)} + onLockToCurrentVersion={() => handleLockToCurrentVersion(server)} onViewLogs={() => setLogViewerServer({ id: server.id, name: server.name })} onViewDefinition={() => setDefinitionServer({ id: server.id, name: server.name }) } + onCloneAccount={() => setCloneModalServer(server)} onUninstall={() => handleUninstall(server)} />
@@ -1826,6 +2075,29 @@ export function ServersPage() {
)} + {/* Uninstall Source With Clones Dialog */} + {uninstallClonesDialog && ( + setUninstallClonesDialog(null)} + onUninstallSourceOnly={handleUninstallSourceOnly} + onUninstallAll={handleUninstallAllWithClones} + /> + )} + + {/* Clone Account Modal */} + {cloneModalServer && viewSpace && ( + setCloneModalServer(null)} + onCloned={handleCloneComplete} + /> + )} + {/* Log Viewer Modal */} {logViewerServer && ( void; + onUninstallSourceOnly: () => void; + onUninstallAll: () => void; +} + +/** + * Warn when uninstalling a source server that still has account clones in the same space. + */ +export function UninstallSourceWithClonesDialog({ + open, + sourceName, + dependents, + onCancel, + onUninstallSourceOnly, + onUninstallAll, +}: UninstallSourceWithClonesDialogProps) { + if (!open) { + return null; + } + + const dependentLabels = dependents.map((dependent) => + resolveInstalledDisplayName({ + server_id: dependent.server_id, + server_name: dependent.server_name ?? null, + display_name_override: dependent.display_name_override ?? null, + }) + ); + const dependentList = dependentLabels.join(', '); + const totalCount = dependents.length + 1; + + return ( +
+
event.stopPropagation()} + data-testid="uninstall-clones-dialog" + > +
+
+ +
+
+

Uninstall account clones?

+

+ {sourceName} has{' '} + {dependents.length === 1 ? '1 account clone' : `${dependents.length} account clones`}{' '} + ({dependentList}). +

+

+ You can uninstall only the source, or uninstall all {totalCount} installs at once. +

+
+
+
+ + + +
+
+
+ ); +} diff --git a/apps/desktop/src/features/servers/index.ts b/apps/desktop/src/features/servers/index.ts index 79baef30..984afb2a 100644 --- a/apps/desktop/src/features/servers/index.ts +++ b/apps/desktop/src/features/servers/index.ts @@ -1 +1,4 @@ export { ServersPage } from './ServersPage'; +export { CloneAccountModal } from './CloneAccountModal'; +export { UninstallSourceWithClonesDialog } from './UninstallSourceWithClonesDialog'; +export { ServerActionMenu } from './ServerActionMenu'; diff --git a/apps/desktop/src/features/servers/server-display-name.helpers.ts b/apps/desktop/src/features/servers/server-display-name.helpers.ts new file mode 100644 index 00000000..7a3eadf9 --- /dev/null +++ b/apps/desktop/src/features/servers/server-display-name.helpers.ts @@ -0,0 +1,32 @@ +import type { InstalledServerState, ServerDefinition } from '@/types/registry'; + +/** + * Resolve the effective display label for an installed server. + * + * Mirrors the Rust `InstalledServer::display_name()` precedence so the UI and + * meta-tools agree on what to show. Order: + * 1. `display_name_override` (user-supplied, survives user-config sync) + * 2. `server_name` cached from the definition at install time + * 3. `definition.name` if a parsed registry definition is provided + * 4. Final segment of `server_id` + */ +export function resolveInstalledDisplayName( + state: Pick, + definition?: Pick | null +): string { + const override = state.display_name_override?.trim(); + if (override) { + return override; + } + + if (state.server_name && state.server_name.length > 0) { + return state.server_name; + } + + if (definition?.name) { + return definition.name; + } + + const tail = state.server_id.split('/').pop(); + return tail && tail.length > 0 ? tail : state.server_id; +} diff --git a/apps/desktop/src/features/servers/server-pending-updates.helpers.ts b/apps/desktop/src/features/servers/server-pending-updates.helpers.ts new file mode 100644 index 00000000..bb49595a --- /dev/null +++ b/apps/desktop/src/features/servers/server-pending-updates.helpers.ts @@ -0,0 +1,91 @@ +import type { InstalledServerState, ServerDefinition } from '@/types/registry'; + +import { + isPackageManagedTransport, + resolveCurrentPackageVersion, + shouldShowPackageUpdate, +} from './server-update-policy.helpers'; + +/** One installed server with a newer package available on the registry. */ +export interface ServerPendingUpdate { + spaceId: string; + serverId: string; + name: string; + enabled: boolean; + currentVersion: string | null; + latestVersion: string; +} + +/** Resolve a server definition from cached install JSON or the discovery map. */ +function resolveServerDefinition( + state: InstalledServerState, + definitionById: Map +): ServerDefinition | null { + if (state.cached_definition) { + try { + return JSON.parse(state.cached_definition) as ServerDefinition; + } catch { + return definitionById.get(state.server_id) ?? null; + } + } + return definitionById.get(state.server_id) ?? null; +} + +/** + * Build the list of package-managed installs that have a newer version available. + */ +export function buildPendingServerUpdates( + installed: InstalledServerState[], + definitions: ServerDefinition[] = [] +): ServerPendingUpdate[] { + const definitionById = new Map(definitions.map((definition) => [definition.id, definition])); + const pending: ServerPendingUpdate[] = []; + + for (const state of installed) { + const definition = resolveServerDefinition(state, definitionById); + if (!definition || definition.transport.type !== 'stdio') { + continue; + } + + const command = definition.transport.command; + if (!isPackageManagedTransport(command)) { + continue; + } + + const currentVersion = resolveCurrentPackageVersion({ + pinnedVersion: state.pinned_version, + transportCommand: command, + transportArgs: definition.transport.args, + installedVersion: state.current_version, + }); + const latestVersion = state.latest_available_version; + if ( + !latestVersion || + !shouldShowPackageUpdate({ + updatePolicy: state.update_policy ?? 'notify', + latestVersion, + currentVersion, + transportCommand: command, + transportArgs: definition.transport.args, + }) + ) { + continue; + } + + pending.push({ + spaceId: state.space_id, + serverId: state.server_id, + name: state.server_name ?? definition.name ?? state.server_id, + enabled: state.enabled, + currentVersion, + latestVersion, + }); + } + + return pending.sort((left, right) => left.name.localeCompare(right.name)); +} + +/** Row key for per-server update-in-progress tracking. */ +export function pendingUpdateKey(update: ServerPendingUpdate): string { + return `${update.spaceId}:${update.serverId}`; +} diff --git a/apps/desktop/src/features/servers/server-update-policy.helpers.ts b/apps/desktop/src/features/servers/server-update-policy.helpers.ts new file mode 100644 index 00000000..e99ae3b6 --- /dev/null +++ b/apps/desktop/src/features/servers/server-update-policy.helpers.ts @@ -0,0 +1,244 @@ +import type { UpdatePolicy } from '@/lib/api/settings'; + +/** Update policy option for display in the UI. */ +export interface UpdatePolicyOption { + value: UpdatePolicy; + label: string; + description: string; +} + +/** Per-server update policy labels for Configure and Settings. */ +export function getUpdatePolicyOptions(): UpdatePolicyOption[] { + return [ + { + value: 'notify', + label: 'Notify', + description: 'Check for updates and show a badge when one is available.', + }, + { + value: 'auto', + label: 'Auto', + description: 'Automatically update to the latest version on connect.', + }, + { + value: 'pinned', + label: 'Pinned', + description: 'Lock to a specific version; never auto-update.', + }, + ]; +} + +/** Basic semver pattern (major.minor.patch with optional pre-release/build). */ +const BASIC_SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +const FLOATING_NPM_TAGS = new Set([ + 'latest', + '*', + 'next', + 'beta', + 'canary', + 'stable', + 'release', +]); + +/** Returns true for npm dist-tags that do not pin an exact semver. */ +export function npmVersionTagIsFloating(tag: string): boolean { + return FLOATING_NPM_TAGS.has(tag.trim().replace(/^@/, '').toLowerCase()); +} + +/** Returns true when `version` matches a basic semver shape. */ +export function isValidSemver(version: string): boolean { + return BASIC_SEMVER_PATTERN.test(version.trim()); +} + +/** Returns true when the stdio transport uses npx or uvx/uv (package-managed). */ +export function isPackageManagedTransport(command: string | undefined): boolean { + if (!command) { + return false; + } + return command === 'npx' || command === 'uvx' || command === 'uv'; +} + +/** Single UI guard mirroring Rust `probe_update_available` plus pinned/auto exclusion. */ +export function shouldShowPackageUpdate(input: { + updatePolicy: UpdatePolicy; + latestVersion: string | null | undefined; + currentVersion: string | null | undefined; + transportCommand?: string; + transportArgs?: string[]; +}): boolean { + if (input.updatePolicy === 'pinned' || input.updatePolicy === 'auto') { + return false; + } + + if (!input.latestVersion) { + return false; + } + + if (packageUsesFloatingNpmTag(input.transportCommand, input.transportArgs)) { + return false; + } + + if (!input.currentVersion) { + return false; + } + + return isNewerVersion(input.latestVersion, input.currentVersion); +} + +/** Returns true when the npx package arg already tracks a floating dist-tag like `@latest`. */ +function packageUsesFloatingNpmTag( + transportCommand: string | undefined, + transportArgs: string[] | undefined +): boolean { + if (transportCommand !== 'npx' || !transportArgs) { + return false; + } + const packageArg = findNpxPackageArg(transportArgs); + if (!packageArg) { + return false; + } + const version = splitNpmPackageArg(packageArg)[1]; + return version != null && npmVersionTagIsFloating(version); +} + +/** Parse a semver-ish version string into numeric segments for comparison. */ +function parseVersionParts(version: string): number[] { + return version + .trim() + .replace(/^v/, '') + .replace(/^=/, '') + .split(/[^0-9]+/) + .filter(Boolean) + .map((part) => Number.parseInt(part, 10)) + .filter((part) => !Number.isNaN(part)); +} + +/** Returns true when `latest` is strictly newer than `current`. */ +function isNewerVersion(latest: string, current: string): boolean { + const latestParts = parseVersionParts(latest); + const currentParts = parseVersionParts(current); + const maxLen = Math.max(latestParts.length, currentParts.length); + + for (let index = 0; index < maxLen; index += 1) { + const latestPart = latestParts[index] ?? 0; + const currentPart = currentParts[index] ?? 0; + if (latestPart > currentPart) { + return true; + } + if (latestPart < currentPart) { + return false; + } + } + + return latest !== current; +} + +/** + * Derive the effective current version for update badge display. + * + * Precedence: + * 1. `pinnedVersion` — explicit user pin (`UpdatePolicy::Pinned`) + * 2. `installedVersion` — actual installed version written by the backend probe + * 3. `argVersion` — semver baked into transport args, used as a cold-cache fallback + */ +export function resolveCurrentPackageVersion(input: { + pinnedVersion?: string | null; + transportCommand?: string; + transportArgs?: string[]; + installedVersion?: string | null; +}): string | null { + if (input.pinnedVersion) { + return input.pinnedVersion; + } + + if (input.installedVersion) { + return input.installedVersion; + } + + return resolveArgPackageVersion(input.transportCommand, input.transportArgs); +} + +/** Extract an exact semver baked into the npx/uvx package argument, if any. */ +function resolveArgPackageVersion( + transportCommand: string | undefined, + transportArgs: string[] | undefined +): string | null { + if (transportCommand === 'npx' && transportArgs) { + const packageArg = findNpxPackageArg(transportArgs); + if (!packageArg) { + return null; + } + const version = splitNpmPackageArg(packageArg)[1]; + if (!version || npmVersionTagIsFloating(version) || !isValidSemver(version)) { + return null; + } + return version; + } + + if ((transportCommand === 'uvx' || transportCommand === 'uv') && transportArgs) { + const packageArg = findUvxPackageArg(transportCommand, transportArgs); + if (!packageArg) { + return null; + } + const eqIndex = packageArg.indexOf('=='); + if (eqIndex >= 0) { + const version = packageArg.slice(eqIndex + 2) || null; + if (!version || !isValidSemver(version)) { + return null; + } + return version; + } + } + + return null; +} + +/** Split an npm package arg into name and optional version tag. */ +function splitNpmPackageArg(packageArg: string): [string, string | null] { + if (packageArg.startsWith('@') && packageArg.indexOf('@', 1) > 0) { + const scopedSplit = packageArg.indexOf('@', 1); + return [packageArg.slice(0, scopedSplit), packageArg.slice(scopedSplit + 1) || null]; + } + const atIndex = packageArg.lastIndexOf('@'); + if (atIndex > 0) { + return [packageArg.slice(0, atIndex), packageArg.slice(atIndex + 1) || null]; + } + return [packageArg, null]; +} + +/** Locate the npm package argument after `-y` / `--yes`. */ +function findNpxPackageArg(args: string[]): string | undefined { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if ( + (arg === '-y' || arg === '--yes') && + index + 1 < args.length && + !args[index + 1].startsWith('-') + ) { + return args[index + 1]; + } + } + return args.find((arg) => !arg.startsWith('-') && arg !== '--'); +} + +/** Locate the first positional package arg for uvx / uv run. */ +function findUvxPackageArg(command: string, args: string[]): string | undefined { + if (command === 'uvx') { + return args.find((arg) => !arg.startsWith('-')); + } + if (command === 'uv' && args[0] === 'run') { + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg.startsWith('-')) { + if (arg === '-m' || arg === '--module') { + index += 1; + } + continue; + } + return arg; + } + } + return undefined; +} diff --git a/apps/desktop/src/features/settings/BuildStampPanel.tsx b/apps/desktop/src/features/settings/BuildStampPanel.tsx new file mode 100644 index 00000000..b71d56b8 --- /dev/null +++ b/apps/desktop/src/features/settings/BuildStampPanel.tsx @@ -0,0 +1,120 @@ +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useBuildStamp, type UseBuildStampResult } from './use-build-stamp.hook'; +import type { BuildStampRow } from '@/lib/build-info.helpers'; + +type BuildStampContext = 'desktop' | 'web-admin'; + +/** + * Load build stamp data and render the panel (for parents that do not already hold stamp state). + */ +export function BuildStampPanel({ context }: { context: BuildStampContext }) { + const stamp = useBuildStamp(); + return ; +} + +/** + * Presentational build stamp panel — accepts preloaded stamp data. + */ +export function BuildStampPanelContent({ + context, + stamp, +}: { + context: BuildStampContext; + stamp: UseBuildStampResult; +}) { + const { backendRows, spaRows, spaSha, backendSha, hasMismatch, loading, error } = stamp; + + if (loading) { + return ( +
+ + Loading build info… +
+ ); + } + + if (error) { + return ( +

+ Build info unavailable. +

+ ); + } + + return ( +
+ + + {hasMismatch ? ( +
+ +
+

+ {context === 'web-admin' ? 'Web admin bundle is out of date' : 'UI bundle mismatch'} +

+

+ {context === 'web-admin' ? ( + <> + SPA SHA {spaSha} ≠ backend SHA {backendSha}.{' '} + Run{' '} + + pnpm build:web:admin + {' '} + to rebuild. + + ) : ( + `SPA SHA ${spaSha} ≠ backend SHA ${backendSha}.` + )} +

+
+
+ ) : null} + + {hasMismatch ? ( + + ) : null} +
+ ); +} + +/** Render a group of labeled build stamp rows. */ +function BuildStampRowGroup({ + rows, + heading, + testIdPrefix = '', +}: { + rows: BuildStampRow[]; + heading?: string; + testIdPrefix?: string; +}) { + return ( +
+ {heading ? ( +

+ {heading} +

+ ) : null} + {rows.map((row) => ( +
+ {row.label} + + {row.value} + +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/features/settings/ServerPendingUpdatesList.tsx b/apps/desktop/src/features/settings/ServerPendingUpdatesList.tsx new file mode 100644 index 00000000..84ba19b6 --- /dev/null +++ b/apps/desktop/src/features/settings/ServerPendingUpdatesList.tsx @@ -0,0 +1,101 @@ +import { Button } from '@mcpmux/ui'; +import { Download, Loader2 } from 'lucide-react'; + +import { + pendingUpdateKey, + type ServerPendingUpdate, +} from '@/features/servers/server-pending-updates.helpers'; + +interface ServerPendingUpdatesListProps { + updates: ServerPendingUpdate[]; + updatingServerKey: string | null; + updatingAll: boolean; + onUpdateOne: (update: ServerPendingUpdate) => void; + onUpdateAll: () => void; +} + + +/** + * List of servers with available package updates and per-row / bulk actions. + */ +export function ServerPendingUpdatesList({ + updates, + updatingServerKey, + updatingAll, + onUpdateOne, + onUpdateAll, +}: ServerPendingUpdatesListProps) { + if (updates.length === 0) { + return null; + } + + const enabledCount = updates.filter((update) => update.enabled).length; + + return ( +
+
+

+ {updates.length === 1 + ? '1 update available' + : `${updates.length} updates available`} +

+ +
+ +
    + {updates.map((update) => { + const rowKey = pendingUpdateKey(update); + const isUpdating = updatingAll || updatingServerKey === rowKey; + + return ( +
  • +
    +

    {update.name}

    +

    + {update.currentVersion ? `v${update.currentVersion}` : 'current'} →{' '} + v{update.latestVersion} +

    +
    + +
  • + ); + })} +
+
+ ); +} diff --git a/apps/desktop/src/features/settings/ServerUpdatesSection.tsx b/apps/desktop/src/features/settings/ServerUpdatesSection.tsx new file mode 100644 index 00000000..c1c4bcf4 --- /dev/null +++ b/apps/desktop/src/features/settings/ServerUpdatesSection.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@mcpmux/ui'; +import { Loader2, Package, RefreshCw } from 'lucide-react'; +import { + checkAllServerUpdates, + getServerUpdateSettings, + updateServerUpdateSettings, + type ServerUpdateSettings, + type UpdatePolicy, +} from '@/lib/api/settings'; +import { discoverServers, listInstalledServers } from '@/lib/api/registry'; +import { updateServerPackage } from '@/lib/api/serverManager'; +import { + buildPendingServerUpdates, + type ServerPendingUpdate, +} from '@/features/servers/server-pending-updates.helpers'; +import { useDomainEvents } from '@/hooks/useDomainEvents'; +import { pendingUpdateKey } from '@/features/servers/server-pending-updates.helpers'; +import { ServerPendingUpdatesList } from './ServerPendingUpdatesList'; +import { getUpdatePolicyOptions } from '@/features/servers/server-update-policy.helpers'; + +/** Format an ISO timestamp for display in settings. */ +function formatCheckedAt(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed.toLocaleString(); +} + +interface ServerUpdatesSectionProps { + onSuccess?: (title: string, message?: string) => void; + onError?: (title: string, message?: string) => void; + onInfo?: (title: string, message?: string) => void; +} + +/** + * Settings section for the app-wide default server update policy. + */ +export function ServerUpdatesSection({ + onSuccess, + onError, + onInfo, +}: ServerUpdatesSectionProps) { + const [settings, setSettings] = useState({ + defaultUpdatePolicy: 'notify', + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [checkingAll, setCheckingAll] = useState(false); + const [pendingUpdates, setPendingUpdates] = useState([]); + const [loadingPending, setLoadingPending] = useState(false); + const [updatingServerKey, setUpdatingServerKey] = useState(null); + const [updatingAll, setUpdatingAll] = useState(false); + const { subscribe } = useDomainEvents(); + + const policyOptions = useMemo(() => getUpdatePolicyOptions(), []); + + /** Load installed servers and derive which have newer packages available. */ + const refreshPendingUpdates = useCallback(async () => { + setLoadingPending(true); + try { + const [installedResult, definitionsResult] = await Promise.allSettled([ + listInstalledServers(), + discoverServers(), + ]); + const installed = installedResult.status === 'fulfilled' ? installedResult.value : []; + const definitions = + definitionsResult.status === 'fulfilled' ? definitionsResult.value : []; + setPendingUpdates(buildPendingServerUpdates(installed, definitions)); + } catch (err) { + console.error('[Settings] Failed to load pending server updates:', err); + } finally { + setLoadingPending(false); + } + }, []); + + useEffect(() => { + const load = async () => { + try { + const loaded = await getServerUpdateSettings(); + setSettings(loaded); + } catch (err) { + console.error('[Settings] Failed to load server update settings:', err); + } finally { + setLoading(false); + } + }; + void load(); + void refreshPendingUpdates(); + }, [refreshPendingUpdates]); + + useEffect(() => { + return subscribe('server-update-available', () => { + void refreshPendingUpdates(); + }); + }, [refreshPendingUpdates, subscribe]); + + useEffect(() => { + return subscribe('server-changed', () => { + void refreshPendingUpdates(); + }); + }, [refreshPendingUpdates, subscribe]); + + /** Persist a new default update policy for newly installed servers. */ + const handlePolicyChange = async (policy: UpdatePolicy) => { + const previous = settings; + const next = { ...settings, defaultUpdatePolicy: policy }; + setSettings(next); + setSaving(true); + try { + await updateServerUpdateSettings(next); + } catch (err) { + console.error('[Settings] Failed to save server update settings:', err); + setSettings(previous); + } finally { + setSaving(false); + } + }; + + /** Reconnect one server so transport resolution picks up the latest package. */ + const handleUpdateOne = async (update: ServerPendingUpdate) => { + const rowKey = pendingUpdateKey(update); + setUpdatingServerKey(rowKey); + try { + await updateServerPackage(update.spaceId, update.serverId); + onSuccess?.( + `Updated ${update.name}`, + `Reconnecting with v${update.latestVersion}…` + ); + await refreshPendingUpdates(); + } catch (err) { + console.error('[Settings] Failed to update server:', err); + onError?.(`Failed to update ${update.name}`, String(err)); + } finally { + setUpdatingServerKey(null); + } + }; + + /** Reconnect every enabled server that has a pending package update. */ + const handleUpdateAll = async () => { + const targets = pendingUpdates.filter((update) => update.enabled); + if (targets.length === 0) { + onInfo?.('No enabled servers to update', 'Enable a server first, then update.'); + return; + } + + setUpdatingAll(true); + let succeeded = 0; + const failures: string[] = []; + + for (const update of targets) { + try { + await updateServerPackage(update.spaceId, update.serverId); + succeeded += 1; + } catch (err) { + failures.push(update.name); + console.error(`[Settings] Failed to update ${update.name}:`, err); + } + } + + await refreshPendingUpdates(); + setUpdatingAll(false); + + if (failures.length === 0) { + onSuccess?.( + `${succeeded} server${succeeded === 1 ? '' : 's'} updated`, + 'Reconnecting with latest packages…' + ); + return; + } + + if (succeeded > 0) { + onInfo?.( + `${succeeded} of ${targets.length} servers updated`, + `Failed: ${failures.join(', ')}` + ); + return; + } + + onError?.('All updates failed', failures.join(', ')); + }; + + /** Trigger a bulk npm/uv version probe across eligible servers. */ + const handleCheckAll = async () => { + setCheckingAll(true); + try { + const result = await checkAllServerUpdates(); + setSettings((current) => ({ + ...current, + lastCheckedAt: result.checkedAt, + })); + await refreshPendingUpdates(); + + if (result.checked === 0) { + onInfo?.('No eligible servers', 'Only npx/uvx servers with notify policy are probed.'); + } else if (result.updatesAvailable > 0) { + onInfo?.( + `${result.updatesAvailable} update${result.updatesAvailable === 1 ? '' : 's'} available`, + 'Update from the list below.' + ); + } else { + onSuccess?.( + 'All servers are up to date', + `Checked ${result.checked} server${result.checked === 1 ? '' : 's'}.` + ); + } + } catch (err) { + console.error('[Settings] Failed to check all server updates:', err); + onError?.('Version check failed', String(err)); + } finally { + setCheckingAll(false); + } + }; + + const lastCheckedLabel = formatCheckedAt(settings.lastCheckedAt); + + return ( + + + + + Server updates + + + Control how McpMux handles package updates for npx/uvx servers. + + + + {loading ? ( +
+ + Loading… +
+ ) : ( + <> +
+
+ +

+ { + policyOptions.find((option) => option.value === settings.defaultUpdatePolicy) + ?.description + } +

+
+ +
+ +
+
+

Check for updates

+

+ {lastCheckedLabel ? `Last checked: ${lastCheckedLabel}` : 'Never checked'} +

+
+ +
+ + {loadingPending ? ( +
+ + Loading pending updates… +
+ ) : ( + + )} + + )} +
+
+ ); +} diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 46f7f41c..c15716ca 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -42,6 +42,7 @@ import { useSetPendingSettingsSection, } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; +import { ServerUpdatesSection } from './ServerUpdatesSection'; import { useGatewayControl } from '@/features/gateway/useGatewayControl'; import { CONTRIBUTE, openExternal } from '@/lib/contribute'; @@ -529,6 +530,12 @@ export function SettingsPage() { + {/* Server Package Updates Section */} + success(title, message)} + onError={(title, message) => error(title, message)} + /> + {/* Startup & System Tray Section - always show toggles so e2e and slow backends see the section */} diff --git a/apps/desktop/src/features/settings/index.ts b/apps/desktop/src/features/settings/index.ts index ef119a39..6d31a5a4 100644 --- a/apps/desktop/src/features/settings/index.ts +++ b/apps/desktop/src/features/settings/index.ts @@ -1,2 +1,6 @@ export { SettingsPage } from './SettingsPage'; export { UpdateChecker } from './UpdateChecker'; +export { BuildStampPanel, BuildStampPanelContent } from './BuildStampPanel'; +export { ServerUpdatesSection } from './ServerUpdatesSection'; +export { ServerPendingUpdatesList } from './ServerPendingUpdatesList'; +export { useBuildStamp } from './use-build-stamp.hook'; diff --git a/apps/desktop/src/features/settings/use-build-stamp.hook.ts b/apps/desktop/src/features/settings/use-build-stamp.hook.ts new file mode 100644 index 00000000..379364e8 --- /dev/null +++ b/apps/desktop/src/features/settings/use-build-stamp.hook.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { getBuildInfo, getVersion } from '@/lib/backend'; +import { + backendBuildInfoRows, + buildStampDisplayRows, + getSpaBuildStamp, + type BuildStampRow, +} from '@/lib/build-info.helpers'; + +/** Result of loading version and build stamp metadata for Settings UI. */ +export interface UseBuildStampResult { + version: string; + backendRows: BuildStampRow[]; + spaRows: BuildStampRow[]; + spaSha: string; + backendSha: string; + hasMismatch: boolean; + loading: boolean; + error: string | null; +} + +/** + * Load app version, SPA compile-time stamp, and backend build info for Settings display. + */ +export function useBuildStamp(): UseBuildStampResult { + const spaStamp = getSpaBuildStamp(); + const [version, setVersion] = useState(''); + const [backendRows, setBackendRows] = useState([]); + const [backendSha, setBackendSha] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const spaRows = buildStampDisplayRows(spaStamp); + const spaSha = spaStamp.gitSha; + const hasMismatch = Boolean(spaSha && backendSha && spaSha !== backendSha); + + useEffect(() => { + let cancelled = false; + + Promise.all([getVersion(), getBuildInfo()]) + .then(([nextVersion, buildInfo]) => { + if (cancelled) return; + setVersion(nextVersion); + setBackendSha(buildInfo.git_sha); + setBackendRows(backendBuildInfoRows(buildInfo)); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return { + version, + backendRows, + spaRows, + spaSha, + backendSha, + hasMismatch, + loading, + error, + }; +} diff --git a/apps/desktop/src/hooks/useDomainEvents.ts b/apps/desktop/src/hooks/useDomainEvents.ts index bfea45b5..79a1e02b 100644 --- a/apps/desktop/src/hooks/useDomainEvents.ts +++ b/apps/desktop/src/hooks/useDomainEvents.ts @@ -58,7 +58,9 @@ export type DomainEventChannel = | 'client-changed' | 'grants-changed' | 'gateway-changed' - | 'mcp-notification'; + | 'mcp-notification' + | 'server-version-checked' + | 'server-update-available'; /** Base event payload */ export interface DomainEventPayload { @@ -160,6 +162,20 @@ export interface MCPNotificationPayload extends DomainEventPayload { server_id: string; } +/** Server version probe completed payload */ +export interface ServerVersionCheckedPayload extends DomainEventPayload { + space_id: string; + server_id: string; +} + +/** Server update available payload */ +export interface ServerUpdateAvailablePayload extends DomainEventPayload { + space_id: string; + server_id: string; + current_version?: string | null; + latest_version?: string | null; +} + /** Payload type map for type safety */ export interface PayloadTypeMap { 'space-changed': SpaceChangedPayload; @@ -172,6 +188,8 @@ export interface PayloadTypeMap { 'grants-changed': GrantsChangedPayload; 'gateway-changed': GatewayChangedPayload; 'mcp-notification': MCPNotificationPayload; + 'server-version-checked': ServerVersionCheckedPayload; + 'server-update-available': ServerUpdateAvailablePayload; } /** Type-safe callback for specific channels */ @@ -201,6 +219,8 @@ const ALL_CHANNELS: DomainEventChannel[] = [ 'grants-changed', 'gateway-changed', 'mcp-notification', + 'server-version-checked', + 'server-update-available', ]; /** diff --git a/apps/desktop/src/lib/api/registry.ts b/apps/desktop/src/lib/api/registry.ts index 4128f0ee..c5f6baac 100644 --- a/apps/desktop/src/lib/api/registry.ts +++ b/apps/desktop/src/lib/api/registry.ts @@ -87,7 +87,18 @@ export async function saveServerInputs( spaceId: string, envOverrides?: Record, argsAppend?: string[], - extraHeaders?: Record + extraHeaders?: Record, + updatePolicy?: string, + pinnedVersion?: string ): Promise { - return invoke('save_server_inputs', { id, inputValues, spaceId, envOverrides, argsAppend, extraHeaders }); + return invoke('save_server_inputs', { + id, + inputValues, + spaceId, + envOverrides, + argsAppend, + extraHeaders, + updatePolicy, + pinnedVersion, + }); } diff --git a/apps/desktop/src/lib/api/serverManager.ts b/apps/desktop/src/lib/api/serverManager.ts index ae111ea5..ce1b5d85 100644 --- a/apps/desktop/src/lib/api/serverManager.ts +++ b/apps/desktop/src/lib/api/serverManager.ts @@ -179,6 +179,13 @@ export async function logoutServer( return invoke("logout_server", { spaceId, serverId }); } +/** + * Reconnect and apply latest package resolution (explicit user update). + */ +export async function updateServerPackage(spaceId: string, serverId: string): Promise { + return invoke('update_server_package', { spaceId, serverId }); +} + /** * Disconnect server - Stop connection but keep enabled and preserve credentials * diff --git a/apps/desktop/src/types/registry.ts b/apps/desktop/src/types/registry.ts index 991b7bce..a501ca6b 100644 --- a/apps/desktop/src/types/registry.ts +++ b/apps/desktop/src/types/registry.ts @@ -2,6 +2,8 @@ * Registry types for MCP server browsing and installation. */ +import type { UpdatePolicy } from '@/lib/api/settings'; + /** Input definition from registry */ export interface InputDefinition { id: string; @@ -101,6 +103,20 @@ export interface InstalledServerState { extra_headers: Record; oauth_connected: boolean; source: InstallationSource; // How this server was installed + /** User-supplied display label that survives user-config sync. */ + display_name_override?: string | null; + /** Source server ID if this was cloned. */ + cloned_from?: string | null; + /** Package update policy for npx/uvx stdio transports. */ + update_policy?: UpdatePolicy; + /** Pinned semver when policy is `pinned`. */ + pinned_version?: string | null; + /** Latest registry version from the most recent probe. */ + latest_available_version?: string | null; + /** Resolved installed version from the most recent probe. */ + current_version?: string | null; + /** When the version probe last ran for this install. */ + version_checked_at?: string | null; created_at: string; updated_at: string; } @@ -123,6 +139,20 @@ export interface ServerViewModel extends ServerDefinition { args_append?: string[]; /** Extra HTTP headers (http only) */ extra_headers?: Record; + /** User-supplied display label that survives user-config sync. */ + display_name_override?: string | null; + /** Source server ID if this was cloned. */ + cloned_from?: string | null; + /** Package update policy for npx/uvx stdio transports. */ + update_policy?: UpdatePolicy; + /** Pinned semver when policy is `pinned`. */ + pinned_version?: string | null; + /** Latest registry version from the most recent probe. */ + latest_available_version?: string | null; + /** Resolved installed version from the most recent probe. */ + current_version?: string | null; + /** When the version probe last ran for this install. */ + version_checked_at?: string | null; } /** Registry category */ diff --git a/crates/mcpmux-core/src/application/server.rs b/crates/mcpmux-core/src/application/server.rs index 80181b03..53d5e22c 100644 --- a/crates/mcpmux-core/src/application/server.rs +++ b/crates/mcpmux-core/src/application/server.rs @@ -8,7 +8,10 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::domain::{DomainEvent, InstallationSource, InstalledServer, ServerDefinition}; +use crate::domain::{ + config::UserServerEntry, DomainEvent, InstallationSource, InstalledServer, ServerDefinition, + UpdatePolicy, +}; use crate::event_bus::EventSender; use crate::repository::{CredentialRepository, InstalledServerRepository, ServerFeatureRepository}; @@ -210,6 +213,7 @@ impl ServerAppService { /// Update server configuration (inputs, env overrides, args, headers) /// /// Emits: `ServerConfigUpdated` + #[allow(clippy::too_many_arguments)] pub async fn update_config( &self, space_id: Uuid, @@ -218,6 +222,8 @@ impl ServerAppService { env_overrides: Option>, args_append: Option>, extra_headers: Option>, + update_policy: Option, + pinned_version: Option, ) -> Result { let space_id_str = space_id.to_string(); @@ -237,6 +243,12 @@ impl ServerAppService { if let Some(headers) = extra_headers { server.extra_headers = headers; } + if let Some(policy) = update_policy { + server.update_policy = policy; + } + if let Some(version) = pinned_version { + server.pinned_version = Some(version); + } server.updated_at = chrono::Utc::now(); self.server_repo.update(&server).await?; @@ -314,6 +326,196 @@ impl ServerAppService { Ok(()) } + /// Set (or clear) the display name override for an installed server. + /// + /// Empty/whitespace values clear the override. Emits `ServerConfigUpdated`. + pub async fn set_display_name_override( + &self, + space_id: Uuid, + server_id: &str, + value: Option, + ) -> Result { + let space_id_str = space_id.to_string(); + + let server = self + .server_repo + .get_by_server_id(&space_id_str, server_id) + .await? + .ok_or_else(|| anyhow!("Server not installed"))?; + + let normalized = value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + self.server_repo + .set_display_name_override(&server.id, normalized.clone()) + .await?; + + info!( + space_id = %space_id, + server_id = server_id, + has_override = normalized.is_some(), + "[ServerAppService] Updated display name override" + ); + + self.event_sender.emit(DomainEvent::ServerConfigUpdated { + space_id, + server_id: server_id.to_string(), + }); + + let mut updated = server; + updated.display_name_override = normalized; + Ok(updated) + } + + /// Clone an existing installed server into a new server ID with the given suffix. + /// + /// Emits: `ServerInstalled` + pub async fn clone_server( + &self, + space_id: Uuid, + source_server_id: &str, + suffix: &str, + alias_override: Option<&str>, + display_name_override: Option<&str>, + ) -> Result { + let space_id_str = space_id.to_string(); + let new_server_id = Self::derive_clone_server_id(source_server_id, suffix)?; + + let source = self + .server_repo + .get_by_server_id(&space_id_str, source_server_id) + .await? + .ok_or_else(|| anyhow!("Source server not installed"))?; + + if self + .server_repo + .get_by_server_id(&space_id_str, &new_server_id) + .await? + .is_some() + { + return Err(anyhow!("Clone server ID already exists in this space")); + } + + let mut definition = source + .get_definition() + .ok_or_else(|| anyhow!("Source server has no cached definition"))?; + + let normalized_suffix = UserServerEntry::normalize_server_id(suffix); + let alias = alias_override + .map(UserServerEntry::normalize_alias) + .unwrap_or_else(|| normalized_suffix.clone()); + + definition.id = new_server_id.clone(); + definition.name = format!("{} ({})", source.display_name(), normalized_suffix); + definition.alias = Some(alias); + + let server = InstalledServer::new(&space_id_str, &new_server_id) + .with_definition(&definition) + .with_source(InstallationSource::ManualEntry) + .with_cloned_from(source_server_id) + .with_display_name_override(display_name_override) + .with_update_policy(source.update_policy) + .with_pinned_version(source.pinned_version.clone()) + .with_enabled(false); + + self.server_repo.install(&server).await?; + + info!( + space_id = %space_id, + source_server_id = source_server_id, + server_id = %new_server_id, + "[ServerAppService] Cloned server" + ); + + let event_name = server + .display_name_override + .clone() + .unwrap_or_else(|| definition.name.clone()); + + self.event_sender.emit(DomainEvent::ServerInstalled { + space_id, + server_id: new_server_id.clone(), + server_name: event_name, + }); + + Ok(server) + } + + /// Return whether a suffixed clone ID is available in the given space. + pub async fn is_clone_id_available( + &self, + space_id: Uuid, + source_server_id: &str, + suffix: &str, + ) -> Result { + let space_id_str = space_id.to_string(); + let new_server_id = match Self::derive_clone_server_id(source_server_id, suffix) { + Ok(id) => id, + Err(_) => return Ok(false), + }; + + Ok(self + .server_repo + .get_by_server_id(&space_id_str, &new_server_id) + .await? + .is_none()) + } + + /// List installed servers in a space that were cloned from the given source. + pub async fn list_clone_dependents( + &self, + space_id: &str, + source_server_id: &str, + ) -> Result> { + let servers = self.server_repo.list_for_space(space_id).await?; + Ok(servers + .into_iter() + .filter(|server| server.cloned_from.as_deref() == Some(source_server_id)) + .collect()) + } + + /// Suggest the first available default suffix for cloning a server. + pub async fn suggest_clone_suffix( + &self, + space_id: Uuid, + source_server_id: &str, + ) -> Result { + const DEFAULT_SUFFIXES: &[&str] = &["work", "personal", "prod", "staging"]; + + for suffix in DEFAULT_SUFFIXES { + if self + .is_clone_id_available(space_id, source_server_id, suffix) + .await? + { + return Ok((*suffix).to_string()); + } + } + + for index in 2..100 { + let suffix = index.to_string(); + if self + .is_clone_id_available(space_id, source_server_id, &suffix) + .await? + { + return Ok(suffix); + } + } + + Err(anyhow!("No available clone suffix")) + } + + /// Derive the normalized clone server ID from a base install ID and user suffix. + fn derive_clone_server_id(base_server_id: &str, suffix: &str) -> Result { + let normalized_suffix = UserServerEntry::normalize_server_id(suffix); + if normalized_suffix.is_empty() { + return Err(anyhow!("Clone suffix cannot be empty")); + } + + let composite = format!("{base_server_id}-{normalized_suffix}"); + Ok(UserServerEntry::normalize_server_id(&composite)) + } + /// Update OAuth connected status pub async fn set_oauth_connected( &self, diff --git a/crates/mcpmux-core/src/domain/config.rs b/crates/mcpmux-core/src/domain/config.rs index d306edcd..afa25333 100644 --- a/crates/mcpmux-core/src/domain/config.rs +++ b/crates/mcpmux-core/src/domain/config.rs @@ -140,7 +140,7 @@ impl UserServerEntry { /// Normalize a server ID for prefix compatibility /// Removes spaces and special characters, converts to lowercase /// IMPORTANT: No underscores - underscore is reserved as delimiter in qualified names (prefix_toolname) - fn normalize_server_id(id: &str) -> String { + pub fn normalize_server_id(id: &str) -> String { id.chars() .filter_map(|c| { if c.is_alphanumeric() { @@ -156,7 +156,7 @@ impl UserServerEntry { /// Normalize an alias to be underscore-free /// Underscores are replaced with hyphens since underscore is the prefix_toolname delimiter - fn normalize_alias(alias: &str) -> String { + pub fn normalize_alias(alias: &str) -> String { alias .chars() .map(|c| { diff --git a/crates/mcpmux-core/src/domain/event.rs b/crates/mcpmux-core/src/domain/event.rs index aa3b410c..fe954023 100644 --- a/crates/mcpmux-core/src/domain/event.rs +++ b/crates/mcpmux-core/src/domain/event.rs @@ -205,6 +205,19 @@ pub enum DomainEvent { /// Server was disabled (will disconnect) ServerDisabled { space_id: Uuid, server_id: String }, + /// Version probe completed for a server (fired regardless of whether an update is available). + ServerVersionChecked { space_id: Uuid, server_id: String }, + + /// Notify-mode probe found a newer package version than the installed/pinned one. + ServerUpdateAvailable { + space_id: Uuid, + server_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + current_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + latest_version: Option, + }, + // ════════════════════════════════════════════════════════════════════════ // SERVER CONNECTION STATE (Runtime) // ════════════════════════════════════════════════════════════════════════ @@ -437,6 +450,8 @@ impl DomainEvent { Self::ServerConfigUpdated { .. } => "server_config_updated", Self::ServerEnabled { .. } => "server_enabled", Self::ServerDisabled { .. } => "server_disabled", + Self::ServerVersionChecked { .. } => "server_version_checked", + Self::ServerUpdateAvailable { .. } => "server_update_available", Self::ServerStatusChanged { .. } => "server_status_changed", Self::ServerAuthProgress { .. } => "server_auth_progress", Self::ServerFeaturesRefreshed { .. } => "server_features_refreshed", @@ -515,6 +530,8 @@ impl DomainEvent { | Self::ServerConfigUpdated { space_id, .. } | Self::ServerEnabled { space_id, .. } | Self::ServerDisabled { space_id, .. } + | Self::ServerVersionChecked { space_id, .. } + | Self::ServerUpdateAvailable { space_id, .. } | Self::ServerStatusChanged { space_id, .. } | Self::ServerAuthProgress { space_id, .. } | Self::ServerFeaturesRefreshed { space_id, .. } @@ -553,6 +570,8 @@ impl DomainEvent { | Self::ServerConfigUpdated { server_id, .. } | Self::ServerEnabled { server_id, .. } | Self::ServerDisabled { server_id, .. } + | Self::ServerVersionChecked { server_id, .. } + | Self::ServerUpdateAvailable { server_id, .. } | Self::ServerStatusChanged { server_id, .. } | Self::ServerAuthProgress { server_id, .. } | Self::ServerFeaturesRefreshed { server_id, .. } diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index cf5e6118..003106e4 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -4,6 +4,7 @@ //! the implementation (SQLite, in-memory, etc.) use async_trait::async_trait; +use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::domain::{ @@ -126,6 +127,19 @@ pub trait InstalledServerRepository: Send + Sync { server_name: Option, cached_definition: Option, ) -> RepoResult<()>; + + /// Pass `None` to clear the override (UI falls back to `server_name` / + /// `cached_definition.name` / `server_id` tail). + async fn set_display_name_override(&self, id: &Uuid, value: Option) -> RepoResult<()>; + + /// Persist notify-mode version probe results for one installation. + async fn update_version_cache( + &self, + id: &Uuid, + latest_available_version: Option, + current_version: Option, + version_checked_at: DateTime, + ) -> RepoResult<()>; } /// ServerFeature repository trait diff --git a/crates/mcpmux-core/src/service/app_settings_service.rs b/crates/mcpmux-core/src/service/app_settings_service.rs index bcb85ac2..954f88fe 100644 --- a/crates/mcpmux-core/src/service/app_settings_service.rs +++ b/crates/mcpmux-core/src/service/app_settings_service.rs @@ -225,8 +225,10 @@ impl AppSettingsService { } /// Get the configured admin server port (defaults to `DEFAULT_ADMIN_PORT`). - pub async fn get_admin_port(&self) -> Option { - self.get_typed(keys::gateway::ADMIN_PORT).await + pub async fn get_admin_port(&self) -> u16 { + self.get_typed(keys::gateway::ADMIN_PORT) + .await + .unwrap_or(Self::DEFAULT_ADMIN_PORT) } /// Set the admin server port. @@ -261,11 +263,12 @@ impl AppSettingsService { self.get_string(keys::gateway::ADMIN_CF_TEAM_DOMAIN).await } - /// Set the Cloudflare team domain. - pub async fn set_admin_cf_team_domain(&self, domain: &str) -> anyhow::Result<()> { + /// Set the Cloudflare team domain (empty or None clears). + pub async fn set_admin_cf_team_domain(&self, domain: Option<&str>) -> anyhow::Result<()> { + let value = domain.unwrap_or("").trim(); info!("[Settings] Setting admin_cf_team_domain"); self.repository - .set(keys::gateway::ADMIN_CF_TEAM_DOMAIN, domain) + .set(keys::gateway::ADMIN_CF_TEAM_DOMAIN, value) .await } diff --git a/crates/mcpmux-gateway/src/admin/command_bridge/write.rs b/crates/mcpmux-gateway/src/admin/command_bridge/write.rs index 1a10aba3..e6960410 100644 --- a/crates/mcpmux-gateway/src/admin/command_bridge/write.rs +++ b/crates/mcpmux-gateway/src/admin/command_bridge/write.rs @@ -761,6 +761,8 @@ pub async fn save_server_inputs( body.env_overrides, body.args_append, body.extra_headers, + None, + None, ) .await?; as_json(installed) diff --git a/crates/mcpmux-gateway/src/admin/ui_events.rs b/crates/mcpmux-gateway/src/admin/ui_events.rs index 68f18d9b..ce9b5115 100644 --- a/crates/mcpmux-gateway/src/admin/ui_events.rs +++ b/crates/mcpmux-gateway/src/admin/ui_events.rs @@ -145,6 +145,30 @@ pub fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, Value) { "server_id": server_id, }), ), + DomainEvent::ServerVersionChecked { + space_id, + server_id, + } => ( + "server-version-checked", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + }), + ), + DomainEvent::ServerUpdateAvailable { + space_id, + server_id, + current_version, + latest_version, + } => ( + "server-update-available", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + "current_version": current_version, + "latest_version": latest_version, + }), + ), DomainEvent::ServerStatusChanged { space_id, server_id, diff --git a/crates/mcpmux-gateway/src/admin/write_runtime.rs b/crates/mcpmux-gateway/src/admin/write_runtime.rs index 19c8cbc7..1b96a079 100644 --- a/crates/mcpmux-gateway/src/admin/write_runtime.rs +++ b/crates/mcpmux-gateway/src/admin/write_runtime.rs @@ -11,7 +11,7 @@ use tokio::sync::RwLock; use tracing::warn; use uuid::Uuid; -use crate::pool::transport::resolution::build_transport_config; +use crate::pool::transport::resolution::{build_transport_config, TransportResolutionOptions}; use crate::pool::{ ConnectionContext, ConnectionResult, FeatureService, PoolService, ServerKey, ServerManager, }; @@ -199,6 +199,7 @@ impl GatewayWriteRuntime for LiveGatewayWriteRuntime { &server_definition.transport, &installed, Some(&self.data_dir), + TransportResolutionOptions::default(), ); let ctx = ConnectionContext::auto(space_uuid, server_id.clone(), transport); diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 0e68ba75..f2692708 100644 --- a/crates/mcpmux-gateway/src/pool/routing.rs +++ b/crates/mcpmux-gateway/src/pool/routing.rs @@ -55,6 +55,9 @@ pub struct ToolCallResult { pub is_error: bool, } +/// Default timeout for MCP tool calls (60 seconds) +const TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(60); + /// Actionable error when a server is not in the effective enable set. pub fn format_server_inactive_error(server_id: &str) -> String { format!( @@ -88,6 +91,33 @@ pub fn format_invoke_permission_denied( } } +/// Redirect message for direct backend `read_resource` attempts. +#[allow(dead_code)] +pub fn format_direct_read_redirect(uri: &str) -> String { + format!( + "Direct backend resource reads are not supported. \ + Use mcpmux_search_resources to discover readable URIs, then \ + mcpmux_read_resource to fetch one: \ + mcpmux_read_resource({{ \"uri\": \"{uri}\" }})" + ) +} + +/// Redirect message for direct backend `get_prompt` attempts. +#[allow(dead_code)] +pub fn format_direct_fetch_prompt_redirect( + qualified_name: &str, + server_id: &str, + prompt_name: &str, +) -> String { + format!( + "Direct backend prompt fetches are not supported. \ + Use mcpmux_search_prompts to discover fetchable prompts, then \ + mcpmux_fetch_prompt to fetch one: \ + mcpmux_fetch_prompt({{ \"server_id\": \"{server_id}\", \"prompt\": \"{prompt_name}\", \"args\": {{}} }}) \ + (qualified name was '{qualified_name}')" + ) +} + /// Actionable error when a server is not in the binding FeatureSet ACL. pub fn format_server_not_in_binding_error(server_id: &str) -> String { format!( @@ -97,8 +127,19 @@ pub fn format_server_not_in_binding_error(server_id: &str) -> String { ) } -/// Default timeout for MCP tool calls (60 seconds) -const TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(60); +/// Redirect message for direct backend `call_tool` attempts. +#[allow(dead_code)] +pub fn format_direct_call_redirect( + qualified_name: &str, + server_id: &str, + tool_name: &str, +) -> String { + format!( + "Direct backend tool calls are not supported. Use mcpmux_invoke_tool instead: \ + mcpmux_invoke_tool({{ \"server_id\": \"{server_id}\", \"tool\": \"{tool_name}\", \"args\": {{}} }}) \ + (qualified name was '{qualified_name}')" + ) +} /// RoutingService dispatches requests to backend MCP servers pub struct RoutingService { @@ -133,7 +174,7 @@ impl RoutingService { // Resolve feature sets to allowed features let allowed_features = self .feature_service - .get_tools_for_grants(&space_id_str, feature_set_ids) + .get_invokable_tools_for_grants(&space_id_str, feature_set_ids) .await?; // Filter to just tools @@ -232,58 +273,68 @@ impl RoutingService { ) -> Result { let space_id_str = space_id.to_string(); - // Authorize AND route in one step by matching the requested qualified - // name against the resolved feature set — using the SAME encoding the - // list path uses (`ServerFeature::qualified_name`). This guarantees - // "if it lists, it calls": the (server_id, tool_name) we route to come - // straight from the matched feature, so there's no dependency on the - // prefix-cache reverse lookup, which could be stale and surface a - // listed tool as "not allowed by the current grants". + // 1. Find the server that provides this tool + let (server_id, actual_tool_name) = self + .feature_service + .find_server_for_qualified_tool(&space_id_str, tool_name) + .await? + .ok_or_else(|| anyhow!("Tool '{}' not found", tool_name))?; + + // 2. Check if the tool is allowed by grants let allowed_features = self .feature_service - .resolve_feature_sets(&space_id_str, feature_set_ids) + .get_invokable_tools_for_grants(&space_id_str, feature_set_ids) .await?; - let feature = allowed_features.iter().find(|f| { - f.feature_type == FeatureType::Tool && f.is_available && f.qualified_name() == tool_name - }); - - let (server_id, actual_tool_name) = match feature { - Some(f) => (f.server_id.clone(), f.feature_name.clone()), - None => { - let available = allowed_features - .iter() - .filter(|f| f.feature_type == FeatureType::Tool && f.is_available) - .count(); - warn!( - "[RoutingService] Tool '{}' not in the resolved feature set ({} tools available)", - tool_name, available - ); - return Err(anyhow!( - "Tool '{}' is not allowed by the current grants", - tool_name - )); - } - }; - info!( - "[RoutingService] Tool '{}' ALLOWED → server={}, tool={}", + "[RoutingService] Checking authorization for tool '{}' (server: {}, actual_name: {})", tool_name, server_id, actual_tool_name ); + info!( + "[RoutingService] Feature sets to check: {:?}", + feature_set_ids + ); + info!( + "[RoutingService] Total allowed features: {}", + allowed_features.len() + ); + + // Log all tool features for debugging + let tool_features: Vec<_> = allowed_features + .iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| format!("{}::{}", f.server_id, f.feature_name)) + .collect(); + info!("[RoutingService] Allowed tools: {:?}", tool_features); + + let is_allowed = allowed_features.iter().any(|f| { + f.feature_type == FeatureType::Tool + && f.server_id == server_id + && f.feature_name == actual_tool_name + && f.is_available + }); + + if !is_allowed { + warn!( + "[RoutingService] Tool '{}' NOT allowed. Looking for server_id='{}', feature_name='{}', is_available=true", + tool_name, server_id, actual_tool_name + ); + return Err(anyhow!(format_invoke_permission_denied( + tool_name, + &server_id, + &actual_tool_name, + &[], + ))); + } + + info!("[RoutingService] Tool '{}' is ALLOWED", tool_name); info!( "[RoutingService] Calling tool {} on server {}", actual_tool_name, server_id ); - // Log the tool call attempt. Persist only the argument KEY names, not - // their values — tool arguments routinely carry secrets/PII, and this - // log is written to plaintext `current.log`. Keys alone are enough to - // debug routing without leaking payloads. - let arg_keys: Vec<&str> = arguments - .as_object() - .map(|o| o.keys().map(String::as_str).collect()) - .unwrap_or_default(); + // Log the tool call attempt self.log( &space_id, &server_id, @@ -291,7 +342,7 @@ impl RoutingService { format!("Calling tool: {}", actual_tool_name), Some(serde_json::json!({ "tool": actual_tool_name, - "argument_keys": arg_keys + "arguments": arguments })), ) .await; diff --git a/crates/mcpmux-gateway/src/pool/transport/resolution.rs b/crates/mcpmux-gateway/src/pool/transport/resolution.rs index 7fbce3a2..da336091 100644 --- a/crates/mcpmux-gateway/src/pool/transport/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/transport/resolution.rs @@ -4,12 +4,22 @@ //! the static registry definition and user-specific installation settings. use super::ResolvedTransport; -use mcpmux_core::{InstalledServer, TransportConfig as RegistryConfig}; +use crate::services::package_version::{is_floating_npm_tag, is_valid_semver}; +use mcpmux_core::{InstalledServer, TransportConfig as RegistryConfig, UpdatePolicy}; use std::collections::HashMap; use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; const MCP_STATE_DIR_ENV: &str = "MCP_STATE_DIR"; +/// Options that affect one-shot transport resolution behavior. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TransportResolutionOptions { + /// When true, apply latest-package resolution for notify servers (explicit user update). + pub apply_package_update: bool, +} + /// Build a merged input_values map that includes defaults for any inputs /// not explicitly provided by the user. fn merge_input_defaults( @@ -38,6 +48,7 @@ pub fn build_transport_config( registry_transport: &RegistryConfig, installed: &InstalledServer, base_state_dir: Option<&Path>, + options: TransportResolutionOptions, ) -> ResolvedTransport { tracing::debug!( "[TransportResolution] Building config for {}/{} with {} input values", @@ -62,20 +73,19 @@ pub fn build_transport_config( // Append user's extra args resolved_args.extend(installed.args_append.clone()); + apply_update_policy(&resolved_command, &mut resolved_args, installed, options); + // Build env from registry + input values + env_overrides let mut resolved_env = HashMap::new(); // 1. Start with registry env for (k, v) in env { let resolved_value = resolve_placeholders(v, &effective_values); - // Log only the key and the (pre-resolution) template `v` — the - // template carries `${input:...}` placeholders, never the - // secret. The resolved value is intentionally NOT logged: it - // may be an API key/token and these logs can persist. tracing::debug!( - "[TransportResolution] Registry env: {}={} → (resolved)", + "[TransportResolution] Registry env: {}={} → {}", k, - v + v, + resolved_value ); resolved_env.insert(k.clone(), resolved_value); } @@ -124,6 +134,496 @@ pub fn build_transport_config( } } +/// Apply per-server update policy for npx/uvx stdio transports. +fn apply_update_policy( + command: &str, + args: &mut [String], + installed: &InstalledServer, + options: TransportResolutionOptions, +) { + if options.apply_package_update && installed.update_policy != UpdatePolicy::Pinned { + apply_explicit_package_update(command, args, installed); + return; + } + + match installed.update_policy { + UpdatePolicy::Auto => apply_auto_update_policy(command, args), + UpdatePolicy::Pinned => apply_pinned_update_policy(command, args, installed), + UpdatePolicy::Notify => {} + } +} + +/// Apply Auto-mode package resolution for npx/uvx stdio transports. +fn apply_auto_update_policy(command: &str, args: &mut [String]) { + match command { + "npx" => inject_npx_latest(args), + "uvx" | "uv" => run_uv_tool_upgrade(command, args), + _ => {} + } +} + +/// One-shot user update: pin to probed semver when known, else re-resolve `@latest`. +fn apply_explicit_package_update(command: &str, args: &mut [String], installed: &InstalledServer) { + match command { + "npx" => { + evict_npx_cache_for_args(args); + if let Some(version) = installed + .latest_available_version + .as_deref() + .filter(|value| is_valid_semver(value)) + { + inject_npx_pinned(args, version); + } else { + inject_npx_latest(args); + } + } + "uvx" | "uv" => run_uv_tool_upgrade(command, args), + _ => {} + } +} + +/// Returns true for npm dist-tags that do not pin an exact installed semver. +pub fn npm_version_tag_is_floating(tag: &str) -> bool { + is_floating_npm_tag(tag) +} + +/// Run a subprocess-backed operation off the async worker when a Tokio runtime is active. +fn run_subprocess_blocking(operation: F) -> R +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + if let Ok(handle) = tokio::runtime::Handle::try_current() { + tokio::task::block_in_place(|| { + handle + .block_on(async { tokio::task::spawn_blocking(operation).await }) + .unwrap_or_else(|err| panic!("subprocess task failed: {err}")) + }) + } else { + operation() + } +} + +/// Returns true when the installed npm CLI supports `npm cache npx` (npm ≥ 11). +pub fn npm_supports_cache_npx() -> bool { + static SUPPORTS: OnceLock = OnceLock::new(); + *SUPPORTS.get_or_init(|| { + let output = match Command::new("npm").arg("--version").output() { + Ok(output) => output, + Err(_) => return false, + }; + if !output.status.success() { + return false; + } + let version = String::from_utf8_lossy(&output.stdout); + parse_npm_major_version(&version).is_some_and(|major| major >= 11) + }) +} + +/// Resolve the on-disk npx cache version for a package argument (e.g. bare `pkg` or `pkg@1.2.3`). +pub fn npx_cache_resolved_version(package_arg: &str) -> Option { + if !npm_supports_cache_npx() { + return None; + } + + // Always consult the real cache rather than short-circuiting on the + // semver embedded in the arg. This ensures that after an explicit update + // (old entry evicted, new @latest entry present) the actual installed + // version is returned instead of the stale args semver. + let entries = run_subprocess_blocking(fetch_npx_cache_ls_entries); + let (key, specs) = entries + .iter() + .find(|(_, specs)| specs.iter().any(|spec| spec == package_arg))?; + + if let Some(matched_spec) = specs.first() { + let (_, version) = split_npm_package_arg(matched_spec); + if let Some(version) = version { + if !is_floating_npm_tag(&version) && is_valid_semver(&version) { + return Some(version); + } + } + } + + parse_npx_cache_info_version(key, package_arg) +} + +/// Remove a frozen npx cache entry for the given package argument. +pub fn evict_npx_cache_entry(package_arg: &str) { + if !npm_supports_cache_npx() { + return; + } + + let entries = run_subprocess_blocking(fetch_npx_cache_ls_entries); + let key = entries + .iter() + .find(|(_, specs)| specs.iter().any(|spec| spec == package_arg)) + .map(|(key, _)| key.clone()); + + if let Some(key) = key { + let _ = run_subprocess_blocking(move || { + Command::new("npm") + .args(["cache", "npx", "rm", &key]) + .output() + }); + } +} + +/// Evict the npx cache entry for the package argument in `npx` stdio args. +fn evict_npx_cache_for_args(args: &[String]) { + if let Some(index) = find_npx_package_arg_index(args) { + evict_npx_cache_entry(&args[index]); + } +} + +/// Parse the major segment of an `npm --version` string. +fn parse_npm_major_version(version: &str) -> Option { + version + .trim() + .split('.') + .next() + .and_then(|segment| segment.parse().ok()) +} + +/// Parse one line of `npm cache npx ls` output into cache key and package specs. +fn parse_npx_cache_ls_line(line: &str) -> Option<(String, Vec)> { + let line = line.trim(); + if line.is_empty() { + return None; + } + + let (key, rest) = line.split_once(':')?; + let key = key.trim(); + if key.is_empty() { + return None; + } + + let rest = rest.trim(); + if rest.is_empty() || rest.starts_with('(') { + return None; + } + + let specs: Vec = rest + .split(", ") + .map(str::trim) + .filter(|spec| !spec.is_empty()) + .map(str::to_string) + .collect(); + + if specs.is_empty() { + return None; + } + + Some((key.to_string(), specs)) +} + +/// List npx cache entries from `npm cache npx ls` (text output; `--json` is not supported). +fn fetch_npx_cache_ls_entries() -> Vec<(String, Vec)> { + if !npm_supports_cache_npx() { + return Vec::new(); + } + + let output = match Command::new("npm").args(["cache", "npx", "ls"]).output() { + Ok(output) => output, + Err(_) => return Vec::new(), + }; + if !output.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_npx_cache_ls_line) + .collect() +} + +/// Parse resolved version from `npm cache npx info` stdout for a package argument. +fn parse_npx_cache_info_text(text: &str, package_arg: &str) -> Option { + let package_name = split_npm_package_arg(package_arg).0; + + for line in text.lines() { + let line = strip_ansi_escapes(line.trim()); + if let Some(rest) = line.strip_prefix("- ") { + if let Some(paren_start) = rest.rfind('(') { + let inner = rest[paren_start + 1..].trim_end_matches(')'); + let (name, version) = split_npm_package_arg(inner); + if name == package_name { + if let Some(version) = version { + if !is_floating_npm_tag(&version) && is_valid_semver(&version) { + return Some(version); + } + } + } + } + } + } + + None +} + +/// Parse resolved version from `npm cache npx info ` for a package argument. +fn parse_npx_cache_info_version(key: &str, package_arg: &str) -> Option { + let cache_key = key.to_string(); + let output = run_subprocess_blocking(move || { + Command::new("npm") + .args(["cache", "npx", "info", &cache_key]) + .output() + }) + .ok()?; + if !output.status.success() { + return None; + } + + parse_npx_cache_info_text(&String::from_utf8_lossy(&output.stdout), package_arg) +} + +/// Strip ANSI escape sequences from npm CLI output when chalk coloring is enabled. +fn strip_ansi_escapes(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + for next in chars.by_ref() { + if next == 'm' { + break; + } + } + continue; + } + result.push(ch); + } + result +} + +/// Enforce an exact semver pin for Pinned-policy servers. +fn apply_pinned_update_policy(command: &str, args: &mut [String], installed: &InstalledServer) { + let Some(pinned) = installed + .pinned_version + .as_deref() + .filter(|v| !v.is_empty()) + else { + return; + }; + + warn_if_pinned_version_differs(installed, pinned); + + match command { + "npx" => inject_npx_pinned(args, pinned), + "uvx" | "uv" => inject_uvx_pinned(command, args, pinned), + _ => {} + } +} + +/// Log when a pin differs from the cached latest probe (informational only). +fn warn_if_pinned_version_differs(installed: &InstalledServer, pinned: &str) { + let Some(latest) = installed + .latest_available_version + .as_deref() + .filter(|v| !v.is_empty()) + else { + return; + }; + + if pinned != latest { + tracing::warn!( + "[TransportResolution] Pinned version {} differs from latest available {} for {}/{}", + pinned, + latest, + installed.space_id, + installed.server_id + ); + } +} + +/// Inject `@latest` into the npx package argument so npm re-resolves the registry tag. +fn inject_npx_latest(args: &mut [String]) { + let Some(index) = find_npx_package_arg_index(args) else { + return; + }; + let injected = inject_npm_version_tag(&args[index], "latest"); + tracing::debug!( + "[TransportResolution] Auto update policy: npx package {} → {}", + args[index], + injected + ); + args[index] = injected; +} + +/// Inject `@` into the npx package argument for Pinned policy. +fn inject_npx_pinned(args: &mut [String], version: &str) { + let Some(index) = find_npx_package_arg_index(args) else { + return; + }; + let injected = inject_npm_version_tag(&args[index], version); + tracing::debug!( + "[TransportResolution] Pinned update policy: npx package {} → {}", + args[index], + injected + ); + args[index] = injected; +} + +/// Inject `==` into the uvx / `uv run` package argument for Pinned policy. +fn inject_uvx_pinned(command: &str, args: &mut [String], version: &str) { + let Some(index) = find_uv_package_arg_index(command, args) else { + return; + }; + let injected = inject_uv_version_tag(&args[index], version); + tracing::debug!( + "[TransportResolution] Pinned update policy: uv package {} → {}", + args[index], + injected + ); + args[index] = injected; +} + +/// Install the latest version of a uvx tool before spawn. +/// +/// Uses `uv tool install @latest` rather than `uv tool upgrade` because +/// `upgrade` silently does nothing when the tool was installed with an exact +/// version pin (e.g. `mcp-server-fetch==2025.1.17`). `install @latest` always +/// overwrites the pin with the current latest. +fn run_uv_tool_upgrade(command: &str, args: &[String]) { + let Some(package) = extract_uv_package_name(command, args) else { + return; + }; + + let package_at_latest = format!("{}@latest", package); + + tracing::debug!( + "[TransportResolution] Auto update policy: running uv tool install for {}", + package_at_latest + ); + + let spec = package_at_latest.clone(); + match run_subprocess_blocking(move || { + Command::new("uv").args(["tool", "install", &spec]).output() + }) { + Ok(output) if output.status.success() => { + tracing::debug!( + "[TransportResolution] uv tool install succeeded for {}", + package_at_latest + ); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "[TransportResolution] uv tool install failed for {} (status {:?}): {}", + package_at_latest, + output.status.code(), + stderr.trim() + ); + } + Err(err) => { + tracing::warn!( + "[TransportResolution] uv tool install could not run for {}: {}", + package_at_latest, + err + ); + } + } +} + +/// Index of the npm package argument for npx (`-y` flag skips to the next positional). +fn find_npx_package_arg_index(args: &[String]) -> Option { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if matches!(arg, "-y" | "--yes") { + let next = index + 1; + if next < args.len() && !args[next].starts_with('-') { + return Some(next); + } + } + index += 1; + } + + args.iter() + .position(|arg| !arg.starts_with('-') && arg != "--") +} + +/// Index of the package argument for uvx or `uv run` invocations. +fn find_uv_package_arg_index(command: &str, args: &[String]) -> Option { + match command { + "uvx" => args.iter().position(|arg| !arg.starts_with('-')), + "uv" if args.first().map(String::as_str) == Some("run") => { + let mut index = 1; + while index < args.len() { + let arg = args[index].as_str(); + if arg.starts_with('-') { + if matches!(arg, "-m" | "--module") { + index += 2; + continue; + } + index += 1; + continue; + } + return Some(index); + } + None + } + _ => None, + } +} + +/// Package name for uvx or `uv run` invocations. +fn extract_uv_package_name(command: &str, args: &[String]) -> Option { + let index = find_uv_package_arg_index(command, args)?; + Some(strip_package_version(&args[index])) +} + +/// Strip an existing `@version` or `==version` suffix from a package specifier. +fn strip_package_version(package: &str) -> String { + if let Some((name, _version)) = package.split_once("==") { + return name.to_string(); + } + split_npm_package_arg(package).0 +} + +/// Split an npm-style package arg into name and optional version tag. +fn split_npm_package_arg(package: &str) -> (String, Option) { + if let Some(scoped) = package.strip_prefix('@') { + if let Some(at_idx) = scoped.find('@') { + let split_at = 1 + at_idx; + return ( + package[..split_at].to_string(), + Some(package[split_at + 1..].to_string()), + ); + } + return (package.to_string(), None); + } + + if let Some(at_idx) = package.rfind('@') { + return ( + package[..at_idx].to_string(), + Some(package[at_idx + 1..].to_string()), + ); + } + + (package.to_string(), None) +} + +/// Append or replace an npm version tag on a package argument (`pkg`, `@scope/pkg`, or `pkg@ver`). +fn inject_npm_version_tag(package: &str, tag: &str) -> String { + let tag = tag.trim_start_matches('@'); + if tag.is_empty() { + return package.to_string(); + } + + let (name, _) = split_npm_package_arg(package); + format!("{name}@{tag}") +} + +/// Append or replace a PEP 440 exact version on a uv package argument (`pkg` or `pkg==ver`). +fn inject_uv_version_tag(package: &str, version: &str) -> String { + let version = version.trim_start_matches('='); + if version.is_empty() { + return package.to_string(); + } + + let name = strip_package_version(package); + format!("{name}=={version}") +} + fn apply_state_dir_env( resolved_env: &mut HashMap, base_state_dir: Option<&Path>, @@ -157,6 +657,22 @@ fn resolve_placeholders(template: &str, input_values: &HashMap) result } +#[cfg(any(test, feature = "test-utils"))] +pub mod update_policy_parsing { + pub use super::build_transport_config; + pub use super::TransportResolutionOptions; + + /// Parse one line of `npm cache npx ls` output into cache key and package specs. + pub fn parse_npx_cache_ls_line(line: &str) -> Option<(String, Vec)> { + super::parse_npx_cache_ls_line(line) + } + + /// Parse resolved version from `npm cache npx info` stdout for a package argument. + pub fn parse_npx_cache_info_text(text: &str, package_arg: &str) -> Option { + super::parse_npx_cache_info_text(text, package_arg) + } +} + #[cfg(test)] mod tests { use super::*; @@ -194,7 +710,12 @@ mod tests { let installed = make_installed(HashMap::new()); // No user values - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Stdio { env, .. } => { @@ -221,7 +742,12 @@ mod tests { "debug".to_string(), )])); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Stdio { env, .. } => { @@ -245,7 +771,12 @@ mod tests { let installed = make_installed(HashMap::new()); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Stdio { args, .. } => { @@ -269,7 +800,12 @@ mod tests { let installed = make_installed(HashMap::new()); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Stdio { command, .. } => { @@ -291,7 +827,12 @@ mod tests { let installed = make_installed(HashMap::new()); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Http { url, .. } => { @@ -313,7 +854,12 @@ mod tests { let installed = make_installed(HashMap::new()); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Http { headers, .. } => { @@ -348,7 +894,12 @@ mod tests { ("API_KEY".to_string(), "secret123".to_string()), ])); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Stdio { env, .. } => { @@ -376,7 +927,12 @@ mod tests { let installed = make_installed(HashMap::new()); - let resolved = build_transport_config(&transport, &installed, None); + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); match resolved { ResolvedTransport::Stdio { env, .. } => { @@ -408,4 +964,128 @@ mod tests { assert_eq!(merged.get("A"), Some(&"user_a".to_string())); assert_eq!(merged.get("B"), Some(&"default_b".to_string())); } + + #[test] + fn test_pinned_policy_injects_npx_version() { + let transport = RegistryConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "firebase-tools".to_string()], + env: HashMap::new(), + metadata: TransportMetadata { inputs: vec![] }, + }; + + let installed = InstalledServer::new("space", "firebase") + .with_update_policy(UpdatePolicy::Pinned) + .with_pinned_version(Some("13.0.0")); + + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions::default(), + ); + + match resolved { + ResolvedTransport::Stdio { args, .. } => { + assert_eq!(args[1], "firebase-tools@13.0.0"); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_explicit_update_applies_latest_for_notify_policy() { + let transport = RegistryConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "inngest-cloud-mcp".to_string()], + env: HashMap::new(), + metadata: TransportMetadata { inputs: vec![] }, + }; + + let installed = + InstalledServer::new("space", "inngest").with_update_policy(UpdatePolicy::Notify); + + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions { + apply_package_update: true, + }, + ); + + match resolved { + ResolvedTransport::Stdio { args, .. } => { + assert_eq!(args[1], "inngest-cloud-mcp@latest"); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_explicit_update_injects_probed_semver_for_notify_policy() { + let transport = RegistryConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "@upstash/context7-mcp@latest".to_string()], + env: HashMap::new(), + metadata: TransportMetadata { inputs: vec![] }, + }; + + let mut installed = + InstalledServer::new("space", "context7").with_update_policy(UpdatePolicy::Notify); + installed.latest_available_version = Some("3.2.1".to_string()); + + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions { + apply_package_update: true, + }, + ); + + match resolved { + ResolvedTransport::Stdio { args, .. } => { + assert_eq!(args[1], "@upstash/context7-mcp@3.2.1"); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_explicit_update_respects_pinned_policy() { + let transport = RegistryConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "firebase-tools".to_string()], + env: HashMap::new(), + metadata: TransportMetadata { inputs: vec![] }, + }; + + let installed = InstalledServer::new("space", "firebase") + .with_update_policy(UpdatePolicy::Pinned) + .with_pinned_version(Some("13.0.0")); + + let resolved = build_transport_config( + &transport, + &installed, + None, + TransportResolutionOptions { + apply_package_update: true, + }, + ); + + match resolved { + ResolvedTransport::Stdio { args, .. } => { + assert_eq!(args[1], "firebase-tools@13.0.0"); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn npm_version_tag_is_floating_recognizes_latest() { + assert!(npm_version_tag_is_floating("latest")); + assert!(!is_valid_semver("latest")); + assert!(is_valid_semver("3.2.1")); + } } diff --git a/crates/mcpmux-gateway/src/server/startup.rs b/crates/mcpmux-gateway/src/server/startup.rs index 9c431441..bfd7862e 100644 --- a/crates/mcpmux-gateway/src/server/startup.rs +++ b/crates/mcpmux-gateway/src/server/startup.rs @@ -254,6 +254,7 @@ impl StartupOrchestrator { &definition.transport, server, self.dependencies.state_dir.as_deref(), + crate::pool::transport::resolution::TransportResolutionOptions::default(), ); // Explicitly set state to connecting in ServerManager BEFORE starting connection diff --git a/crates/mcpmux-gateway/src/services/mod.rs b/crates/mcpmux-gateway/src/services/mod.rs index 50a1bcd2..e0660f60 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -15,9 +15,11 @@ mod feature_set_resolver; mod grant_service; pub mod meta_tools; mod notification_emitter; +pub mod package_version; mod prefix_cache; pub mod prompt_discovery; pub mod resource_discovery; +mod server_version_probe; mod session_roots; mod space_resolver; pub mod tool_discovery; @@ -36,9 +38,17 @@ pub use meta_tools::{ ResolutionNotifier, MCPMUX_PREFIX, META_TOOL_APPROVAL_EVENT, META_TOOL_APPROVAL_RESOLVED_EVENT, }; pub use notification_emitter::NotificationEmitter; +pub use package_version::{ + is_floating_npm_tag, is_newer_than, is_pinned, is_valid_semver, probe_update_available, +}; pub use prefix_cache::PrefixCacheService; pub use prompt_discovery::{PromptDetailLevel, PromptDiscoveryService, PromptIndexEntry}; pub use resource_discovery::{ResourceDetailLevel, ResourceDiscoveryService, ResourceIndexEntry}; +#[cfg(any(test, feature = "test-utils"))] +pub use server_version_probe::update_policy_parsing as server_update_policy_parsing; +pub use server_version_probe::{ + ServerVersionProbeResult, ServerVersionProbeService, ServerVersionProbeSummary, +}; pub use session_roots::SessionRootsRegistry; pub use space_resolver::SpaceResolverService; pub use tool_discovery::{ diff --git a/crates/mcpmux-gateway/src/services/package_version.rs b/crates/mcpmux-gateway/src/services/package_version.rs new file mode 100644 index 00000000..070ef4ec --- /dev/null +++ b/crates/mcpmux-gateway/src/services/package_version.rs @@ -0,0 +1,143 @@ +//! Shared guards and version comparison for package update policy (probe + resolution). + +use mcpmux_core::UpdatePolicy; + +/// Returns true for npm dist-tags that do not pin an exact semver. +pub fn is_floating_npm_tag(tag: &str) -> bool { + matches!( + tag.trim() + .trim_start_matches('@') + .to_ascii_lowercase() + .as_str(), + "latest" | "*" | "next" | "beta" | "canary" | "stable" | "release" + ) +} + +/// Returns true when `version` matches strict semver (major.minor.patch). +pub fn is_valid_semver(version: &str) -> bool { + let version = version + .trim() + .trim_start_matches('v') + .trim_start_matches('='); + if version.is_empty() { + return false; + } + + let core = version.split('+').next().unwrap_or(version); + let (core, prerelease) = match core.split_once('-') { + Some((core, pre)) if !pre.is_empty() => { + if !is_valid_prerelease(pre) { + return false; + } + (core, Some(pre)) + } + Some((_, _)) => return false, + None => (core, None), + }; + let _ = prerelease; + + let parts: Vec<&str> = core.split('.').collect(); + if parts.len() != 3 { + return false; + } + + parts.iter().all(|part| is_valid_numeric_ident(part)) +} + +/// Returns true when the server update policy locks to a pinned version. +pub fn is_pinned(policy: UpdatePolicy) -> bool { + policy == UpdatePolicy::Pinned +} + +/// Returns true when a background probe should report `update_available`. +pub fn probe_update_available( + current: Option<&str>, + latest: Option<&str>, + npm_package_version: Option<&str>, +) -> bool { + if npm_package_version.is_some_and(is_floating_npm_tag) { + return false; + } + + let Some(latest) = latest.filter(|value| !value.is_empty()) else { + return false; + }; + + is_newer_than(latest, current) +} + +/// Returns true when `latest` is strictly newer than `current`. +pub fn is_newer_than(latest: &str, current: Option<&str>) -> bool { + let Some(current) = current.filter(|value| !value.is_empty()) else { + return false; + }; + + let latest_parts = parse_version_parts(latest); + let current_parts = parse_version_parts(current); + latest_parts > current_parts || (latest_parts == current_parts && latest != current) +} + +fn is_valid_numeric_ident(value: &str) -> bool { + if value.is_empty() { + return false; + } + if value == "0" { + return true; + } + !value.starts_with('0') && value.chars().all(|ch| ch.is_ascii_digit()) +} + +fn is_valid_prerelease(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-')) +} + +/// Split a semver-ish string into numeric comparison parts. +fn parse_version_parts(version: &str) -> Vec { + version + .trim() + .trim_start_matches('v') + .trim_start_matches('=') + .split(|ch: char| !ch.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_valid_semver_accepts_release_and_prerelease() { + assert!(is_valid_semver("1.2.3")); + assert!(is_valid_semver("v1.2.3")); + assert!(is_valid_semver("0.0.1")); + assert!(is_valid_semver("1.2.3-beta.1")); + assert!(is_valid_semver("1.2.3+build.1")); + } + + #[test] + fn is_valid_semver_rejects_loose_values() { + assert!(!is_valid_semver("latest")); + assert!(!is_valid_semver("1.2")); + assert!(!is_valid_semver("01.2.3")); + assert!(!is_valid_semver("")); + } + + #[test] + fn is_floating_npm_tag_recognizes_dist_tags() { + assert!(is_floating_npm_tag("latest")); + assert!(is_floating_npm_tag("@next")); + assert!(!is_floating_npm_tag("1.2.3")); + } + + #[test] + fn probe_update_available_honors_floating_tag_and_unknown_current() { + assert!(!probe_update_available(None, Some("2.0.0"), Some("latest"),)); + assert!(!probe_update_available(None, Some("2.0.0"), None)); + assert!(probe_update_available(Some("1.0.0"), Some("2.0.0"), None,)); + } +} diff --git a/crates/mcpmux-gateway/src/services/server_version_probe.rs b/crates/mcpmux-gateway/src/services/server_version_probe.rs new file mode 100644 index 00000000..4c22373c --- /dev/null +++ b/crates/mcpmux-gateway/src/services/server_version_probe.rs @@ -0,0 +1,676 @@ +//! Background version probe for notify/auto server update policies. +//! +//! Shells out to `npm view`, `uv tool list`, and `uv tool list --outdated`; +//! queries the PyPI JSON API for uvx latest versions; caches results on +//! `installed_servers`, and emits `ServerUpdateAvailable` domain events. + +use std::collections::HashMap; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crate::pool::transport::resolution::npx_cache_resolved_version; +use crate::services::package_version::{ + is_floating_npm_tag, is_valid_semver, probe_update_available, +}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use futures::stream::{self, StreamExt}; +use mcpmux_core::{ + AppSettingsRepository, DomainEvent, EventBus, InstalledServer, InstalledServerRepository, + TransportConfig, UpdatePolicy, +}; +use tokio::time; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +const DEFAULT_PROBE_INTERVAL_HOURS: u64 = 6; +const PROBE_INTERVAL_HOURS_KEY: &str = "servers.version_probe_interval_hours"; +const LAST_VERSION_PROBE_AT_KEY: &str = "servers.last_version_probe_at"; +const PROBE_CONCURRENCY: usize = 4; + +/// Result of probing one installed server. +#[derive(Debug, Clone)] +pub struct ServerVersionProbeResult { + pub space_id: String, + pub server_id: String, + pub current_version: Option, + pub latest_version: Option, + pub update_available: bool, + pub checked_at: DateTime, +} + +/// Summary returned by bulk probe operations. +#[derive(Debug, Clone, Default)] +pub struct ServerVersionProbeSummary { + pub checked: usize, + pub updates_available: usize, + pub checked_at: DateTime, +} + +/// Probes npm/PyPI for package updates and persists notify-mode cache columns. +#[derive(Clone)] +pub struct ServerVersionProbeService { + installed_server_repo: Arc, + settings_repo: Arc, + event_bus: Arc, + scheduler_started: Arc, +} + +impl ServerVersionProbeService { + /// Build a probe service wired to storage and the application event bus. + pub fn new( + installed_server_repo: Arc, + settings_repo: Arc, + event_bus: Arc, + ) -> Self { + Self { + installed_server_repo, + settings_repo, + event_bus, + scheduler_started: Arc::new(AtomicBool::new(false)), + } + } + + /// Start the startup + interval background scheduler (idempotent). + pub fn start_scheduler(self: Arc) { + if self + .scheduler_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + tokio::spawn(async move { + info!("[VersionProbe] Running startup version probe"); + if let Err(error) = self.probe_all().await { + warn!("[VersionProbe] Startup probe failed: {error}"); + } + + loop { + let interval = self.probe_interval().await; + time::sleep(interval).await; + debug!("[VersionProbe] Running scheduled version probe"); + if let Err(error) = self.probe_all().await { + warn!("[VersionProbe] Scheduled probe failed: {error}"); + } + } + }); + } + + /// Probe every notify/auto package-managed server. + pub async fn probe_all(&self) -> Result { + let servers: Vec = self + .installed_server_repo + .list() + .await? + .into_iter() + .filter(Self::is_probe_eligible) + .collect(); + + let uv_outdated = tokio::task::spawn_blocking(fetch_uv_outdated_map) + .await + .ok() + .flatten(); + let uv_tool_list = tokio::task::spawn_blocking(fetch_uv_tool_list_map) + .await + .ok() + .flatten(); + let checked_at = Utc::now(); + + let results = stream::iter(servers) + .map(|server| { + let service = self.clone(); + let uv_outdated = uv_outdated.clone(); + let uv_tool_list = uv_tool_list.clone(); + async move { + service + .probe_installed_server( + &server, + uv_outdated.as_ref(), + uv_tool_list.as_ref(), + checked_at, + ) + .await + } + }) + .buffer_unordered(PROBE_CONCURRENCY) + .collect::>() + .await; + + let mut summary = ServerVersionProbeSummary { + checked_at, + ..Default::default() + }; + + for result in results { + match result { + Ok(probe_result) => { + summary.checked += 1; + if probe_result.update_available { + summary.updates_available += 1; + } + } + Err(error) => { + warn!("[VersionProbe] Failed probing server: {error}"); + } + } + } + + self.settings_repo + .set(LAST_VERSION_PROBE_AT_KEY, &checked_at.to_rfc3339()) + .await + .context("failed to persist last version probe timestamp")?; + + Ok(summary) + } + + /// Probe a single installed server by registry id within a space. + pub async fn probe_server( + &self, + space_id: &str, + server_id: &str, + ) -> Result { + let server = self + .installed_server_repo + .get_by_server_id(space_id, server_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Server not found: {space_id}/{server_id}"))?; + + if !Self::is_probe_eligible(&server) { + anyhow::bail!("Server transport is not package-managed (npx/uvx only)"); + } + + let uv_outdated = tokio::task::spawn_blocking(fetch_uv_outdated_map) + .await + .ok() + .flatten(); + let uv_tool_list = tokio::task::spawn_blocking(fetch_uv_tool_list_map) + .await + .ok() + .flatten(); + let checked_at = Utc::now(); + self.probe_installed_server( + &server, + uv_outdated.as_ref(), + uv_tool_list.as_ref(), + checked_at, + ) + .await + } + + async fn probe_interval(&self) -> Duration { + let hours = match self.settings_repo.get(PROBE_INTERVAL_HOURS_KEY).await { + Ok(Some(value)) => value.parse::().unwrap_or(DEFAULT_PROBE_INTERVAL_HOURS), + _ => DEFAULT_PROBE_INTERVAL_HOURS, + }; + Duration::from_secs(hours.max(1) * 3600) + } + + fn is_probe_eligible(server: &InstalledServer) -> bool { + matches!( + server.update_policy, + UpdatePolicy::Notify | UpdatePolicy::Auto + ) && package_spec(server).is_some() + } + + async fn probe_installed_server( + &self, + server: &InstalledServer, + uv_outdated: Option<&HashMap>, + uv_tool_list: Option<&HashMap>, + checked_at: DateTime, + ) -> Result { + let Some(spec) = package_spec(server) else { + anyhow::bail!("No resolvable package for {}", server.server_id); + }; + + let current_version = current_version(server, &spec, uv_tool_list).await; + let latest_version = match spec.transport_kind { + PackageTransportKind::Npx => { + let package_name = spec.package_name.clone(); + tokio::task::spawn_blocking(move || fetch_npm_latest_version(&package_name)) + .await + .ok() + .flatten() + } + PackageTransportKind::Uvx => { + let outdated_latest = uv_outdated + .and_then(|map| map.get(&spec.package_name)) + .map(|entry| entry.latest.clone()); + if outdated_latest.is_some() { + outdated_latest + } else { + fetch_pypi_latest_version(&spec.package_name).await + } + } + }; + + self.installed_server_repo + .update_version_cache( + &server.id, + latest_version.clone(), + current_version.clone(), + checked_at, + ) + .await?; + + let npm_package_version = npm_package_version_suffix(server, &spec); + let update_available = probe_update_available( + current_version.as_deref(), + latest_version.as_deref(), + npm_package_version.as_deref(), + ); + + if update_available { + let space_uuid = Uuid::parse_str(&server.space_id) + .with_context(|| format!("Invalid space_id: {}", server.space_id))?; + self.event_bus + .sender() + .emit(DomainEvent::ServerUpdateAvailable { + space_id: space_uuid, + server_id: server.server_id.clone(), + current_version: current_version.clone(), + latest_version: latest_version.clone(), + }); + } + + Ok(ServerVersionProbeResult { + space_id: server.space_id.clone(), + server_id: server.server_id.clone(), + current_version, + latest_version, + update_available, + checked_at, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PackageTransportKind { + Npx, + Uvx, +} + +#[derive(Debug, Clone)] +struct PackageSpec { + transport_kind: PackageTransportKind, + package_name: String, +} + +#[derive(Debug, Clone)] +struct UvOutdatedEntry { + latest: String, +} + +/// Resolve the npm/PyPI package name for an installed stdio server. +fn package_spec(server: &InstalledServer) -> Option { + let definition = server.get_definition()?; + let TransportConfig::Stdio { command, args, .. } = definition.transport else { + return None; + }; + + match command.as_str() { + "npx" => find_npx_package_arg(&args).map(|package| PackageSpec { + transport_kind: PackageTransportKind::Npx, + package_name: strip_package_version(&package), + }), + "uvx" | "uv" => extract_uv_package_name(&command, &args).map(|package| PackageSpec { + transport_kind: PackageTransportKind::Uvx, + package_name: package, + }), + _ => None, + } +} + +/// Version suffix from the npx package argument, when present. +fn npm_package_version_suffix(server: &InstalledServer, spec: &PackageSpec) -> Option { + if spec.transport_kind != PackageTransportKind::Npx { + return None; + } + + let definition = server.get_definition()?; + let TransportConfig::Stdio { args, .. } = definition.transport else { + return None; + }; + + find_npx_package_arg(&args).and_then(|package| split_npm_package_arg(&package).1) +} + +/// Best-effort current version: pin, package suffix, uv tool list, or uv arg pin. +async fn current_version( + server: &InstalledServer, + spec: &PackageSpec, + uv_tool_list: Option<&HashMap>, +) -> Option { + if let Some(pinned) = server.pinned_version.as_deref().filter(|v| !v.is_empty()) { + return Some(pinned.to_string()); + } + + let definition = server.get_definition()?; + let TransportConfig::Stdio { command, args, .. } = definition.transport else { + return None; + }; + + match spec.transport_kind { + PackageTransportKind::Npx => { + let package_arg = find_npx_package_arg(&args)?; + let bare_name = split_npm_package_arg(&package_arg).0; + + // After an explicit update, npm caches the new version under + // `@pkg@{resolved_version}` (e.g. `@playwright/mcp@0.0.76`). + // Build a versioned lookup using latest_available_version so the probe + // finds the freshly cached entry even though the stored args still carry + // the pre-update semver. + let latest_versioned = server + .latest_available_version + .as_ref() + .filter(|v| is_valid_semver(v)) + .map(|v| format!("{}@{}", bare_name, v)); + + // Try original arg first (normal/cold-cache case), then the + // latest-versioned spec (post-update case where old entry was evicted). + let cache_version = tokio::task::spawn_blocking({ + let package_arg = package_arg.clone(); + move || { + npx_cache_resolved_version(&package_arg).or_else(|| { + latest_versioned + .as_deref() + .and_then(npx_cache_resolved_version) + }) + } + }) + .await + .ok() + .flatten(); + + if let Some(version) = cache_version { + return Some(version); + } + + // No cache hit: preserve the DB-stored current_version (set during an + // explicit update) rather than clobbering it with the stale args semver. + // Only fall back to args semver when the DB has nothing (cold-cache, + // first-run before any update has ever run). + server.current_version.clone().or_else(|| { + split_npm_package_arg(&package_arg) + .1 + .filter(|version| !is_floating_npm_tag(version)) + .filter(|version| is_valid_semver(version)) + .map(|version| version.to_string()) + }) + } + PackageTransportKind::Uvx => uv_tool_list + .and_then(|map| map.get(&spec.package_name)) + .cloned() + .or_else(|| { + find_uv_package_arg(&command, &args).and_then(|package| { + split_uv_version(&package) + .1 + .filter(|version| is_valid_semver(version)) + .map(|version| version.to_string()) + }) + }), + } +} + +/// Parse the latest published version from a PyPI JSON API body. +fn parse_pypi_json_version(body: &serde_json::Value) -> Option { + body.get("info")? + .get("version")? + .as_str() + .filter(|version| !version.is_empty()) + .map(|version| version.to_string()) +} + +/// Fetch the latest published PyPI version via the JSON API. +async fn fetch_pypi_latest_version(package: &str) -> Option { + let url = format!( + "https://pypi.org/pypi/{}/json", + urlencoding::encode(package) + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .ok()?; + let response = client.get(&url).send().await.ok()?; + if !response.status().is_success() { + return None; + } + let body: serde_json::Value = response.json().await.ok()?; + parse_pypi_json_version(&body) +} + +/// Fetch the latest published version via `npm view version`. +fn fetch_npm_latest_version(package: &str) -> Option { + let output = Command::new("npm") + .args(["view", package, "version"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if version.is_empty() { + None + } else { + Some(version) + } +} + +/// Parse `uv tool list` into a package-name → installed-version map. +fn fetch_uv_tool_list_map() -> Option> { + let output = Command::new("uv").args(["tool", "list"]).output().ok()?; + if !output.status.success() { + return None; + } + + let mut map = HashMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some((name, version)) = parse_uv_tool_list_line(line) { + map.insert(name, version); + } + } + Some(map) +} + +/// Parse one `uv tool list` row (` v`). +fn parse_uv_tool_list_line(line: &str) -> Option<(String, String)> { + let mut parts = line.split_whitespace(); + let name = parts.next()?.to_string(); + let version = parts.next()?.trim_start_matches('v').to_string(); + if version.is_empty() { + return None; + } + Some((name, version)) +} + +/// Parse `uv tool list --outdated` into a package-name map. +fn fetch_uv_outdated_map() -> Option> { + let output = Command::new("uv") + .args(["tool", "list", "--outdated"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let mut map = HashMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(entry) = parse_uv_outdated_line(line) { + map.insert(entry.0, entry.1); + } + } + Some(map) +} + +/// Parse one `uv tool list --outdated` row. +fn parse_uv_outdated_line(line: &str) -> Option<(String, UvOutdatedEntry)> { + let mut parts = line.split_whitespace(); + let name = parts.next()?.to_string(); + let remainder = parts.collect::>().join(" "); + if remainder.contains("->") { + let (_installed, latest) = remainder.split_once("->")?; + return Some(( + name, + UvOutdatedEntry { + latest: latest.trim().trim_start_matches('v').to_string(), + }, + )); + } + None +} + +fn find_npx_package_arg(args: &[String]) -> Option { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if matches!(arg, "-y" | "--yes") { + let next = index + 1; + if next < args.len() && !args[next].starts_with('-') { + return Some(args[next].clone()); + } + } + index += 1; + } + + args.iter() + .find(|arg| !arg.starts_with('-') && arg.as_str() != "--") + .cloned() +} + +fn extract_uv_package_name(command: &str, args: &[String]) -> Option { + find_uv_package_arg(command, args).map(|package| split_uv_version(&package).0) +} + +fn find_uv_package_arg(command: &str, args: &[String]) -> Option { + match command { + "uvx" => args.iter().find(|arg| !arg.starts_with('-')).cloned(), + "uv" if args.first().map(String::as_str) == Some("run") => { + let mut index = 1; + while index < args.len() { + let arg = args[index].as_str(); + if arg.starts_with('-') { + if matches!(arg, "-m" | "--module") { + index += 2; + continue; + } + index += 1; + continue; + } + return Some(args[index].clone()); + } + None + } + _ => None, + } +} + +fn strip_package_version(package: &str) -> String { + split_npm_package_arg(package).0 +} + +fn split_npm_package_arg(package: &str) -> (String, Option) { + if let Some(scoped) = package.strip_prefix('@') { + if let Some(at_idx) = scoped.find('@') { + let split_at = 1 + at_idx; + return ( + package[..split_at].to_string(), + Some(package[split_at + 1..].to_string()), + ); + } + return (package.to_string(), None); + } + + if let Some(at_idx) = package.rfind('@') { + return ( + package[..at_idx].to_string(), + Some(package[at_idx + 1..].to_string()), + ); + } + + (package.to_string(), None) +} + +fn split_uv_version(package: &str) -> (String, Option) { + if let Some((name, version)) = package.split_once("==") { + return (name.to_string(), Some(version.to_string())); + } + (package.to_string(), None) +} + +#[cfg(any(test, feature = "test-utils"))] +pub mod update_policy_parsing { + /// Parse one `uv tool list` row (` v`). + pub fn parse_uv_tool_list_line(line: &str) -> Option<(String, String)> { + super::parse_uv_tool_list_line(line) + } + + /// Parse one `uv tool list --outdated` row. + pub fn parse_uv_outdated_line(line: &str) -> Option<(String, String)> { + super::parse_uv_outdated_line(line).map(|(name, entry)| (name, entry.latest)) + } + + /// Parse the latest published version from a PyPI JSON API body. + pub fn parse_pypi_json_version(body: &serde_json::Value) -> Option { + super::parse_pypi_json_version(body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::package_version::is_newer_than; + + #[test] + fn is_newer_than_compares_numeric_segments() { + assert!(is_newer_than("1.2.0", Some("1.1.9"))); + assert!(!is_newer_than("1.2.0", Some("1.2.0"))); + assert!(!is_newer_than("2.0.0", None)); + } + + #[test] + fn parse_uv_outdated_line_reads_arrow_format() { + let (name, entry) = + parse_uv_outdated_line("mcp-server v1.0.0 -> v1.2.0").expect("parse line"); + assert_eq!(name, "mcp-server"); + assert_eq!(entry.latest, "1.2.0"); + } + + #[test] + fn parse_uv_tool_list_line_reads_name_and_version() { + let (name, version) = parse_uv_tool_list_line("ruff v0.8.6").expect("parse line"); + assert_eq!(name, "ruff"); + assert_eq!(version, "0.8.6"); + } + + #[test] + fn extract_uv_package_name_strips_pep508_version_pin() { + let args = vec!["mcp-server-fetch==2025.1.17".to_string()]; + let name = extract_uv_package_name("uvx", &args).expect("extract name"); + assert_eq!(name, "mcp-server-fetch"); + + let bare = vec!["mcp-server-fetch".to_string()]; + assert_eq!( + extract_uv_package_name("uvx", &bare).as_deref(), + Some("mcp-server-fetch") + ); + } + + #[test] + fn split_npm_package_arg_handles_scoped_packages() { + let (name, version) = split_npm_package_arg("@scope/pkg@1.2.3"); + assert_eq!(name, "@scope/pkg"); + assert_eq!(version.as_deref(), Some("1.2.3")); + } +} diff --git a/crates/mcpmux-storage/src/repositories/installed_server_repository.rs b/crates/mcpmux-storage/src/repositories/installed_server_repository.rs index 8827b809..04ca2172 100644 --- a/crates/mcpmux-storage/src/repositories/installed_server_repository.rs +++ b/crates/mcpmux-storage/src/repositories/installed_server_repository.rs @@ -533,4 +533,41 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { )?; Ok(()) } + + async fn set_display_name_override(&self, id: &Uuid, value: Option) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "UPDATE installed_servers SET display_name_override = ?2, updated_at = ?3 WHERE id = ?1", + params![id.to_string(), value, Utc::now().to_rfc3339()], + )?; + Ok(()) + } + + async fn update_version_cache( + &self, + id: &Uuid, + latest_available_version: Option, + current_version: Option, + version_checked_at: chrono::DateTime, + ) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "UPDATE installed_servers + SET latest_available_version = ?2, current_version = ?3, + version_checked_at = ?4, updated_at = ?5 + WHERE id = ?1", + params![ + id.to_string(), + latest_available_version, + current_version, + version_checked_at.to_rfc3339(), + Utc::now().to_rfc3339(), + ], + )?; + Ok(()) + } } diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index d5c8f1e5..8c36324a 100644 --- a/tests/rust/src/mocks.rs +++ b/tests/rust/src/mocks.rs @@ -223,6 +223,28 @@ impl InstalledServerRepository for MockInstalledServerRepository { } Ok(()) } + + async fn set_display_name_override(&self, id: &Uuid, value: Option) -> RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.display_name_override = value; + } + Ok(()) + } + + async fn update_version_cache( + &self, + id: &Uuid, + latest_available_version: Option, + current_version: Option, + version_checked_at: chrono::DateTime, + ) -> RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.latest_available_version = latest_available_version; + server.current_version = Some(current_version.unwrap_or_default()); + server.version_checked_at = Some(version_checked_at); + } + Ok(()) + } } // ============================================================================ From bf1ee50a8e10b99bb0f1a421ded64e659af232e6 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 21:15:24 -0600 Subject: [PATCH 007/148] =?UTF-8?q?feat(port):=20Phase=207=20=E2=80=94=20D?= =?UTF-8?q?ashboard=20+=20workspace=20appearances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add features/dashboard/ (DashboardPage, DashboardQuickLinks, DashboardRecentActivity, DashboardServerHealth, DashboardStatCards, dashboard.helpers, useDashboardData, index) - Add /dashboard nav entry in navigation.ts + App.tsx route - Port SourceBadge (add clonedFrom prop), source-badge.helpers.ts, AddServerMenu, ServerEnabledToggle, ServersCountSummary, ServersFiltersPopover, servers-page.helpers — hardcoded English - Wire workspace appearances into WorkspacesPage (load/persist icons for unmapped roots, card + inspector live preview, upload via pickPath) - Extend ServerIcon to resolve local:workspace-icons refs - Add SpacePanel slide-out editor in features/spaces/ - Add AboutSection to features/settings/ - Add useMetaToolEvents, useOAuthClientEvents, useWorkspaceEvents shims - Reconcile useServerManager to use useDomainEvents subscribe facade - Add pendingServersFilter state/action/selector to appStore - Add update_space Tauri command + SpaceService.update + updateSpace API - Wire resolveInstalledDisplayName into registryStore.mergeServers SpaceSwitcher already uses spaceAccentTint + space.icon (no change needed). pnpm validate clean; no react-i18next in this phase. Signed-off-by: crimsonsunset --- apps/desktop/src-tauri/src/commands/space.rs | 39 +++ apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/App.tsx | 2 + apps/desktop/src/components/ServerIcon.tsx | 72 ++++- apps/desktop/src/components/SourceBadge.tsx | 57 ++-- .../src/components/source-badge.helpers.ts | 33 +++ .../src/features/dashboard/DashboardPage.tsx | 38 +++ .../dashboard/DashboardQuickLinks.tsx | 110 +++++++ .../dashboard/DashboardRecentActivity.tsx | 93 ++++++ .../dashboard/DashboardServerHealth.tsx | 137 +++++++++ .../features/dashboard/DashboardStatCards.tsx | 165 +++++++++++ .../features/dashboard/dashboard.helpers.ts | 117 ++++++++ apps/desktop/src/features/dashboard/index.ts | 1 + .../features/dashboard/useDashboardData.ts | 98 +++++++ .../src/features/servers/AddServerMenu.tsx | 48 ++++ .../features/servers/ServerEnabledToggle.tsx | 39 +++ .../features/servers/ServersCountSummary.tsx | 35 +++ .../servers/ServersFiltersPopover.tsx | 143 ++++++++++ .../src/features/servers/ServersPage.tsx | 4 +- apps/desktop/src/features/servers/index.ts | 4 + .../features/servers/servers-page.helpers.ts | 268 ++++++++++++++++++ .../src/features/settings/AboutSection.tsx | 38 +++ apps/desktop/src/features/settings/index.ts | 1 + .../src/features/spaces/SpacePanel.tsx | 228 +++++++++++++++ apps/desktop/src/features/spaces/index.ts | 2 + .../features/workspaces/WorkspacesPage.tsx | 232 ++++++++++++++- apps/desktop/src/hooks/index.ts | 11 + apps/desktop/src/hooks/useMetaToolEvents.ts | 4 + .../desktop/src/hooks/useOAuthClientEvents.ts | 6 + apps/desktop/src/hooks/useServerManager.ts | 265 ++++++++--------- apps/desktop/src/hooks/useWorkspaceEvents.ts | 10 + apps/desktop/src/lib/api/spaces.ts | 16 ++ apps/desktop/src/lib/navigation.ts | 10 +- apps/desktop/src/stores/appStore.ts | 6 + apps/desktop/src/stores/registryStore.ts | 2 + apps/desktop/src/stores/selectors.ts | 2 + apps/desktop/src/stores/types.ts | 4 + .../mcpmux-core/src/service/space_service.rs | 32 +++ tests/ts/components/SourceBadge.test.tsx | 4 +- 39 files changed, 2187 insertions(+), 190 deletions(-) create mode 100644 apps/desktop/src/components/source-badge.helpers.ts create mode 100644 apps/desktop/src/features/dashboard/DashboardPage.tsx create mode 100644 apps/desktop/src/features/dashboard/DashboardQuickLinks.tsx create mode 100644 apps/desktop/src/features/dashboard/DashboardRecentActivity.tsx create mode 100644 apps/desktop/src/features/dashboard/DashboardServerHealth.tsx create mode 100644 apps/desktop/src/features/dashboard/DashboardStatCards.tsx create mode 100644 apps/desktop/src/features/dashboard/dashboard.helpers.ts create mode 100644 apps/desktop/src/features/dashboard/index.ts create mode 100644 apps/desktop/src/features/dashboard/useDashboardData.ts create mode 100644 apps/desktop/src/features/servers/AddServerMenu.tsx create mode 100644 apps/desktop/src/features/servers/ServerEnabledToggle.tsx create mode 100644 apps/desktop/src/features/servers/ServersCountSummary.tsx create mode 100644 apps/desktop/src/features/servers/ServersFiltersPopover.tsx create mode 100644 apps/desktop/src/features/servers/servers-page.helpers.ts create mode 100644 apps/desktop/src/features/settings/AboutSection.tsx create mode 100644 apps/desktop/src/features/spaces/SpacePanel.tsx create mode 100644 apps/desktop/src/hooks/useMetaToolEvents.ts create mode 100644 apps/desktop/src/hooks/useOAuthClientEvents.ts create mode 100644 apps/desktop/src/hooks/useWorkspaceEvents.ts diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index d5e79e1e..da6a5384 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -106,6 +106,45 @@ pub async fn create_space( Ok(space) } +/// Update a space's display metadata (name, icon, description). +#[tauri::command] +pub async fn update_space( + id: String, + name: Option, + icon: Option, + description: Option, + app: AppHandle, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + + let space = state + .space_service + .update(uuid, name, icon, description) + .await + .map_err(|e| e.to_string())?; + + // Emit domain event if gateway is running + let gw_state = gateway_state.read().await; + if let Some(ref gw) = gw_state.gateway_state { + let gw = gw.read().await; + gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceUpdated { + space_id: uuid, + name: space.name.clone(), + }); + } + + // Update system tray menu to reflect the rename + if let Err(e) = tray::update_tray_spaces(&app, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[update_space] Space '{}' updated successfully", uuid); + + Ok(space) +} + /// Delete a space. #[tauri::command] pub async fn delete_space( diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e909acd5..f080084b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -903,6 +903,7 @@ pub fn run() { commands::list_spaces, commands::get_space, commands::create_space, + commands::update_space, commands::delete_space, commands::list_space_base_dirs, commands::add_space_base_dir, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2fc82019..30745308 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -22,6 +22,7 @@ import { import { NAV_ZONES, NAV_SETTINGS } from '@/lib/navigation'; import { spaceAccentColor } from '@/lib/spaceAccent'; import { HomePage } from '@/features/home'; +import { DashboardPage } from '@/features/dashboard'; import { RegistryPage } from '@/features/registry'; import { FeatureSetsPage } from '@/features/featuresets'; import { ClientsPage } from '@/features/clients'; @@ -371,6 +372,7 @@ function AppContent() { )} {activeNav === 'home' && } + {activeNav === 'dashboard' && } {activeNav === 'registry' && } {activeNav === 'servers' && } {activeNav === 'spaces' && } diff --git a/apps/desktop/src/components/ServerIcon.tsx b/apps/desktop/src/components/ServerIcon.tsx index d2623329..69d0efa4 100644 --- a/apps/desktop/src/components/ServerIcon.tsx +++ b/apps/desktop/src/components/ServerIcon.tsx @@ -1,13 +1,9 @@ /** - * Shared server icon component that handles both URL-based and emoji icons. - * - * Server definitions may have an `icon` field that is either: - * - An HTTP(S) URL to an image (e.g., GitHub avatar) - * - An emoji string (e.g., "📦") - * - null/undefined + * Shared server icon component that handles URL-based, local file, and emoji icons. */ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { resolveWorkspaceIconDisplaySrc } from '@/lib/api/workspaceAppearances'; interface ServerIconProps { icon: string | null | undefined; @@ -17,21 +13,71 @@ interface ServerIconProps { fallback?: string; } -export function ServerIcon({ icon, className = 'w-9 h-9 object-contain', fallback = '📦' }: ServerIconProps) { - const [failed, setFailed] = useState(false); +/** + * Renders a server icon from a remote URL, local file reference, emoji, or fallback. + */ +export function ServerIcon({ + icon, + className = 'w-9 h-9 object-contain', + fallback = '📦', +}: ServerIconProps) { + const isLocalRef = icon?.startsWith('local:') ?? false; + const isRemoteUrl = icon?.startsWith('http') ?? false; + const [failedIcon, setFailedIcon] = useState(null); + const [localResolved, setLocalResolved] = useState<{ icon: string; src: string | null } | null>( + null + ); + const hasFailed = icon != null && failedIcon === icon; + const localSrc = + localResolved != null && localResolved.icon === icon ? localResolved.src : null; + const resolvedSrc = isRemoteUrl && icon ? icon : isLocalRef ? localSrc : null; + + useEffect(() => { + if (!icon || !isLocalRef) { + return; + } + + const localIcon = icon; + let cancelled = false; + void resolveWorkspaceIconDisplaySrc(localIcon) + .then((src) => { + if (cancelled) { + return; + } + setLocalResolved({ icon: localIcon, src }); + }) + .catch(() => { + if (cancelled) { + return; + } + setFailedIcon(localIcon); + }); + + return () => { + cancelled = true; + }; + }, [icon, isLocalRef]); + + const shouldRenderImage = useMemo( + () => isRemoteUrl || isLocalRef, + [isLocalRef, isRemoteUrl] + ); - if (!icon || failed) { + if (!icon || hasFailed) { return {fallback}; } - if (icon.startsWith('http')) { + if (shouldRenderImage) { + if (!resolvedSrc) { + return {fallback}; + } return ( setFailed(true)} + onError={() => setFailedIcon(icon)} /> ); } diff --git a/apps/desktop/src/components/SourceBadge.tsx b/apps/desktop/src/components/SourceBadge.tsx index 6dd139a9..baae55bb 100644 --- a/apps/desktop/src/components/SourceBadge.tsx +++ b/apps/desktop/src/components/SourceBadge.tsx @@ -6,17 +6,32 @@ import type { InstallationSource } from '@/types/registry'; interface SourceBadgeProps { source: InstallationSource | undefined; + /** Source server ID when this install is a clone (display-only lineage). */ + clonedFrom?: string | null; className?: string; } /** * Badge showing where a server was installed from. - * - * - Registry: Blue badge - installed from official/bundled registry - * - Config File: Green badge - synced from user's JSON config file - * - Manual: Gray badge - manually entered via UI + * + * - Clone: Indigo badge — derived from another installed server + * - Registry: Blue badge — installed from official/bundled registry + * - Config File: Green badge — synced from user's JSON config file + * - Manual: Gray badge — manually entered via UI */ -export function SourceBadge({ source, className = '' }: SourceBadgeProps) { +export function SourceBadge({ source, clonedFrom, className = '' }: SourceBadgeProps) { + if (clonedFrom) { + return ( + + Clone of {clonedFrom} + + ); + } + if (!source) { return null; } @@ -56,35 +71,3 @@ export function SourceBadge({ source, className = '' }: SourceBadgeProps) { return null; } } - -/** - * Get the appropriate uninstall action label based on source. - */ -export function getUninstallLabel(source: InstallationSource | undefined): string { - if (!source) { - return 'Uninstall'; - } - - switch (source.type) { - case 'user_config': - return 'Remove from Config'; - case 'manual_entry': - return 'Remove'; - case 'registry': - default: - return 'Uninstall'; - } -} - -/** - * Get confirmation message for uninstalling based on source. - */ -export function getUninstallConfirmMessage( - serverName: string, - source: InstallationSource | undefined -): string { - if (source?.type === 'user_config') { - return `This will remove "${serverName}" from your config file. You can re-add it by editing the config file.`; - } - return `Are you sure you want to uninstall "${serverName}"? You can reinstall it from the registry.`; -} diff --git a/apps/desktop/src/components/source-badge.helpers.ts b/apps/desktop/src/components/source-badge.helpers.ts new file mode 100644 index 00000000..40663c75 --- /dev/null +++ b/apps/desktop/src/components/source-badge.helpers.ts @@ -0,0 +1,33 @@ +import type { InstallationSource } from '@/types/registry'; + +/** + * Get the appropriate uninstall action label based on source. + */ +export function getUninstallLabel(source: InstallationSource | undefined): string { + if (!source) { + return 'Uninstall'; + } + + switch (source.type) { + case 'user_config': + return 'Remove from Config'; + case 'manual_entry': + return 'Remove'; + case 'registry': + default: + return 'Uninstall'; + } +} + +/** + * Get confirmation message for uninstalling based on source. + */ +export function getUninstallConfirmMessage( + serverName: string, + source: InstallationSource | undefined +): string { + if (source?.type === 'user_config') { + return `This will remove "${serverName}" from your config file. You can re-add it by editing the config file.`; + } + return `Are you sure you want to uninstall "${serverName}"? You can reinstall it from the registry.`; +} diff --git a/apps/desktop/src/features/dashboard/DashboardPage.tsx b/apps/desktop/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 00000000..10a36b38 --- /dev/null +++ b/apps/desktop/src/features/dashboard/DashboardPage.tsx @@ -0,0 +1,38 @@ +import { ConnectionCard } from '@/components/ConnectionCard'; +import { DashboardQuickLinks } from './DashboardQuickLinks'; +import { DashboardRecentActivity } from './DashboardRecentActivity'; +import { DashboardServerHealth } from './DashboardServerHealth'; +import { DashboardStatCards } from './DashboardStatCards'; +import { useDashboardData } from './useDashboardData'; + +/** + * Home dashboard — gateway connection, stat cards, server health, and quick navigation. + */ +export function DashboardPage() { + const { stats, attentionServers, isLoading } = useDashboardData(); + + return ( +
+
+

+ Dashboard +

+

+ Welcome to McpMux. Here's an overview of your setup. +

+
+ + + + + +
+
+ + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/features/dashboard/DashboardQuickLinks.tsx b/apps/desktop/src/features/dashboard/DashboardQuickLinks.tsx new file mode 100644 index 00000000..18f8bc50 --- /dev/null +++ b/apps/desktop/src/features/dashboard/DashboardQuickLinks.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from 'react'; +import { + FolderOpen, + Globe, + Monitor, + Search, + Server, + Settings, + ShoppingBasket, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui'; +import { useNavigateTo } from '@/stores'; +import type { NavItem } from '@/stores/types'; + +type QuickLinkConfig = { + nav: NavItem; + label: string; + description: string; + icon: ReactNode; + testId: string; +}; + +const QUICK_LINK_CONFIG: QuickLinkConfig[] = [ + { + nav: 'servers', + label: 'My Servers', + description: 'Manage your installed MCP servers', + icon: , + testId: 'quick-link-servers', + }, + { + nav: 'registry', + label: 'Discover', + description: 'Browse the MCP server registry', + icon: , + testId: 'quick-link-discover', + }, + { + nav: 'spaces', + label: 'Spaces', + description: 'Manage your connected AI client spaces', + icon: , + testId: 'quick-link-spaces', + }, + { + nav: 'featuresets', + label: 'Bundles', + description: 'Curated tool sets for specific workflows', + icon: , + testId: 'quick-link-featuresets', + }, + { + nav: 'workspaces', + label: 'Projects', + description: 'Bind workspace roots to Spaces', + icon: , + testId: 'quick-link-workspaces', + }, + { + nav: 'clients', + label: 'Clients', + description: 'Manage connected AI clients', + icon: , + testId: 'quick-link-clients', + }, + { + nav: 'settings', + label: 'Settings', + description: 'Configure McpMux preferences', + icon: , + testId: 'quick-link-settings', + }, +]; + +/** + * Compact navigation grid covering every sidebar destination except Dashboard. + */ +export function DashboardQuickLinks() { + const navigateTo = useNavigateTo(); + + return ( + + + Quick Links + Jump to any section + + +
+ {QUICK_LINK_CONFIG.map((link) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/desktop/src/features/dashboard/DashboardRecentActivity.tsx b/apps/desktop/src/features/dashboard/DashboardRecentActivity.tsx new file mode 100644 index 00000000..8ba605b3 --- /dev/null +++ b/apps/desktop/src/features/dashboard/DashboardRecentActivity.tsx @@ -0,0 +1,93 @@ +import { useCallback, useState } from 'react'; +import { CheckCircle2, Eye, ShieldAlert, XCircle } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui'; +import type { MetaToolAuditEvent } from '@/lib/api/metaTools'; +import { useMetaToolEventListener } from '@/hooks/useMetaToolEvents'; + +const MAX_ROWS = 5; + +/** + * Pick the icon shown beside a meta-tool audit row based on the gateway decision. + */ +function DecisionIcon({ decision }: { decision: string }) { + const className = 'h-4 w-4 mt-0.5 flex-shrink-0'; + + switch (decision) { + case 'read': + return ; + case 'allow_once': + case 'always_for_this_session_and_client': + return ; + case 'deny': + case 'timeout': + case 'rate_limited': + case 'approval_required': + return ; + default: + return ; + } +} + +/** + * Compact live feed of recent `mcpmux_*` meta-tool invocations from connected clients. + */ +export function DashboardRecentActivity() { + const [rows, setRows] = useState([]); + + const appendRow = useCallback((event: MetaToolAuditEvent) => { + setRows((prev) => { + const next = [event, ...prev]; + return next.length > MAX_ROWS ? next.slice(0, MAX_ROWS) : next; + }); + }, []); + + useMetaToolEventListener(appendRow); + + return ( + + + + + Recent Activity + + Last {MAX_ROWS} meta-tool invocations + + + {rows.length === 0 ? ( +

+ Waiting for mcpmux_* tool calls… +

+ ) : ( +
    + {rows.map((row, index) => ( +
  • + +
    +
    + {row.tool_name} + + {row.decision} + +
    +
    + client {row.client_id.slice(0, 8)} •{' '} + {new Date(row.timestamp).toLocaleTimeString()} +
    + {row.summary && ( +
    + {row.summary} +
    + )} +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/features/dashboard/DashboardServerHealth.tsx b/apps/desktop/src/features/dashboard/DashboardServerHealth.tsx new file mode 100644 index 00000000..2fddaf34 --- /dev/null +++ b/apps/desktop/src/features/dashboard/DashboardServerHealth.tsx @@ -0,0 +1,137 @@ +import { useState, type ReactNode } from 'react'; +import { AlertCircle, CheckCircle2, KeyRound, Loader2, Settings2 } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui'; +import { ServerLogViewer } from '@/components/ServerLogViewer'; +import { useNavigateTo, useSetPendingServersFilter } from '@/stores'; +import type { AttentionKind, AttentionServer } from './dashboard.helpers'; + +interface DashboardServerHealthProps { + attentionServers: AttentionServer[]; + isLoading: boolean; +} + +/** + * Label and icon for each server-health attention bucket. + */ +function attentionPresentation(kind: AttentionKind): { + label: string; + icon: ReactNode; + badgeClass: string; +} { + switch (kind) { + case 'error': + return { + label: 'Error', + icon: , + badgeClass: 'text-red-600 bg-red-500/10', + }; + case 'auth_required': + return { + label: 'Auth Required', + icon: , + badgeClass: 'text-amber-700 bg-amber-500/10', + }; + case 'needs_setup': + return { + label: 'Needs Setup', + icon: , + badgeClass: 'text-blue-700 bg-blue-500/10', + }; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +/** + * Lists enabled servers that need operator attention, with a link to My Servers. + */ +export function DashboardServerHealth({ + attentionServers, + isLoading, +}: DashboardServerHealthProps) { + const navigateTo = useNavigateTo(); + const setPendingServersFilter = useSetPendingServersFilter(); + const hasIssues = attentionServers.length > 0; + const [logServer, setLogServer] = useState<{ id: string; name: string } | null>(null); + + return ( + <> + {logServer && ( + setLogServer(null)} + /> + )} + + + Server Health + Servers that need your attention + + + {isLoading ? ( +
+ + Loading server status… +
+ ) : hasIssues ? ( +
    + {attentionServers.map((server) => { + const presentation = attentionPresentation(server.kind); + + return ( +
  • + +
  • + ); + })} +
+ ) : ( +
+ +
+

All servers healthy

+

+ No enabled servers need attention right now. +

+
+
+ )} + + +
+
+ + ); +} diff --git a/apps/desktop/src/features/dashboard/DashboardStatCards.tsx b/apps/desktop/src/features/dashboard/DashboardStatCards.tsx new file mode 100644 index 00000000..2ab107a1 --- /dev/null +++ b/apps/desktop/src/features/dashboard/DashboardStatCards.tsx @@ -0,0 +1,165 @@ +import type { KeyboardEvent } from 'react'; +import { FolderOpen, Globe, Monitor, Server, Wrench } from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@mcpmux/ui'; +import { useNavigateTo, useViewSpace } from '@/stores'; +import type { DashboardStats } from './dashboard.helpers'; + +interface DashboardStatCardsProps { + stats: DashboardStats; +} + +const STAT_CARD_CLASS = + 'cursor-pointer transition-all hover:shadow-lg hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50'; + +/** + * Navigate when the user activates a stat card via click or keyboard. + */ +function activateStatCard(event: KeyboardEvent, navigate: () => void) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + navigate(); + } +} + +/** + * Top-row stat cards with descriptions and deep links into sidebar destinations. + */ +export function DashboardStatCards({ stats }: DashboardStatCardsProps) { + const navigateTo = useNavigateTo(); + const viewSpace = useViewSpace(); + + return ( +
+ navigateTo('servers')} + onKeyDown={(event) => activateStatCard(event, () => navigateTo('servers'))} + > + + + + My Servers + + Installed MCP servers + + +
+ {stats.connectedServers}/{stats.installedServers} +
+
connected / installed
+
+
+ + navigateTo('featuresets')} + onKeyDown={(event) => activateStatCard(event, () => navigateTo('featuresets'))} + > + + + + Feature Sets + + Curated tool bundles + + +
+ {stats.featureSets} +
+
bundles
+
+
+ + navigateTo('clients')} + onKeyDown={(event) => activateStatCard(event, () => navigateTo('clients'))} + > + + + + Clients + + Connected AI clients + + +
+ {stats.clients} +
+
clients
+
+
+ + navigateTo('spaces')} + onKeyDown={(event) => activateStatCard(event, () => navigateTo('spaces'))} + > + + + + Active Space + + Currently viewed space + + +
+ {viewSpace?.icon} {viewSpace?.name ?? 'None'} +
+
+ {stats.spaces} space{stats.spaces !== 1 ? 's' : ''} total +
+
+
+ + navigateTo('workspaces')} + onKeyDown={(event) => activateStatCard(event, () => navigateTo('workspaces'))} + > + + + + Workspaces + + Bound workspace roots + + +
+ {stats.workspaceBindings} +
+
bindings
+
+
+
+ ); +} diff --git a/apps/desktop/src/features/dashboard/dashboard.helpers.ts b/apps/desktop/src/features/dashboard/dashboard.helpers.ts new file mode 100644 index 00000000..f7e0fc43 --- /dev/null +++ b/apps/desktop/src/features/dashboard/dashboard.helpers.ts @@ -0,0 +1,117 @@ +import type { ConnectionStatus, ServerStatusResponse } from '@/lib/api/serverManager'; +import type { InstalledServerState, ServerDefinition } from '@/types/registry'; +import { resolveInstalledDisplayName } from '@/features/servers/server-display-name.helpers'; + +/** Aggregated counts shown in the dashboard stat cards. */ +export type DashboardStats = { + installedServers: number; + connectedServers: number; + featureSets: number; + clients: number; + workspaceBindings: number; + spaces: number; +}; + +/** Severity bucket for a server that needs operator attention. */ +export type AttentionKind = 'error' | 'auth_required' | 'needs_setup'; + +/** One installed server surfaced in the health panel. */ +export type AttentionServer = { + serverId: string; + displayName: string; + kind: AttentionKind; + detail: string; +}; + +const ATTENTION_PRIORITY: Record = { + error: 0, + auth_required: 1, + needs_setup: 2, +}; + +const MAX_ATTENTION_SERVERS = 8; + +/** + * Whether an installed server is missing values for required transport inputs. + */ +export function hasMissingRequiredInputs(state: InstalledServerState): boolean { + if (!state.cached_definition) { + return false; + } + + try { + const definition = JSON.parse(state.cached_definition) as ServerDefinition; + const inputs = definition.transport.metadata?.inputs ?? []; + const values = state.input_values ?? {}; + + return inputs.some((input) => input.required && !values[input.id]); + } catch { + return false; + } +} + +/** + * Map a runtime connection status to a dashboard attention item, if any. + */ +export function attentionFromStatus( + status: ConnectionStatus, + message: string | null +): Pick | null { + if (status === 'error') { + return { kind: 'error', detail: message ?? 'Connection error' }; + } + + if (status === 'oauth_required') { + return { kind: 'auth_required', detail: 'Authentication required' }; + } + + return null; +} + +/** + * Build the ordered list of enabled servers that need attention in the current Space. + */ +export function buildAttentionServers( + installed: InstalledServerState[], + statuses: Record +): AttentionServer[] { + const items: AttentionServer[] = []; + + for (const server of installed) { + if (!server.enabled) { + continue; + } + + const displayName = resolveInstalledDisplayName(server); + const runtime = statuses[server.server_id]; + + if (hasMissingRequiredInputs(server)) { + items.push({ + serverId: server.server_id, + displayName, + kind: 'needs_setup', + detail: 'Missing required configuration', + }); + continue; + } + + if (runtime) { + const fromStatus = attentionFromStatus(runtime.status, runtime.message); + if (fromStatus) { + items.push({ + serverId: server.server_id, + displayName, + ...fromStatus, + }); + } + } + } + + return items + .sort( + (left, right) => + ATTENTION_PRIORITY[left.kind] - ATTENTION_PRIORITY[right.kind] || + left.displayName.localeCompare(right.displayName) + ) + .slice(0, MAX_ATTENTION_SERVERS); +} diff --git a/apps/desktop/src/features/dashboard/index.ts b/apps/desktop/src/features/dashboard/index.ts new file mode 100644 index 00000000..d1e7bf22 --- /dev/null +++ b/apps/desktop/src/features/dashboard/index.ts @@ -0,0 +1 @@ +export { DashboardPage } from './DashboardPage'; diff --git a/apps/desktop/src/features/dashboard/useDashboardData.ts b/apps/desktop/src/features/dashboard/useDashboardData.ts new file mode 100644 index 00000000..5841abfa --- /dev/null +++ b/apps/desktop/src/features/dashboard/useDashboardData.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react'; +import { listClients } from '@/lib/api/clients'; +import { listFeatureSets, listFeatureSetsBySpace } from '@/lib/api/featureSets'; +import { getGatewayStatus } from '@/lib/api/gateway'; +import { listInstalledServers } from '@/lib/api/registry'; +import { getServerStatuses } from '@/lib/api/serverManager'; +import { listWorkspaceBindings } from '@/lib/api/workspaceBindings'; +import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents'; +import { useIsLoading, useSpaces, useViewSpace } from '@/stores'; +import { + buildAttentionServers, + type AttentionServer, + type DashboardStats, +} from './dashboard.helpers'; + +const EMPTY_STATS: DashboardStats = { + installedServers: 0, + connectedServers: 0, + featureSets: 0, + clients: 0, + workspaceBindings: 0, + spaces: 0, +}; + +/** + * Loads dashboard stats and server-health rows, refreshing on Space or gateway changes. + */ +export function useDashboardData() { + const viewSpace = useViewSpace(); + const spaces = useSpaces(); + const isLoadingSpaces = useIsLoading('spaces'); + const [stats, setStats] = useState(EMPTY_STATS); + const [attentionServers, setAttentionServers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const reload = useCallback(async () => { + const spaceId = viewSpace?.id; + + try { + const [clients, featureSets, gateway, installedServers, workspaceBindings] = + await Promise.all([ + listClients(), + spaceId ? listFeatureSetsBySpace(spaceId) : listFeatureSets(), + getGatewayStatus(spaceId), + listInstalledServers(spaceId), + listWorkspaceBindings(), + ]); + + let nextAttention: AttentionServer[] = []; + if (spaceId) { + const statuses = await getServerStatuses(spaceId); + nextAttention = buildAttentionServers(installedServers, statuses); + } + + setStats({ + installedServers: installedServers.length, + connectedServers: gateway.connected_backends, + featureSets: featureSets.length, + clients: clients.length, + workspaceBindings: workspaceBindings.length, + spaces: spaces.length, + }); + setAttentionServers(nextAttention); + } catch (error) { + console.error('Failed to load dashboard data:', error); + } finally { + setIsLoading(false); + } + }, [spaces.length, viewSpace?.id]); + + // Wait for useDataSync before fan-out GETs — avoids HTTP/1.1 connection starvation with SSE. + useEffect(() => { + if (isLoadingSpaces) { + return; + } + setIsLoading(true); + void reload(); + }, [reload, isLoadingSpaces]); + + useGatewayEvents((payload) => { + if (payload.action === 'started') { + reload(); + return; + } + + if (payload.action === 'stopped') { + setStats((prev) => ({ ...prev, connectedServers: 0 })); + } + }); + + useServerStatusEvents((payload) => { + if (payload.status === 'connected' || payload.status === 'disconnected') { + reload(); + } + }); + + return { stats, attentionServers, isLoading, reload }; +} diff --git a/apps/desktop/src/features/servers/AddServerMenu.tsx b/apps/desktop/src/features/servers/AddServerMenu.tsx new file mode 100644 index 00000000..7653add0 --- /dev/null +++ b/apps/desktop/src/features/servers/AddServerMenu.tsx @@ -0,0 +1,48 @@ +import { ChevronDown, Compass, FileJson, Plus } from 'lucide-react'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@mcpmux/ui'; + +interface AddServerMenuProps { + /** Opens the Discover page to browse the community server registry. */ + onDiscover: () => void; + /** Opens the Space JSON editor to add a custom server definition. */ + onCustom: () => void; +} + +/** + * Dropdown for the two ways to add MCP servers: registry discover vs custom JSON. + */ +export function AddServerMenu({ onDiscover, onCustom }: AddServerMenuProps) { + return ( + + + + + + + + + + ); +} diff --git a/apps/desktop/src/features/servers/ServerEnabledToggle.tsx b/apps/desktop/src/features/servers/ServerEnabledToggle.tsx new file mode 100644 index 00000000..ebd9c067 --- /dev/null +++ b/apps/desktop/src/features/servers/ServerEnabledToggle.tsx @@ -0,0 +1,39 @@ +import { Switch } from '@mcpmux/ui'; + +interface ServerEnabledToggleProps { + serverId: string; + enabled: boolean; + isLoading: boolean; + disabled?: boolean; + onToggle: (enabled: boolean) => void; +} + +/** + * Labeled enable/disable control for an installed server row. + */ +export function ServerEnabledToggle({ + serverId, + enabled, + isLoading, + disabled = false, + onToggle, +}: ServerEnabledToggleProps) { + let label: string; + if (isLoading) { + label = enabled ? 'Disabling…' : 'Enabling…'; + } else { + label = enabled ? 'Enabled' : 'Disabled'; + } + + return ( +
+ {label} + +
+ ); +} diff --git a/apps/desktop/src/features/servers/ServersCountSummary.tsx b/apps/desktop/src/features/servers/ServersCountSummary.tsx new file mode 100644 index 00000000..b8cbbd00 --- /dev/null +++ b/apps/desktop/src/features/servers/ServersCountSummary.tsx @@ -0,0 +1,35 @@ +import { HoverTooltip } from '@mcpmux/ui'; +import { + describeServerCountSummary, + formatServerCountSummary, + type ServerCountSummary, +} from './servers-page.helpers'; + +interface ServersCountSummaryProps { + summary: ServerCountSummary; +} + +/** + * Inline installed-server counts beside the My Servers title, with hover breakdown. + */ +export function ServersCountSummary({ summary }: ServersCountSummaryProps) { + if (summary.installed === 0) { + return null; + } + + return ( + +

+ {formatServerCountSummary(summary)} +

+
+ ); +} diff --git a/apps/desktop/src/features/servers/ServersFiltersPopover.tsx b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx new file mode 100644 index 00000000..7a4bcaa1 --- /dev/null +++ b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { ChevronDown, SlidersHorizontal } from 'lucide-react'; +import { + Button, + ChipButton, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + HoverTooltip, +} from '@mcpmux/ui'; +import { + STATUS_FILTER_IDS, + TRANSPORT_FILTER_IDS, + countActiveServerFilters, + describeAppliedServerFilters, + getStatusFilterLabel, + getTransportFilterLabel, + type StatusFilterKey, + type TransportFilter, +} from './servers-page.helpers'; + +interface ServersFiltersPopoverProps { + transportFilter: TransportFilter; + onTransportFilterChange: (filter: TransportFilter) => void; + activeStatusFilters: Set; + onToggleStatusFilter: (statusKey: StatusFilterKey) => void; + onClearStatusFilters: () => void; + onClearAllFilters: () => void; +} + +/** + * Popover for transport (stdio/http) and Beeper-style multi-select status filters. + */ +export function ServersFiltersPopover({ + transportFilter, + onTransportFilterChange, + activeStatusFilters, + onToggleStatusFilter, + onClearStatusFilters, + onClearAllFilters, +}: ServersFiltersPopoverProps) { + const [open, setOpen] = useState(false); + const activeCount = countActiveServerFilters(transportFilter, activeStatusFilters); + const appliedFilterLines = describeAppliedServerFilters(transportFilter, activeStatusFilters); + + return ( + + ); +} diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 533933af..b6d8af28 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -836,7 +836,7 @@ export function ServersPage() { return; } - const { getUninstallLabel } = await import('@/components/SourceBadge'); + const { getUninstallLabel } = await import('@/components/source-badge.helpers'); const actionLabel = getUninstallLabel(server.installation_source); setActionLoading(`uninstall-${server.id}`); @@ -856,7 +856,7 @@ export function ServersPage() { } const { server } = uninstallClonesDialog; - const { getUninstallLabel } = await import('@/components/SourceBadge'); + const { getUninstallLabel } = await import('@/components/source-badge.helpers'); const actionLabel = getUninstallLabel(server.installation_source); setUninstallClonesDialog(null); diff --git a/apps/desktop/src/features/servers/index.ts b/apps/desktop/src/features/servers/index.ts index 984afb2a..f130b052 100644 --- a/apps/desktop/src/features/servers/index.ts +++ b/apps/desktop/src/features/servers/index.ts @@ -2,3 +2,7 @@ export { ServersPage } from './ServersPage'; export { CloneAccountModal } from './CloneAccountModal'; export { UninstallSourceWithClonesDialog } from './UninstallSourceWithClonesDialog'; export { ServerActionMenu } from './ServerActionMenu'; +export { AddServerMenu } from './AddServerMenu'; +export { ServerEnabledToggle } from './ServerEnabledToggle'; +export { ServersCountSummary } from './ServersCountSummary'; +export { ServersFiltersPopover } from './ServersFiltersPopover'; diff --git a/apps/desktop/src/features/servers/servers-page.helpers.ts b/apps/desktop/src/features/servers/servers-page.helpers.ts new file mode 100644 index 00000000..cc61c4dd --- /dev/null +++ b/apps/desktop/src/features/servers/servers-page.helpers.ts @@ -0,0 +1,268 @@ +import type { ServerFeature } from '@/lib/api/serverFeatures'; +import type { ServerViewModel } from '../../types/registry'; + +/** Runtime action used to derive status filter buckets. */ +export type ServerActionKey = + | 'enable' + | 'configure' + | 'connecting' + | 'authenticating' + | 'auth_required' + | 'running' + | 'error' + | 'connected_auto'; + +/** Transport filter for installed servers. */ +export type TransportFilter = 'all' | 'stdio' | 'http'; + +/** Status bucket for Beeper-style multi-select filters. */ +export type StatusFilterKey = 'connected' | 'disabled' | 'error' | 'needs_setup'; + +export const TRANSPORT_FILTER_IDS: TransportFilter[] = ['all', 'stdio', 'http']; + +export const STATUS_FILTER_IDS: StatusFilterKey[] = [ + 'connected', + 'disabled', + 'error', + 'needs_setup', +]; + +/** + * Resolve the display label for a transport filter chip. + */ +export function getTransportFilterLabel(id: TransportFilter): string { + switch (id) { + case 'all': + return 'All'; + case 'stdio': + return 'Stdio'; + case 'http': + return 'HTTP'; + default: { + const _exhaustive: never = id; + return _exhaustive; + } + } +} + +/** + * Resolve the display label for a status filter chip. + */ +export function getStatusFilterLabel(id: StatusFilterKey): string { + switch (id) { + case 'connected': + return 'Connected'; + case 'disabled': + return 'Disabled'; + case 'error': + return 'Error'; + case 'needs_setup': + return 'Needs Setup'; + default: { + const _exhaustive: never = id; + return _exhaustive; + } + } +} + +/** Group discovered features by installed server id. */ +export function groupFeaturesByServerId(features: ServerFeature[]): Record { + return features.reduce>((acc, feature) => { + const bucket = acc[feature.server_id] ?? []; + bucket.push(feature); + acc[feature.server_id] = bucket; + return acc; + }, {}); +} + +/** + * Map a server action to the status filter bucket it belongs in. + */ +export function statusKeyFromAction(action: ServerActionKey): StatusFilterKey { + switch (action) { + case 'running': + case 'connected_auto': + return 'connected'; + case 'enable': + return 'disabled'; + case 'error': + return 'error'; + default: + return 'needs_setup'; + } +} + +/** Whether a server matches the selected transport filter. */ +export function matchesTransport(server: ServerViewModel, transportFilter: TransportFilter): boolean { + if (transportFilter === 'all') { + return true; + } + + return server.transport.type === transportFilter; +} + +/** + * Whether a server matches active status toggles. + * Empty set means show all (Beeper-style: no status filter applied). + */ +export function matchesStatus( + action: ServerActionKey, + activeStatusFilters: ReadonlySet +): boolean { + if (activeStatusFilters.size === 0) { + return true; + } + + return activeStatusFilters.has(statusKeyFromAction(action)); +} + +/** Whether a feature name or description matches the search query. */ +function featureMatchesQuery(feature: ServerFeature, query: string): boolean { + return ( + feature.feature_name.toLowerCase().includes(query) || + (feature.display_name?.toLowerCase().includes(query) ?? false) || + (feature.description?.toLowerCase().includes(query) ?? false) + ); +} + +/** + * Whether an installed server matches transport, status, and search filters. + */ +export function serverMatchesFilters( + server: ServerViewModel, + searchQuery: string, + features: ServerFeature[], + transportFilter: TransportFilter, + activeStatusFilters: ReadonlySet, + serverAction: ServerActionKey +): boolean { + if (!matchesTransport(server, transportFilter)) { + return false; + } + + if (!matchesStatus(serverAction, activeStatusFilters)) { + return false; + } + + const query = searchQuery.trim().toLowerCase(); + if (!query) { + return true; + } + + const metadataMatch = + server.name.toLowerCase().includes(query) || + server.id.toLowerCase().includes(query) || + (server.description?.toLowerCase().includes(query) ?? false); + + if (metadataMatch) { + return true; + } + + return features.some((feature) => featureMatchesQuery(feature, query)); +} + +/** + * Count non-default transport and status filters for the Filters button badge. + */ +export function countActiveServerFilters( + transportFilter: TransportFilter, + activeStatusFilters: ReadonlySet +): number { + let count = activeStatusFilters.size; + if (transportFilter !== 'all') { + count += 1; + } + return count; +} + +/** Per-status counts for the My Servers header summary. */ +export type ServerCountSummary = { + installed: number; + connected: number; + disabled: number; + error: number; + needsSetup: number; +}; + +/** + * Aggregate installed-server counts by status bucket (same buckets as status filters). + */ +export function computeServerCountSummary( + servers: ServerViewModel[], + getAction: (server: ServerViewModel) => ServerActionKey +): ServerCountSummary { + const summary: ServerCountSummary = { + installed: servers.length, + connected: 0, + disabled: 0, + error: 0, + needsSetup: 0, + }; + + for (const server of servers) { + switch (statusKeyFromAction(getAction(server))) { + case 'connected': + summary.connected += 1; + break; + case 'disabled': + summary.disabled += 1; + break; + case 'error': + summary.error += 1; + break; + case 'needs_setup': + summary.needsSetup += 1; + break; + } + } + + return summary; +} + +/** + * Compact inline summary next to the My Servers title. + */ +export function formatServerCountSummary(summary: ServerCountSummary): string { + return `${summary.connected} connected, ${summary.installed - summary.connected} other`; +} + +/** + * Tooltip lines for the server count hover panel. + */ +export function describeServerCountSummary(summary: ServerCountSummary): string[] { + const lines = [ + `${summary.installed} installed`, + `${summary.connected} connected`, + `${summary.disabled} disabled`, + `${summary.error} with errors`, + ]; + + if (summary.needsSetup > 0) { + lines.push(`${summary.needsSetup} need setup`); + } + + return lines; +} + +/** + * Human-readable lines describing the currently applied server list filters. + */ +export function describeAppliedServerFilters( + transportFilter: TransportFilter, + activeStatusFilters: ReadonlySet +): string[] { + const transportLabel = getTransportFilterLabel(transportFilter); + + const statusLabel = + activeStatusFilters.size === 0 + ? 'All' + : STATUS_FILTER_IDS.filter((filterId) => activeStatusFilters.has(filterId)) + .map((filterId) => getStatusFilterLabel(filterId)) + .join(', '); + + if (countActiveServerFilters(transportFilter, activeStatusFilters) === 0) { + return ['No filters applied', 'Showing all servers']; + } + + return [`Transport: ${transportLabel}`, `Status: ${statusLabel}`]; +} diff --git a/apps/desktop/src/features/settings/AboutSection.tsx b/apps/desktop/src/features/settings/AboutSection.tsx new file mode 100644 index 00000000..472f37e2 --- /dev/null +++ b/apps/desktop/src/features/settings/AboutSection.tsx @@ -0,0 +1,38 @@ +import { Info } from 'lucide-react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@mcpmux/ui'; +import { BuildStampPanelContent } from './BuildStampPanel'; +import { useBuildStamp } from './use-build-stamp.hook'; + +/** + * Web-admin Settings card showing app version and build stamp metadata. + */ +export function AboutSection() { + const stamp = useBuildStamp(); + + return ( + + + + + About + + App version and build information + + +
+ +

+ {stamp.loading ? 'Loading…' : `v${stamp.version || 'unknown'}`} +

+ +
+
+
+ ); +} diff --git a/apps/desktop/src/features/settings/index.ts b/apps/desktop/src/features/settings/index.ts index 6d31a5a4..1cee7cf8 100644 --- a/apps/desktop/src/features/settings/index.ts +++ b/apps/desktop/src/features/settings/index.ts @@ -1,4 +1,5 @@ export { SettingsPage } from './SettingsPage'; +export { AboutSection } from './AboutSection'; export { UpdateChecker } from './UpdateChecker'; export { BuildStampPanel, BuildStampPanelContent } from './BuildStampPanel'; export { ServerUpdatesSection } from './ServerUpdatesSection'; diff --git a/apps/desktop/src/features/spaces/SpacePanel.tsx b/apps/desktop/src/features/spaces/SpacePanel.tsx new file mode 100644 index 00000000..cc64819f --- /dev/null +++ b/apps/desktop/src/features/spaces/SpacePanel.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from 'react'; +import { Loader2, Save, Trash2, X } from 'lucide-react'; +import { Button, useConfirm, useToast, ToastContainer } from '@mcpmux/ui'; +import type { Space } from '@/lib/api/spaces'; +import { deleteSpace, updateSpace } from '@/lib/api/spaces'; + +const SPACE_ICON_OPTIONS = ['🌐', '💻', '🚀', '🏢', '🏠', '🔒', '🧪', '📦'] as const; + +export interface SpacePanelProps { + space: Space; + onClose: () => void; + onSaved: (space: Space) => void; + onDeleted: (id: string) => void; +} + +/** + * Slide-out panel for editing a Space's display metadata (name, icon, description). + */ +export function SpacePanel({ space, onClose, onSaved, onDeleted }: SpacePanelProps) { + const [name, setName] = useState(space.name); + const [icon, setIcon] = useState(space.icon ?? '🌐'); + const [description, setDescription] = useState(space.description ?? ''); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const { toasts, success, error: showError, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); + + useEffect(() => { + setName(space.name); + setIcon(space.icon ?? '🌐'); + setDescription(space.description ?? ''); + setError(null); + }, [space]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + /** + * Persist name, icon, and description to the backend and notify the parent. + */ + const handleSave = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + setError('Name is required'); + return; + } + + setIsSaving(true); + setError(null); + try { + const updated = await updateSpace(space.id, { + name: trimmedName, + icon: icon.trim() || undefined, + description: description.trim() || undefined, + }); + success('Space updated', `"${updated.name}" has been saved.`); + onSaved(updated); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + showError('Save failed', msg); + } finally { + setIsSaving(false); + } + }; + + /** + * Delete this Space after confirmation (default Space cannot be deleted). + */ + const handleDelete = async () => { + const ok = await confirm({ + title: 'Delete Space', + message: `Are you sure you want to delete "${space.name}"? This action cannot be undone.`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (!ok) return; + + setIsDeleting(true); + try { + await deleteSpace(space.id); + success('Space deleted', `"${space.name}" has been removed.`); + onDeleted(space.id); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + showError('Delete failed', msg); + } finally { + setIsDeleting(false); + } + }; + + const hasChanges = + name.trim() !== space.name || + (icon.trim() || '🌐') !== (space.icon ?? '🌐') || + description.trim() !== (space.description ?? ''); + + return ( +
+ + {ConfirmDialogElement} + +
+
+
+
+ {icon} +
+
+

{space.name}

+ {space.is_default && ( + + Default + + )} +
+
+ +
+
+ +
+ {error && ( +

+ {error} +

+ )} + +
+ +
+ {SPACE_ICON_OPTIONS.map((emoji) => ( + + ))} +
+
+ +
+ + setName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="space-panel-name" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Optional description" + className="w-full px-3 py-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="space-panel-description" + /> +
+
+ +
+ + {!space.is_default && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/features/spaces/index.ts b/apps/desktop/src/features/spaces/index.ts index e9c3f51b..259da98c 100644 --- a/apps/desktop/src/features/spaces/index.ts +++ b/apps/desktop/src/features/spaces/index.ts @@ -1,2 +1,4 @@ export { SpacesPage } from './SpacesPage'; export { CreateSpaceModal } from './CreateSpaceModal'; +export { SpacePanel } from './SpacePanel'; +export type { SpacePanelProps } from './SpacePanel'; diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 0c677aa6..eaebad39 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -18,7 +18,7 @@ import { Radio, RefreshCw, Search, - Server as ServerIcon, + Server as ServerGlyph, Trash2, Wrench, X, @@ -45,6 +45,13 @@ import { type WorkspaceBindingInput, type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; +import { + deleteWorkspaceAppearance, + listWorkspaceAppearances, + upsertWorkspaceAppearance, + uploadWorkspaceIcon, + type WorkspaceAppearance, +} from '@/lib/api/workspaceAppearances'; import { isStarterFeatureSet, listFeatureSets, @@ -53,6 +60,8 @@ import { import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; import { WorkspaceSetupWizard } from './WorkspaceSetupWizard'; import { useSpaces, usePendingWorkspaceNew, useSetPendingWorkspaceNew } from '@/stores'; +import { ServerIcon } from '@/components/ServerIcon'; +import { pickPath } from '@/lib/backend/shell'; import type { Space } from '@/lib/api/spaces'; /** @@ -86,6 +95,7 @@ export function WorkspacesPage() { const pendingNew = usePendingWorkspaceNew(); const clearPendingNew = useSetPendingWorkspaceNew(); const [bindings, setBindings] = useState([]); + const [appearances, setAppearances] = useState([]); const [reportedRoots, setReportedRoots] = useState([]); const [featureSets, setFeatureSets] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -97,18 +107,22 @@ export function WorkspacesPage() { const [selected, setSelected] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [filter, setFilter] = useState<'all' | 'live' | 'mapped' | 'unmapped'>('all'); + /** Optimistic icon overrides while the inspector panel is open. */ + const [liveEntryIcons, setLiveEntryIcons] = useState>(new Map()); const loadData = useCallback(async () => { setError(null); try { - const [b, fs, roots] = await Promise.all([ + const [b, fs, roots, ap] = await Promise.all([ listWorkspaceBindings(), listFeatureSets(), listReportedWorkspaceRoots().catch(() => [] as string[]), + listWorkspaceAppearances().catch(() => [] as WorkspaceAppearance[]), ]); setBindings(b); setFeatureSets(fs); setReportedRoots(roots); + setAppearances(ap); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } @@ -159,6 +173,13 @@ export function WorkspacesPage() { for (const b of bindings) m.set(b.workspace_root.toLowerCase(), b); return m; }, [bindings]); + const appearancesByRoot = useMemo(() => { + const m = new Map(); + for (const appearance of appearances) { + m.set(appearance.workspace_root.toLowerCase(), appearance.icon); + } + return m; + }, [appearances]); const fsById = useMemo(() => { const m = new Map(); for (const f of featureSets) m.set(f.id, f); @@ -248,9 +269,22 @@ export function WorkspacesPage() { const selectedEntry: Entry | null = selected?.mode === 'entry' ? entries.find((e) => e.id === selected.id) ?? null : null; + const resolveEntryIcon = useCallback( + (entry: Entry): string | null => + liveEntryIcons.get(entry.id) ?? + appearancesByRoot.get(entry.root.toLowerCase()) ?? + null, + [appearancesByRoot, liveEntryIcons] + ); const selectedIsNew = selected?.mode === 'new'; const panelOpen = selected !== null; + useEffect(() => { + if (!panelOpen) { + setLiveEntryIcons(new Map()); + } + }, [panelOpen]); + const handleCreate = async (input: WorkspaceBindingInput): Promise => { const created = await createWorkspaceBinding(input); setBindings((prev) => @@ -439,6 +473,7 @@ export function WorkspacesPage() { { if (selectedEntry?.binding) await handleDelete(selectedEntry.binding); }} + onIconChange={(icon) => { + if (!selectedEntry) return; + setLiveEntryIcons((prev) => { + const next = new Map(prev); + if (icon) { + next.set(selectedEntry.id, icon); + } else { + next.delete(selectedEntry.id); + } + return next; + }); + }} onError={(msg) => showError('Could not save', msg)} /> )} @@ -509,6 +557,11 @@ export function WorkspacesPage() { // Filter segmented control // --------------------------------------------------------------------------- +function normalizeIcon(icon: string | null | undefined): string | null { + const trimmed = icon?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; +} + /** * Render a list of FeatureSet names as a single string for display * surfaces (cards, badges, panel headers) where a multi-FS binding has @@ -632,12 +685,14 @@ const CARD_TONES = { function EntryCard({ entry, + icon, spaceName, fsNames, selected, onClick, }: { entry: Entry; + icon: string | null; spaceName: string | undefined; /** Resolved FeatureSet names for a mapped folder; empty when unmapped. */ fsNames: string[]; @@ -669,7 +724,9 @@ function EntryCard({
- {entry.isLive ? ( + {icon ? ( + + ) : entry.isLive ? ( ) : ( @@ -926,6 +983,7 @@ type SaveStatus = function InspectorPanel({ entry, isNew, + resolvedIcon, spaces, featureSets, existingBindings, @@ -933,9 +991,11 @@ function InspectorPanel({ onSubmit, onDelete, onError, + onIconChange, }: { entry: Entry | null; isNew: boolean; + resolvedIcon: string | null; spaces: Space[]; featureSets: FeatureSet[]; existingBindings: WorkspaceBinding[]; @@ -943,7 +1003,27 @@ function InspectorPanel({ onSubmit: (input: WorkspaceBindingInput) => Promise; onDelete: () => Promise; onError: (msg: string) => void; + /** Live icon edits from the binding form (before persistence lands in entry state). */ + onIconChange?: (icon: string | null) => void; }) { + const [editedIcon, setEditedIcon] = useState(undefined); + const [prevResolvedIcon, setPrevResolvedIcon] = useState(resolvedIcon); + + if (resolvedIcon !== prevResolvedIcon) { + setPrevResolvedIcon(resolvedIcon); + setEditedIcon(undefined); + } + + const liveIcon = editedIcon !== undefined ? editedIcon : resolvedIcon; + + const handleIconChange = useCallback( + (icon: string | null) => { + setEditedIcon(icon); + onIconChange?.(icon); + }, + [onIconChange] + ); + useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); @@ -980,7 +1060,11 @@ function InspectorPanel({
- + {liveIcon ? ( + + ) : ( + + )}
@@ -1039,11 +1123,13 @@ function InspectorPanel({ featureSets={featureSets} initial={entry?.binding ?? null} prefillRoot={entry && !isMapped ? entry.root : undefined} + initialUnmappedIcon={!isMapped ? resolvedIcon : null} existingBindings={existingBindings} onCancel={onClose} onSubmit={onSubmit} onError={onError} onSaveStatusChange={setSaveStatus} + onIconChange={handleIconChange} /> @@ -1453,7 +1539,7 @@ function ServerGroupRow({ ) : ( )} - +
{prefix && ( @@ -1645,17 +1731,20 @@ function BindingForm({ featureSets, initial, prefillRoot, + initialUnmappedIcon, existingBindings, onCancel, onSubmit, onError, onSaveStatusChange, + onIconChange, }: { mode: 'create' | 'edit' | 'create-from-live'; spaces: Space[]; featureSets: FeatureSet[]; initial?: WorkspaceBinding | null; prefillRoot?: string; + initialUnmappedIcon?: string | null; /** Every saved mapping, used to flag a folder that's already mapped. */ existingBindings: WorkspaceBinding[]; onCancel: () => void; @@ -1663,6 +1752,8 @@ function BindingForm({ onError: (message: string) => void; /** Surfaced upward so the section header can show a Saving / Saved pill. */ onSaveStatusChange?: (status: SaveStatus) => void; + /** Propagate icon edits to the inspector header and card list. */ + onIconChange?: (icon: string | null) => void; }) { const defaultSpaceId = useMemo( () => spaces.find((s) => s.is_default)?.id ?? spaces[0]?.id ?? '', @@ -1671,6 +1762,7 @@ function BindingForm({ const rootRef = useRef(null); const [root, setRoot] = useState(initial?.workspace_root ?? prefillRoot ?? ''); + const [icon, setIcon] = useState(initialUnmappedIcon ?? ''); const [spaceId, setSpaceId] = useState(initial?.space_id ?? defaultSpaceId); // Multi-FS: a binding may resolve to N FeatureSets (the resolver merges // their members into one allow set). Order is preserved so the operator @@ -1882,6 +1974,59 @@ function BindingForm({ ? 'Save mapping' : 'Create mapping'; + const lastSavedAppearanceRef = useRef( + mode === 'create-from-live' ? normalizeIcon(initialUnmappedIcon) : null + ); + + /** + * Persist icon immediately after upload so the card updates without waiting + * for the debounced appearance save. + */ + const persistIconNow = async (nextIcon: string) => { + const workspaceRoot = root.trim(); + if (!workspaceRoot || mode !== 'create-from-live') return; + const normalizedIcon = normalizeIcon(nextIcon); + + if (normalizedIcon) { + await upsertWorkspaceAppearance({ + workspace_root: workspaceRoot, + icon: normalizedIcon, + }); + lastSavedAppearanceRef.current = normalizedIcon; + } else { + await deleteWorkspaceAppearance(workspaceRoot); + lastSavedAppearanceRef.current = null; + } + }; + + useEffect(() => { + if (mode !== 'create-from-live') return; + const workspaceRoot = root.trim(); + if (!workspaceRoot) return; + const normalizedIcon = normalizeIcon(icon); + const baseline = lastSavedAppearanceRef.current; + if (normalizedIcon === baseline) return; + + const handle = setTimeout(() => { + void (async () => { + try { + if (normalizedIcon) { + await upsertWorkspaceAppearance({ + workspace_root: workspaceRoot, + icon: normalizedIcon, + }); + } else { + await deleteWorkspaceAppearance(workspaceRoot); + } + lastSavedAppearanceRef.current = normalizedIcon; + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + } + })(); + }, 600); + return () => clearTimeout(handle); + }, [mode, root, icon, onError]); + return (
{/* Plain-language primer for anyone who's never seen McpMux. Explains @@ -1895,6 +2040,83 @@ function BindingForm({ it exactly the tools you choose here, and nothing else.
+ +
+
+
+ {icon.trim() ? ( + + ) : ( + + )} +
+
+ { + const next = e.target.value; + setIcon(next); + onIconChange?.(normalizeIcon(next)); + }} + placeholder="Emoji, URL, or upload an image" + className="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="workspace-binding-icon-input" + /> +
+ + +
+
+
+
+
+
- | "enable" - | "disable" - | "connect" - | "cancel" - | "retry" - | "connected" - | "connecting"; + | 'enable' + | 'disable' + | 'connect' + | 'cancel' + | 'retry' + | 'connected' + | 'connecting'; /** Refresh statuses from backend */ refresh: () => Promise; } +/** + * Normalize backend status strings to the UI ConnectionStatus union. + */ +function normalizeConnectionStatus(status: string): ConnectionStatus { + if (status === 'auth_required') { + return 'oauth_required'; + } + return status as ConnectionStatus; +} + +/** + * Map a REST/Tauri status payload into ServerStatusResponse. + */ +function toServerStatusResponse( + serverId: string, + payload: Pick & { + status: string; + } +): ServerStatusResponse { + return { + server_id: serverId, + status: normalizeConnectionStatus(payload.status), + flow_id: payload.flow_id, + has_connected_before: payload.has_connected_before, + message: payload.message ?? null, + }; +} + +/** + * Event-driven hook for managing MCP server connection state. + * Uses the useDomainEvents facade so it works on both Tauri desktop and web admin. + */ export function useServerManager({ spaceId, onFeaturesChange, }: UseServerManagerOptions): UseServerManagerResult { - const [statuses, setStatuses] = useState< - Record - >({}); + const [statuses, setStatuses] = useState>({}); const [authProgress, setAuthProgress] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const prevSpaceId = useRef(null); - // Stable ref for onFeaturesChange to avoid re-subscribing on every render const onFeaturesChangeRef = useRef(onFeaturesChange); onFeaturesChangeRef.current = onFeaturesChange; - // Fetch initial statuses + const { subscribe } = useDomainEvents(); + const refresh = useCallback(async () => { if (!spaceId) return; @@ -92,9 +125,16 @@ export function useServerManager({ setLoading(true); setError(null); const result = await getServerStatuses(spaceId); - setStatuses(result); + const normalized = Object.fromEntries( + Object.entries(result).map(([serverId, status]) => [ + serverId, + toServerStatusResponse(serverId, status), + ]) + ); + setStatuses(normalized); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const message = e instanceof Error ? e.message : String(e); + setError(message); } finally { setLoading(false); } @@ -105,135 +145,98 @@ export function useServerManager({ setStatuses({}); setAuthProgress({}); setError(null); - setLoading(true); // Set loading true while fetching + setLoading(true); prevSpaceId.current = spaceId || null; - - // Immediately hydrate from backend on space switch + if (spaceId) { - refresh(); + void refresh(); } } }, [spaceId, refresh]); - // Initial fetch (only on mount, not on space change since handled above) useEffect(() => { if (!prevSpaceId.current) { - refresh(); + void refresh(); } }, [refresh]); - // Subscribe to events useEffect(() => { if (!spaceId) return; - const unsubscribers: Array<() => void> = []; - - // Event listeners are async (Tauri listen() returns a Promise). - // Events emitted between the initial getServerStatuses fetch and listener - // activation are lost. Track when all listeners are ready, then re-fetch - // statuses to catch any events missed during the gap. - const listenerPromises: Array void>> = []; + const unsubs = [ + subscribe('server-status-changed', (event: ServerStatusChangedPayload) => { + if (event.space_id !== spaceId) return; - // Status changes - const statusPromise = onServerStatus((event: ServerStatusEvent) => { - if (event.space_id !== spaceId) return; + setStatuses((prev) => { + const existing = prev[event.server_id]; + return { + ...prev, + [event.server_id]: toServerStatusResponse(event.server_id, { + status: event.status, + flow_id: existing?.flow_id ?? 0, + has_connected_before: + event.has_connected_before ?? + (existing?.has_connected_before ?? false), + message: event.message ?? null, + }), + }; + }); - setStatuses((prev) => { - const existing = prev[event.server_id]; - // Ignore events from older flows (race condition prevention) - if (existing && existing.flow_id > event.flow_id) { - return prev; + if (event.status !== 'authenticating') { + setAuthProgress((prev) => { + const next = { ...prev }; + delete next[event.server_id]; + return next; + }); } + }), + subscribe('server-auth-progress', (event: ServerAuthProgressPayload) => { + if (event.space_id !== spaceId) return; - return { + setAuthProgress((prev) => ({ ...prev, - [event.server_id]: { - server_id: event.server_id, - status: event.status, - flow_id: event.flow_id, - has_connected_before: event.has_connected_before, - message: event.message || null, - }, - }; - }); - - // Clear auth progress when leaving Authenticating state - if (event.status !== "authenticating") { - setAuthProgress((prev) => { - const next = { ...prev }; - delete next[event.server_id]; - return next; - }); - } - }); - statusPromise.then((unlisten) => unsubscribers.push(unlisten)); - listenerPromises.push(statusPromise); - - // Auth progress - const authPromise = onAuthProgress((event: AuthProgressEvent) => { - if (event.space_id !== spaceId) return; - - setAuthProgress((prev) => ({ - ...prev, - [event.server_id]: event.remaining_seconds, - })); - }); - authPromise.then((unlisten) => unsubscribers.push(unlisten)); - listenerPromises.push(authPromise); - - // Features updated (always subscribe, use ref to call latest callback) - const featuresPromise = onFeaturesUpdated( - (event: FeaturesUpdatedEvent) => { + [event.server_id]: event.remaining_seconds, + })); + }), + subscribe('server-features-refreshed', (event: ServerFeaturesRefreshedPayload) => { if (event.space_id !== spaceId) return; - onFeaturesChangeRef.current?.(event); - } - ); - featuresPromise.then((unlisten) => unsubscribers.push(unlisten)); - listenerPromises.push(featuresPromise); - // Once all listeners are active, re-fetch statuses to close the gap - // between the initial fetch and listener activation (startup race fix) - Promise.all(listenerPromises).then(() => { - refresh(); - }); + void (async () => { + const allFeatures = await listServerFeaturesByServer(event.space_id, event.server_id); + const features = { + tools: allFeatures.filter((f) => f.feature_type === 'tool'), + prompts: allFeatures.filter((f) => f.feature_type === 'prompt'), + resources: allFeatures.filter((f) => f.feature_type === 'resource'), + }; + onFeaturesChangeRef.current?.({ + type: 'features_updated', + space_id: event.space_id, + server_id: event.server_id, + features, + added: event.added, + removed: event.removed, + }); + })(); + }), + ]; + + void refresh(); return () => { - unsubscribers.forEach((fn) => fn()); + unsubs.forEach((unsub) => unsub()); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [spaceId]); - - // Actions - const enable = useCallback( - (serverId: string) => enableServer(spaceId, serverId), - [spaceId] - ); - - const disable = useCallback( - (serverId: string) => disableServer(spaceId, serverId), - [spaceId] - ); - - const connect = useCallback( - (serverId: string) => startAuth(spaceId, serverId), - [spaceId] - ); + }, [spaceId, subscribe, refresh]); - const cancel = useCallback( - (serverId: string) => cancelAuth(spaceId, serverId), - [spaceId] - ); - - const retry = useCallback( - (serverId: string) => retryConnection(spaceId, serverId), - [spaceId] - ); + const enable = useCallback((serverId: string) => enableServer(spaceId, serverId), [spaceId]); + const disable = useCallback((serverId: string) => disableServer(spaceId, serverId), [spaceId]); + const connect = useCallback((serverId: string) => startAuth(spaceId, serverId), [spaceId]); + const cancel = useCallback((serverId: string) => cancelAuth(spaceId, serverId), [spaceId]); + const retry = useCallback((serverId: string) => retryConnection(spaceId, serverId), [spaceId]); - // Helpers const getButtonLabel = useCallback( (serverId: string) => { const status = statuses[serverId]; - if (!status) return "Enable"; + if (!status) return 'Enable'; return getConnectButtonLabel(status.status, status.has_connected_before); }, [statuses] @@ -242,7 +245,7 @@ export function useServerManager({ const getAction = useCallback( (serverId: string) => { const status = statuses[serverId]; - if (!status) return "enable"; + if (!status) return 'enable' as const; return getServerAction(status.status); }, [statuses] diff --git a/apps/desktop/src/hooks/useWorkspaceEvents.ts b/apps/desktop/src/hooks/useWorkspaceEvents.ts new file mode 100644 index 00000000..4727c036 --- /dev/null +++ b/apps/desktop/src/hooks/useWorkspaceEvents.ts @@ -0,0 +1,10 @@ +/** @deprecated Prefer `@/lib/backend/events` */ +export { useWorkspaceEvents, useWorkspaceEventListener } from '@/lib/backend/events'; + +export type { + WorkspaceEventChannel, + WorkspaceBindingChangedPayload, + WorkspaceNeedsBindingPayload, +} from '@/lib/backend/events'; + +export { default } from '@/lib/backend/events/useWorkspaceEvents'; diff --git a/apps/desktop/src/lib/api/spaces.ts b/apps/desktop/src/lib/api/spaces.ts index ab0030f0..25925d17 100644 --- a/apps/desktop/src/lib/api/spaces.ts +++ b/apps/desktop/src/lib/api/spaces.ts @@ -85,3 +85,19 @@ export async function addSpaceBaseDir(spaceId: string, path: string): Promise { return invoke('remove_space_base_dir', { id }); } + +export interface UpdateSpaceInput { + name?: string; + icon?: string; + description?: string; +} + +/** Update a space's display metadata (name, icon, description). */ +export async function updateSpace(id: string, input: UpdateSpaceInput): Promise { + return invoke('update_space', { + id, + name: input.name, + icon: input.icon, + description: input.description, + }); +} diff --git a/apps/desktop/src/lib/navigation.ts b/apps/desktop/src/lib/navigation.ts index 82c44184..3f061d87 100644 --- a/apps/desktop/src/lib/navigation.ts +++ b/apps/desktop/src/lib/navigation.ts @@ -17,6 +17,7 @@ import type { LucideIcon } from 'lucide-react'; import { Home, + LayoutDashboard, Server, Sparkles, Compass, @@ -50,8 +51,15 @@ export const NAV_ZONES: NavZone[] = [ key: 'home', label: 'Home', icon: Home, + testId: 'nav-home', + hint: 'Gateway connection and Space overview', + }, + { + key: 'dashboard', + label: 'Dashboard', + icon: LayoutDashboard, testId: 'nav-dashboard', - hint: 'Your gateway, connections, and Space at a glance', + hint: 'Server health, recent activity, and stats at a glance', }, ], }, diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index 13e4e156..5250c0e6 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -10,6 +10,7 @@ const initialState: AppState = { pendingClientId: null, pendingSettingsSection: null, pendingWorkspaceNew: false, + pendingServersFilter: null, sidebarCollapsed: false, theme: 'system', analyticsEnabled: true, @@ -90,6 +91,11 @@ export const useAppStore = create()( state.pendingWorkspaceNew = v; }), + setPendingServersFilter: (filter) => + set((state) => { + state.pendingServersFilter = filter; + }), + // UI toggleSidebar: () => set((state) => { diff --git a/apps/desktop/src/stores/registryStore.ts b/apps/desktop/src/stores/registryStore.ts index 26e169a8..da7024b8 100644 --- a/apps/desktop/src/stores/registryStore.ts +++ b/apps/desktop/src/stores/registryStore.ts @@ -16,6 +16,7 @@ import type { SortOption, } from '../types/registry'; import * as api from '../lib/api/registry'; +import { resolveInstalledDisplayName } from '../features/servers/server-display-name.helpers'; // ============================================ // State & Actions Types @@ -369,6 +370,7 @@ function mergeServers(defs: ServerDefinition[], states: InstalledServerState[]): return { ...def, + name: state ? resolveInstalledDisplayName(state, def) : def.name, is_installed: !!state, enabled: state?.enabled ?? false, oauth_connected: state?.oauth_connected ?? false, diff --git a/apps/desktop/src/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 5c800120..5182efe2 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -15,6 +15,8 @@ export const useSetPendingSettingsSection = () => export const usePendingWorkspaceNew = () => useAppStore((state) => state.pendingWorkspaceNew); export const useSetPendingWorkspaceNew = () => useAppStore((state) => state.setPendingWorkspaceNew); +export const usePendingServersFilter = () => useAppStore((state) => state.pendingServersFilter); +export const useSetPendingServersFilter = () => useAppStore((state) => state.setPendingServersFilter); export const useTheme = () => useAppStore((state) => state.theme); export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCollapsed); export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled); diff --git a/apps/desktop/src/stores/types.ts b/apps/desktop/src/stores/types.ts index c9626eba..cd3ea3ff 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -2,6 +2,7 @@ import { Space } from '@/lib/api/spaces'; export type NavItem = | 'home' + | 'dashboard' | 'registry' | 'servers' | 'spaces' @@ -30,6 +31,8 @@ export interface AppState { pendingSettingsSection: string | null; /** When true, the Workspaces page opens the New-mapping walkthrough on arrival. */ pendingWorkspaceNew: boolean; + /** Status filter to pre-apply when navigating to My Servers */ + pendingServersFilter: string | null; // UI state sidebarCollapsed: boolean; @@ -56,6 +59,7 @@ export interface AppActions { setPendingClientId: (id: string | null) => void; setPendingSettingsSection: (section: string | null) => void; setPendingWorkspaceNew: (v: boolean) => void; + setPendingServersFilter: (filter: string | null) => void; // UI toggleSidebar: () => void; diff --git a/crates/mcpmux-core/src/service/space_service.rs b/crates/mcpmux-core/src/service/space_service.rs index 5110ec94..a9361ad6 100644 --- a/crates/mcpmux-core/src/service/space_service.rs +++ b/crates/mcpmux-core/src/service/space_service.rs @@ -96,6 +96,38 @@ impl SpaceService { self.repository.delete(id).await } + /// Update a space's display metadata (name, icon, description). + pub async fn update( + &self, + id: Uuid, + name: Option, + icon: Option, + description: Option, + ) -> anyhow::Result { + let mut space = self + .repository + .get(&id) + .await? + .ok_or_else(|| anyhow::anyhow!("Space not found: {}", id))?; + + if let Some(name) = name { + space.name = name; + } + if let Some(icon) = icon { + space.icon = Some(icon); + } + if let Some(description) = description { + space.description = Some(description); + } + space.updated_at = chrono::Utc::now(); + + self.repository.update(&space).await?; + + info!(space_id = %space.id, name = %space.name, "[SpaceService] Updated space"); + + Ok(space) + } + /// Get the system's default Space (the gateway's routing fallback when /// no `WorkspaceBinding` matches a session's reported workspace root). pub async fn get_default(&self) -> anyhow::Result> { diff --git a/tests/ts/components/SourceBadge.test.tsx b/tests/ts/components/SourceBadge.test.tsx index 661563ff..52977437 100644 --- a/tests/ts/components/SourceBadge.test.tsx +++ b/tests/ts/components/SourceBadge.test.tsx @@ -2,9 +2,11 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { SourceBadge, +} from '../../../apps/desktop/src/components/SourceBadge'; +import { getUninstallLabel, getUninstallConfirmMessage, -} from '../../../apps/desktop/src/components/SourceBadge'; +} from '../../../apps/desktop/src/components/source-badge.helpers'; import type { InstallationSource } from '../../../apps/desktop/src/types/registry'; describe('SourceBadge', () => { From 7d9c6e6e52024410312c34680f93bf29f94285c6 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 23 Jun 2026 21:40:21 -0600 Subject: [PATCH 008/148] =?UTF-8?q?feat(port):=20Phase=208=20=E2=80=94=20i?= =?UTF-8?q?18n=20rebase=20+=20landing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port react-i18next infrastructure and stringified UI from i18n branch onto the reconciled Phases 1–7 port branch. Reconcile nav IA, meta-tools, workspaces, registry analytics, and Phase 5–7 locale keys while keeping port functionality intact. Signed-off-by: crimsonsunset --- apps/desktop/package.json | 2 + apps/desktop/src/App.tsx | 117 +- .../src/components/ConfigEditorModal.tsx | 164 +- apps/desktop/src/components/ConnectIDEs.tsx | 119 +- .../desktop/src/components/ConnectionCard.tsx | 43 +- apps/desktop/src/components/Contribute.tsx | 106 +- .../src/components/OAuthConsentModal.tsx | 195 +- .../src/components/ServerDefinitionModal.tsx | 47 +- apps/desktop/src/components/ServerIcon.tsx | 13 +- .../src/components/ServerInstallModal.tsx | 129 +- .../src/components/ServerLogViewer.tsx | 146 +- apps/desktop/src/components/SourceBadge.tsx | 24 +- apps/desktop/src/components/SpaceSwitcher.tsx | 176 +- .../src/components/StaleBuildBanner.tsx | 10 +- .../src/components/source-badge.helpers.ts | 19 +- .../src/features/clients/ClientsPage.tsx | 282 +- .../src/features/dashboard/DashboardPage.tsx | 6 +- .../dashboard/DashboardQuickLinks.tsx | 50 +- .../dashboard/DashboardRecentActivity.tsx | 12 +- .../dashboard/DashboardServerHealth.tsx | 32 +- .../features/dashboard/DashboardStatCards.tsx | 57 +- .../features/dashboard/dashboard.helpers.ts | 23 +- .../features/featuresets/FeatureSetPanel.tsx | 332 ++- .../features/featuresets/FeatureSetsPage.tsx | 588 ++--- .../gateway/AutoStartConflictResolver.tsx | 25 +- .../features/gateway/useGatewayControl.tsx | 53 +- apps/desktop/src/features/home/HomePage.tsx | 6 +- .../metaTools/MetaToolApprovalDialog.tsx | 135 +- .../features/metaTools/MetaToolAuditLog.tsx | 47 +- .../metaTools/MetaToolGrantsPanel.tsx | 125 +- .../src/features/registry/RegistryPage.tsx | 133 +- .../src/features/registry/ServerCard.tsx | 98 +- .../features/registry/ServerDetailModal.tsx | 212 +- .../src/features/servers/AddServerMenu.tsx | 13 +- .../features/servers/CloneAccountModal.tsx | 48 +- .../src/features/servers/ServerActionMenu.tsx | 32 +- .../src/features/servers/ServerCard.tsx | 149 +- .../features/servers/ServerEnabledToggle.tsx | 15 +- .../features/servers/ServersCountSummary.tsx | 9 +- .../servers/ServersFiltersPopover.tsx | 84 +- .../src/features/servers/ServersPage.tsx | 2276 ++++++++++------- .../UninstallSourceWithClonesDialog.tsx | 23 +- apps/desktop/src/features/servers/index.ts | 7 - .../servers/server-pending-updates.helpers.ts | 9 +- .../servers/server-update-policy.helpers.ts | 80 +- .../features/servers/servers-page.helpers.ts | 55 +- .../src/features/settings/AboutSection.tsx | 14 +- .../src/features/settings/BuildStampPanel.tsx | 36 +- .../settings/ServerPendingUpdatesList.tsx | 36 +- .../settings/ServerUpdatesSection.tsx | 109 +- .../src/features/settings/SettingsPage.tsx | 1367 +++++----- .../src/features/settings/UpdateChecker.tsx | 230 +- apps/desktop/src/features/settings/index.ts | 5 - .../features/spaces/SpaceBaseDirsModal.tsx | 4 +- .../src/features/spaces/SpacePanel.tsx | 36 +- .../src/features/spaces/SpacesPage.tsx | 331 ++- apps/desktop/src/features/spaces/index.ts | 3 - .../workspaces/WorkspaceBindingSheet.tsx | 152 +- .../features/workspaces/WorkspacesPage.tsx | 991 +++---- apps/desktop/src/i18n.ts | 60 + apps/desktop/src/i18n.types.ts | 33 + apps/desktop/src/lib/api/featureSets.ts | 2 + apps/desktop/src/lib/api/metaTools.ts | 17 +- apps/desktop/src/lib/api/registry.ts | 6 + apps/desktop/src/lib/api/workspaceBindings.ts | 13 + apps/desktop/src/lib/navigation.ts | 69 +- apps/desktop/src/locales/en/clients.json | 122 + apps/desktop/src/locales/en/common.json | 89 + apps/desktop/src/locales/en/dashboard.json | 121 + apps/desktop/src/locales/en/featuresets.json | 87 + apps/desktop/src/locales/en/home.json | 6 + apps/desktop/src/locales/en/metatools.json | 45 + apps/desktop/src/locales/en/nav.json | 28 + apps/desktop/src/locales/en/registry.json | 121 + apps/desktop/src/locales/en/servers.json | 268 ++ apps/desktop/src/locales/en/settings.json | 268 ++ apps/desktop/src/locales/en/spaces.json | 60 + apps/desktop/src/locales/en/workspaces.json | 173 ++ apps/desktop/src/main.tsx | 36 +- apps/desktop/src/stores/registryStore.ts | 15 - apps/desktop/src/types/registry.ts | 8 + package.json | 1 + pnpm-lock.yaml | 74 +- scripts/lint-i18n.mjs | 30 + tests/ts/admin-transport.test.ts | 274 ++ tests/ts/components/App.test.tsx | 130 +- tests/ts/components/ConfirmDialog.test.tsx | 3 +- tests/ts/components/ConnectIDEs.test.tsx | 29 +- tests/ts/components/Contribute.test.tsx | 48 + .../MetaToolApprovalDialog.test.tsx | 62 +- .../RegistryPageSearchAnalytics.test.tsx | 3 +- tests/ts/components/ServerCard.test.tsx | 25 +- .../ts/components/ServerDetailModal.test.tsx | 23 +- tests/ts/components/SourceBadge.test.tsx | 34 +- .../WorkspaceBindingPrompt.test.tsx | 51 +- .../WorkspacesClearUnmapped.test.tsx | 12 +- .../WorkspacesMappedFilter.test.tsx | 14 +- tests/ts/render-with-i18n.helpers.tsx | 28 + tests/ts/setup.ts | 15 + tests/ts/vitest.config.ts | 2 + 100 files changed, 7580 insertions(+), 4565 deletions(-) create mode 100644 apps/desktop/src/i18n.ts create mode 100644 apps/desktop/src/i18n.types.ts create mode 100644 apps/desktop/src/locales/en/clients.json create mode 100644 apps/desktop/src/locales/en/common.json create mode 100644 apps/desktop/src/locales/en/dashboard.json create mode 100644 apps/desktop/src/locales/en/featuresets.json create mode 100644 apps/desktop/src/locales/en/home.json create mode 100644 apps/desktop/src/locales/en/metatools.json create mode 100644 apps/desktop/src/locales/en/nav.json create mode 100644 apps/desktop/src/locales/en/registry.json create mode 100644 apps/desktop/src/locales/en/servers.json create mode 100644 apps/desktop/src/locales/en/settings.json create mode 100644 apps/desktop/src/locales/en/spaces.json create mode 100644 apps/desktop/src/locales/en/workspaces.json create mode 100644 scripts/lint-i18n.mjs create mode 100644 tests/ts/admin-transport.test.ts create mode 100644 tests/ts/components/Contribute.test.tsx create mode 100644 tests/ts/render-with-i18n.helpers.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 87bb59fd..a098add2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,11 +24,13 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-updater": "^2", + "i18next": "^26.3.1", "immer": "^11.0.1", "lucide-react": "^0.561.0", "posthog-js": "^1.387.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.8", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 30745308..8ae4ac7e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,11 +1,14 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; import { Sun, Moon, Download, X } from 'lucide-react'; import { AppShell, Sidebar, SidebarItem, SidebarSection } from '@mcpmux/ui'; import { ThemeProvider } from '@/components/ThemeProvider'; +import { isTauri, performWindowControl } from '@/lib/backend/shell'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; import { ServerInstallModal } from '@/components/ServerInstallModal'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; +import { StaleBuildBanner } from '@/components/StaleBuildBanner'; import { useDataSync } from '@/hooks/useDataSync'; import { useAnalytics } from '@/hooks/useAnalytics'; import { startMetaToolActivityListener } from '@/stores/metaToolActivityStore'; @@ -18,6 +21,7 @@ import { useActiveNav, useNavigateTo, useSetPendingSettingsSection, + useIsLoading, } from '@/stores'; import { NAV_ZONES, NAV_SETTINGS } from '@/lib/navigation'; import { spaceAccentColor } from '@/lib/spaceAccent'; @@ -35,6 +39,8 @@ import { AutoStartConflictResolver } from '@/features/gateway/AutoStartConflictR import { WorkspaceBindingSheet } from '@/features/workspaces'; import { MetaToolApprovalDialog } from '@/features/metaTools'; import { useGatewayEvents } from '@/hooks/useDomainEvents'; +import { getVersion } from '@/lib/api/app'; +import { checkForUpdate } from '@/lib/updates'; /** McpMux title-bar icon — miniature cat icon */ function McpMuxGlyph({ className }: { className?: string }) { @@ -53,14 +59,12 @@ function McpMuxGlyph({ className }: { className?: string }) { - {/* Cat silhouette with transparent eyes/nose */} - {/* Smile */} - {/* Whiskers left */} - {/* Whiskers right */} (null); - // Auto-check for updates on startup (silent check after 5 seconds). - // When auto-install is enabled (the default), download + install + relaunch - // 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; + if (import.meta.env.DEV && !import.meta.env.VITEST) return; const checkForUpdates = async () => { try { - const { checkForUpdate } = await import('@/lib/updates'); const update = await checkForUpdate(); if (!update) return; console.log(`[Auto-Update] Update available: ${update.version}`); - // Default to auto-install; honor the persisted opt-out. let autoInstall = true; try { autoInstall = await invoke('get_auto_install_updates'); @@ -145,21 +140,22 @@ function AppContent() { return () => clearTimeout(timer); }, []); - // Get state from store const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); const viewSpace = useViewSpace(); + const isLoadingSpaces = useIsLoading('spaces'); const analyticsEnabled = useAnalyticsEnabled(); - // App version from Rust backend const [appVersion, setAppVersion] = useState(''); useEffect(() => { - invoke('get_version') + if (isLoadingSpaces) { + return; + } + getVersion() .then(setAppVersion) .catch((err) => console.error('Failed to get version:', err)); - }, []); + }, [isLoadingSpaces]); - // Initialize analytics once we have the app version useEffect(() => { if (!appVersion) return; initAnalytics(appVersion); @@ -171,14 +167,10 @@ function AppContent() { } }, [appVersion]); // eslint-disable-line react-hooks/exhaustive-deps - // Start the app-wide meta-tool activity listener once at launch so the - // "Recent meta-tool activity" panel accumulates rows for the whole session - // and survives tab changes (the listener is idempotent and app-scoped). useEffect(() => { startMetaToolActivityListener(); }, []); - // Sync opt-in/out when user toggles analytics useEffect(() => { if (!appVersion) return; if (analyticsEnabled) { @@ -188,15 +180,12 @@ function AppContent() { } }, [analyticsEnabled, appVersion]); - // Track domain events (server install/uninstall) useAnalytics(); - // Track page navigation useEffect(() => { capture('page_viewed', { page: activeNav }); }, [activeNav]); - // Gateway status for sidebar footer const [gatewayUrl, setGatewayUrl] = useState(null); const loadGatewayUrl = useCallback(async () => { try { @@ -209,8 +198,11 @@ function AppContent() { }, [viewSpace?.id]); useEffect(() => { - loadGatewayUrl(); - }, [loadGatewayUrl]); + if (isLoadingSpaces) { + return; + } + void loadGatewayUrl(); + }, [loadGatewayUrl, isLoadingSpaces]); useGatewayEvents((payload) => { if (payload.action === 'started') { @@ -220,7 +212,6 @@ function AppContent() { } }); - // Toggle dark mode const toggleDarkMode = () => { setTheme(theme === 'dark' ? 'light' : 'dark'); }; @@ -235,16 +226,14 @@ function AppContent() { } })(); - // Sidebar renders entirely from the navigation model (lib/navigation.ts) — - // future surfaces (Chat, Agents, Models) are config additions, not layout work. const sidebar = ( } footer={ } - label={NAV_SETTINGS.label} - hint={NAV_SETTINGS.hint} + label={t(NAV_SETTINGS.labelKey)} + hint={t(NAV_SETTINGS.hintKey)} active={activeNav === NAV_SETTINGS.key} onClick={() => navigateTo(NAV_SETTINGS.key)} data-testid={NAV_SETTINGS.testId} @@ -252,13 +241,13 @@ function AppContent() { } > {NAV_ZONES.map((zone, i) => ( - + {zone.entries.map((entry) => ( } - label={entry.label} - hint={entry.hint} + label={t(entry.labelKey)} + hint={t(entry.hintKey)} active={activeNav === entry.key} onClick={() => navigateTo(entry.key)} data-testid={entry.testId} @@ -277,14 +266,18 @@ function AppContent() { onClick={() => navigateTo('home')} className="flex items-center gap-1.5 transition-colors hover:text-[rgb(var(--foreground))]" data-testid="statusbar-gateway" - title="View connection details on the dashboard" + title={tDashboard('statusbar.gatewayTitle')} > - {gatewayRunning ? `Gateway${gatewayPort ? ` · :${gatewayPort}` : ''}` : 'Gateway stopped'} + {gatewayRunning + ? `${tDashboard('statusbar.gatewayRunning')}${ + gatewayPort ? tDashboard('statusbar.gatewayPortSuffix', { port: gatewayPort }) : '' + }` + : tDashboard('statusbar.gatewayStopped')} - Space: {viewSpace?.name || 'None'} + {tDashboard('statusbar.spaceLabel', { + name: viewSpace?.name || tCommon('none'), + })}
{appVersion && ( @@ -312,9 +307,10 @@ function AppContent() {
)} + {activeNav === 'home' && } {activeNav === 'dashboard' && } {activeNav === 'registry' && } @@ -390,16 +391,10 @@ function App() { return ( - {/* Resolves deferred auto-start port conflicts — runs once on mount */} - {/* OAuth consent modal - shown when MCP clients request authorization */} - {/* Workspace binding sheet - slides in when a session reports a root - that has no binding yet and resolved via the Space default */} - {/* Server install modal - shown when install deep link is received */} - {/* Meta-tool approval dialog — gates every mcpmux_* write tool */} ); @@ -407,12 +402,8 @@ function App() { /** Window control button for custom title bar */ function WindowButton({ action }: { action: 'minimize' | 'maximize' | 'close' }) { - const handleClick = async () => { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const appWindow = getCurrentWindow(); - if (action === 'minimize') appWindow.minimize(); - else if (action === 'maximize') appWindow.toggleMaximize(); - else appWindow.close(); + const handleClick = () => { + void performWindowControl(action); }; return ( diff --git a/apps/desktop/src/components/ConfigEditorModal.tsx b/apps/desktop/src/components/ConfigEditorModal.tsx index 6f62c4d1..1f6746bb 100644 --- a/apps/desktop/src/components/ConfigEditorModal.tsx +++ b/apps/desktop/src/components/ConfigEditorModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { X, Save, Loader2, AlertTriangle, Wand2, Plus } from 'lucide-react'; import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces'; import { refreshRegistry } from '@/lib/api/registry'; @@ -8,6 +9,8 @@ import { useToast, ToastContainer } from '@mcpmux/ui'; import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json'; import { RequestServerCTA } from './Contribute'; +const EDITOR_MOUNT_TIMEOUT_MS = 10_000; + interface ConfigEditorModalProps { spaceId: string; spaceName: string; @@ -61,6 +64,7 @@ export function ConfigEditorModal({ onClose, onSaved, }: ConfigEditorModalProps) { + const { t } = useTranslation('servers'); const [content, setContent] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -68,6 +72,8 @@ export function ConfigEditorModal({ const [isValidJson, setIsValidJson] = useState(true); const [validationErrors, setValidationErrors] = useState([]); const [editorReady, setEditorReady] = useState(false); + const [editorMounted, setEditorMounted] = useState(false); + const [editorLoadFailed, setEditorLoadFailed] = useState(false); const editorRef = useRef(null); const monacoRef = useRef(null); const { toasts, success, error: showError } = useToast(); @@ -78,11 +84,10 @@ export function ConfigEditorModal({ return () => clearTimeout(timer); }, []); - useEffect(() => { - loadConfig(); - }, [spaceId, insertNewServer]); - - const loadConfig = async () => { + /** + * Load the space JSON config from disk. + */ + const loadConfig = useCallback(async () => { try { setIsLoading(true); setError(null); @@ -101,17 +106,37 @@ export function ConfigEditorModal({ } finally { setIsLoading(false); } - }; + }, [spaceId, insertNewServer]); + + useEffect(() => { + void loadConfig(); + setEditorMounted(false); + setEditorLoadFailed(false); + }, [loadConfig]); - const handleSave = async () => { + useEffect(() => { + if (isLoading || !editorReady || editorMounted || editorLoadFailed) { + return; + } + + const timer = setTimeout(() => { + setEditorLoadFailed(true); + setError(t('configEditorModal.editorLoadFailed')); + }, EDITOR_MOUNT_TIMEOUT_MS); + + return () => clearTimeout(timer); + }, [isLoading, editorReady, editorMounted, editorLoadFailed, t]); + + const handleSave = useCallback(async () => { try { // Validate JSON try { JSON.parse(content); } catch (e) { setIsValidJson(false); - setError(`Invalid JSON: ${(e as Error).message}`); - showError('Invalid JSON', (e as Error).message); + const message = (e as Error).message; + setError(t('configEditorModal.validation.invalidJson', { message })); + showError(t('configEditorModal.toast.invalidJsonTitle'), message); return; } @@ -121,24 +146,37 @@ export function ConfigEditorModal({ // Refresh server discovery to pick up new/changed servers await refreshRegistry(); - success('Configuration saved', 'Space configuration updated successfully'); + success(t('configEditorModal.toast.saved'), t('configEditorModal.toast.savedBody')); onSaved(); onClose(); } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); setError(errorMsg); - showError('Failed to save configuration', errorMsg); + showError(t('configEditorModal.toast.saveFailed'), errorMsg); } finally { setIsSaving(false); } - }; + }, [content, onClose, onSaved, showError, spaceId, success, t]); + /** + * Format JSON via Monaco or plain parse/stringify when the editor failed to load. + */ const handleFormat = useCallback(() => { if (editorRef.current) { - // Use Monaco's built-in formatter editorRef.current.getAction('editor.action.formatDocument')?.run(); + return; } - }, []); + + try { + const parsed = JSON.parse(content); + setContent(JSON.stringify(parsed, null, 2)); + setIsValidJson(true); + setValidationErrors([]); + } catch (e) { + setIsValidJson(false); + setError(t('configEditorModal.validation.cannotFormat', { message: (e as Error).message })); + } + }, [content, t]); const handleInsertCustomServer = useCallback(() => { try { @@ -149,10 +187,10 @@ export function ConfigEditorModal({ } catch (e) { const message = e instanceof Error ? e.message : String(e); setIsValidJson(false); - setError('Invalid JSON: ' + message); - showError('Invalid JSON', message); + setError(t('configEditorModal.validation.invalidJson', { message })); + showError(t('configEditorModal.toast.invalidJsonTitle'), message); } - }, [content, showError]); + }, [content, showError, t]); // Configure Monaco before mount to set up JSON schema validation const handleEditorBeforeMount = (monaco: Monaco) => { @@ -172,28 +210,52 @@ export function ConfigEditorModal({ }); }; - const handleEditorMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { - editorRef.current = editor; + /** + * Mount handler — marks Monaco ready and focuses the editor. + */ + const handleEditorMount = (mountedEditor: editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorRef.current = mountedEditor; monacoRef.current = monaco; - - // Focus editor on mount - editor.focus(); + setEditorMounted(true); + mountedEditor.focus(); }; const handleEditorValidation = (markers: editor.IMarker[]) => { - const errors = markers.map((m) => `Line ${m.startLineNumber}: ${m.message}`); + const errors = markers.map((m) => + t('configEditorModal.validation.line', { line: m.startLineNumber, message: m.message }), + ); setValidationErrors(errors); setIsValidJson(markers.length === 0); }; + /** + * Sync editor content and clear stale parse errors on edit. + */ const handleContentChange = (newValue: string | undefined) => { - if (newValue !== undefined) { - setContent(newValue); - // Clear any manual errors when content changes - if (error && (error.startsWith('Invalid JSON') || error.startsWith('Cannot format'))) { - setError(null); + if (newValue === undefined) { + return; + } + + setContent(newValue); + + if (editorLoadFailed) { + try { + JSON.parse(newValue); + setIsValidJson(true); + setValidationErrors([]); + } catch (e) { + setIsValidJson(false); + setValidationErrors([(e as Error).message]); } } + + if ( + error && + (error.startsWith(t('configEditorModal.validation.invalidJsonPrefix')) || + error.startsWith(t('configEditorModal.validation.cannotFormatPrefix'))) + ) { + setError(null); + } }; // Keyboard shortcuts @@ -216,16 +278,22 @@ export function ConfigEditorModal({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleFormat, onClose]); + }, [handleFormat, handleSave, onClose]); return ( <> toasts.find((t) => t.id === id)?.onClose(id)} + onClose={(id) => toasts.find((toast) => toast.id === id)?.onClose(id)} /> -
-
+
+
{/* Header */}
@@ -233,8 +301,10 @@ export function ConfigEditorModal({
-

Custom Server Configuration

-

{spaceName} · JSON config

+

{t('configEditorModal.title')}

+

+ {t('configEditorModal.subtitle', { spaceName })} +

@@ -266,20 +336,20 @@ export function ConfigEditorModal({ onClick={handleFormat} disabled={isLoading || !isValidJson} className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] disabled:opacity-50" - title="Format JSON (Ctrl+Shift+F)" + title={t('configEditorModal.formatTitle')} > - Format + {t('configEditorModal.format')}
@@ -287,12 +357,14 @@ export function ConfigEditorModal({ {!isValidJson && ( - {validationErrors.length > 0 ? 'Schema Error' : 'Invalid JSON'} + {validationErrors.length > 0 + ? t('configEditorModal.schemaError') + : t('configEditorModal.invalidJson')} )} - Ctrl+S save · Ctrl+Shift+F format + {t('configEditorModal.keyboardHints')}
@@ -308,6 +380,13 @@ export function ConfigEditorModal({
+ ) : editorLoadFailed ? ( +