diff --git a/.changeset/remove-vendored-inspector.md b/.changeset/remove-vendored-inspector.md new file mode 100644 index 000000000..8668e8466 --- /dev/null +++ b/.changeset/remove-vendored-inspector.md @@ -0,0 +1,13 @@ +--- +'agent-bundle': minor +--- + +Remove the vendored MCP Inspector from the Workbench. The MCP page now has a +single playground presentation; the only surviving derived code is the +first-party MCP App renderer (`src/mcp/app-renderer.tsx`, MIT-attributed to +the Inspector's AppRenderer). Protocol inspection moves to the standalone +Inspector app: the dev server gains opt-in `/api/inspector/status` and +`/api/inspector/launch` routes that spawn `@modelcontextprotocol/inspector` +via npx on demand and return its tokenized URL. Drops the sync-inspector +machinery and the Mantine/react-icons/syntax-highlighter dependency surface +(~737 kB less workbench JS). diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index 09e7766e0..07ce48155 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -65,7 +65,6 @@ packages/ tests/mcp-app-sandbox.test.ts tests/mcp-session-routes.test.ts tests/mcp-session-service.test.ts - tests/native-host-smoke-workflow.test.ts tests/normalization.test.ts tests/playground-service.test.ts tests/portable-adapter.test.ts @@ -81,12 +80,6 @@ packages/ workbench/ rsbuild.config.ts scripts/capture-runtime-playground.mjs - src/inspector/adapter/inspector-session-adapter-entry.ts - src/inspector/adapter/inspector-session-adapter-model.ts - src/inspector/adapter/inspector-session-adapter.css - src/inspector/adapter/inspector-session-adapter.tsx - src/inspector/adapter/protocol-screen-without-replay.tsx - src/inspector/adapter/runtime-app-bridge.ts src/main.tsx src/mcp/mcp-app-client.ts src/mcp/mcp-app-frame.tsx @@ -94,6 +87,7 @@ packages/ src/mcp/mcp-page.tsx src/mcp/mcp-session-controller.ts src/mcp/mcp-session-model.ts + src/mcp/runtime-app-bridge.ts src/mcp/runtime-consent-dialog.tsx src/mcp/runtime-consent-queue.ts src/mcp/runtime-mcp-handoff.ts @@ -105,10 +99,6 @@ packages/ src/runtime-stage.tsx src/styles.css tests/helpers/runtime-playground-fixture.ts - tests/inspector-modern-mcp-types.test.ts - tests/inspector-session-adapter-fixture.test.ts - tests/inspector-session-adapter.test.ts - tests/inspector-shell.e2e.test.ts tests/mcp-app-client.test.ts tests/mcp-app-frame.test.ts tests/mcp-app-preview-browser.test.ts @@ -215,7 +205,9 @@ and HMR lane. `AgentBundleDevRuntimeConfig.provider` is loaded by The Workbench has one `Workbench` root and navigation authority. Runtime is the optional fourth top-level `WorkbenchPage` (`overview`, `skills`, `mcp`, -`runtime`); Inspector is a nested MCP presentation, not a fifth shell sibling. +`runtime`); the MCP page renders a single playground presentation, and protocol +inspection is delegated to the standalone MCP Inspector app that the dev server +spawns on demand via the opt-in `/api/inspector/*` routes. The root owns one `ProjectClient` and EventSource, one `McpAppClient`, and one shared `McpSessionController`. It creates one Runtime controller only when the project status advertises the configured runtime capability. `WorkbenchScreen` @@ -268,5 +260,5 @@ flowchart LR `npm run docs:runtime-topology` regenerates only the marked file tree from a fixed Git allowlist. `npm run check:runtime-topology` compares bytes without writing. The generator intentionally excludes generated output, dependencies, -runtime state, unrelated historical tests, and the vendored Inspector source so -the map remains an implementation boundary rather than a repository inventory. +runtime state, and unrelated historical tests so the map remains an +implementation boundary rather than a repository inventory. diff --git a/package.json b/package.json index d3db2a7e5..fb5b80d3d 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,7 @@ "example:audiobook": "pnpm build && pnpm --filter @agent-bundle-example/audiobook-curator dev", "example:mcp-app": "pnpm build && pnpm --filter @agent-bundle-example/mcp-app dev", "example:skills": "pnpm build && pnpm --filter @agent-bundle-example/skills-starter dev", - "examples:check": "pnpm build && pnpm --filter './examples/*' --workspace-concurrency=1 check", - "sync:inspector": "node scripts/sync-inspector.mjs --commit 672f9f41c548487a468b9e7007d2f9de14da5a69 --version 2.2.0 --mcp-sdk-version 2.0.0 --entry clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx --entry clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx --entry clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx --entry clients/web/src/components/screens/AppsScreen/AppsScreen.tsx --entry clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx --entry clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx --entry clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx --test clients/web/src/utils/inspectorTabs.test.ts --dependency @dnd-kit/core --dependency @dnd-kit/sortable --dependency @dnd-kit/utilities --dependency @emotion/react --dependency @mantine/core --dependency @mantine/form --dependency @mantine/hooks --dependency @mantine/notifications --dependency @modelcontextprotocol/client --dependency @modelcontextprotocol/core --dependency @modelcontextprotocol/ext-apps --dependency ajv --dependency papaparse --dependency pino --dependency react --dependency react-dom --dependency react-icons --dependency react-markdown --dependency react-syntax-highlighter --dependency remark-gfm --dependency zod --test-dependency @rstest/core --test-dependency vitest --public-import @modelcontextprotocol/client/validators/ajv --public-import @modelcontextprotocol/ext-apps/app-bridge --public-import react-icons/md --public-import react-icons/ri --public-import react-icons/tb --public-import react-icons/ti --public-import react-syntax-highlighter/dist/esm/languages/prism/bash --public-import react-syntax-highlighter/dist/esm/languages/prism/css --public-import react-syntax-highlighter/dist/esm/languages/prism/javascript --public-import react-syntax-highlighter/dist/esm/languages/prism/json --public-import react-syntax-highlighter/dist/esm/languages/prism/markdown --public-import react-syntax-highlighter/dist/esm/languages/prism/markup --public-import react-syntax-highlighter/dist/esm/languages/prism/python --public-import react-syntax-highlighter/dist/esm/languages/prism/typescript --public-import react-syntax-highlighter/dist/esm/languages/prism/yaml --public-import react-syntax-highlighter/dist/esm/prism-light --public-import react-syntax-highlighter/dist/esm/styles/prism --public-import react-syntax-highlighter/dist/esm/styles/prism/tomorrow" + "examples:check": "pnpm build && pnpm --filter './examples/*' --workspace-concurrency=1 check" }, "devDependencies": { "@changesets/cli": "2.29.7", diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 9cbf116ec..ed365acc6 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -11,6 +11,7 @@ import { DevLogRoutes } from './logs/dev-log-routes.ts'; import type { DevLogService } from './logs/dev-log-service.ts'; import { EvalRoutes, type EvalRouteService } from './eval/eval-routes.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; +import { InspectorRoutes, type InspectorRouteService } from './inspector-routes.ts'; import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playground/hook-playground-routes.ts'; import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; import { McpSessionRoutes } from './mcp-session/mcp-session-routes.ts'; @@ -126,6 +127,8 @@ export interface ForegroundServerOptions { readonly mcpAppPreviews?: McpAppRoutePreviewService; /** Epoch-bound hook playground service; the browser never selects a wrapper or artifact path. */ readonly hookPlayground?: HookPlaygroundRouteService; + /** Opt-in standalone MCP Inspector child; never auto-started. */ + readonly inspector?: InspectorRouteService; /** Persistent MCP sessions are supplied by the workbench service, never by browser input. */ readonly mcpSessions?: McpSessionService; readonly now?: () => Date; @@ -421,6 +424,7 @@ export class ForegroundServer { readonly #eventHub: ProjectEventHub; readonly #hookPlaygroundRoutes: HookPlaygroundRoutes; readonly #host: string; + readonly #inspectorRoutes: InspectorRoutes; readonly #mcpAppPreviews: McpAppRoutePreviewService | undefined; readonly #mcpAppRoutes: McpAppRoutes; readonly #runtimeMcpRoutes: RuntimeMcpRoutes; @@ -496,6 +500,10 @@ export class ForegroundServer { authorize: (request) => this.#assertMutationSession(request), ...(options.hookPlayground === undefined ? {} : { service: options.hookPlayground }), }); + this.#inspectorRoutes = new InspectorRoutes({ + authorize: (request) => this.#assertMutationSession(request), + ...(options.inspector === undefined ? {} : { service: options.inspector }), + }); this.#playgroundRoutes = new PlaygroundRoutes({ authorize: (request) => this.#assertMutationSession(request), ...(options.playground === undefined ? {} : { service: options.playground }), @@ -645,6 +653,7 @@ export class ForegroundServer { // records and reports the same rejection. void releaseHookPlayground.catch(() => undefined); this.#playgroundRoutes.close(); + this.#inspectorRoutes.close(); this.#artifactRoutes.close(); const releaseEvals = this.#evalRoutes.close(); void releaseEvals.catch(() => undefined); @@ -729,6 +738,7 @@ export class ForegroundServer { if (await this.#runtimeRoutes.handle(request, response)) return; if (await this.#hookPlaygroundRoutes.handle(request, response)) return; if (await this.#playgroundRoutes.handle(request, response)) return; + if (await this.#inspectorRoutes.handle(request, response)) return; if (await this.#artifactRoutes.handle(request, response)) return; if (await this.#evalRoutes.handle(request, response)) return; if (await this.#devLogRoutes.handle(request, response)) return; diff --git a/packages/agent-bundle/src/dev/inspector-launcher.ts b/packages/agent-bundle/src/dev/inspector-launcher.ts new file mode 100644 index 000000000..c6e81ad65 --- /dev/null +++ b/packages/agent-bundle/src/dev/inspector-launcher.ts @@ -0,0 +1,308 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { CodedError } from '../core/errors.ts'; +import { taskkill, terminateProcessTree } from '../services/process-tree.ts'; + +const inspectorPackage = '@modelcontextprotocol/inspector'; +const startupBudgetMs = 30_000; +const terminateGraceMs = 2_000; +const startupTimeoutKey = Symbol.for('agent-bundle.inspector-launcher.startup-timeout-ms'); +const terminateGraceKey = Symbol.for('agent-bundle.inspector-launcher.terminate-grace-ms'); +const httpUrl = /https?:\/\/[^\s"'<>\\]+/gi; +const trailingPunctuation = /[),.;:\]}>]+$/u; +const urlDelimiter = /[\s"'<>\\]/u; + +export type InspectorLauncherErrorCode = + | 'INSPECTOR_EXITED' + | 'INSPECTOR_LAUNCH_FAILED' + | 'INSPECTOR_STARTUP_TIMEOUT'; + +export type InspectorLauncherState = 'exited' | 'idle' | 'running' | 'starting'; + +export interface InspectorLauncherStatus { + readonly state: InspectorLauncherState; + readonly url?: string; +} + +export interface InspectorSpawnOptions { + readonly cwd: string; + readonly detached?: boolean; + readonly env: NodeJS.ProcessEnv; + readonly shell: false; + readonly stdio: readonly ['pipe', 'pipe', 'pipe']; + readonly windowsHide?: boolean; +} + +export type InspectorSpawn = ( + command: string, + args: readonly string[], + options: InspectorSpawnOptions, +) => ChildProcess; + +export interface InspectorLauncherOptions { + readonly env?: NodeJS.ProcessEnv; + readonly projectRoot: string; + readonly spawn?: InspectorSpawn; +} + +export interface InspectorLauncher { + close(): Promise; + launch(): Promise<{ readonly url: string }>; + status(): InspectorLauncherStatus; +} + +/** Coded refusals a caller can act on without reading inspector internals. */ +export class InspectorLauncherError extends CodedError { + constructor(code: InspectorLauncherErrorCode, message: string) { + super('InspectorLauncherError', code, message); + } +} + +const inspectorLauncherError = (code: InspectorLauncherErrorCode, message: string): InspectorLauncherError => + new InspectorLauncherError(code, message); + +const positiveMs = (value: unknown, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback; + +const startupTimeout = (options: InspectorLauncherOptions): number => + positiveMs( + (options as InspectorLauncherOptions & Record)[startupTimeoutKey], + startupBudgetMs * (process.env.CI ? 4 : 1), + ); + +const terminateGrace = (options: InspectorLauncherOptions): number => + positiveMs( + (options as InspectorLauncherOptions & Record)[terminateGraceKey], + terminateGraceMs, + ); + +const defaultSpawn: InspectorSpawn = (command, args, options) => spawn(command, [...args], { + cwd: options.cwd, + ...(options.detached === undefined ? {} : { detached: options.detached }), + env: options.env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + ...(options.windowsHide === undefined ? {} : { windowsHide: options.windowsHide }), +}); + +const stripAnsi = (value: string): string => { + let result = ''; + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) !== 0x1b || value[index + 1] !== '[') { + result += value[index]; + continue; + } + index += 2; + while (index < value.length && !/[A-Za-z]/u.test(value[index]!)) index += 1; + } + return result; +}; + +const inspectableUrl = (raw: string): URL | undefined => { + try { + return new URL(raw.replace(trailingPunctuation, '')); + } catch { + return undefined; + } +}; + +const hasTokenQuery = (url: URL): boolean => + [...url.searchParams.keys()].some((key) => key.toLowerCase().includes('token')); + +const isLocalhost = (url: URL): boolean => { + const host = url.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +}; + +/** First stdout http(s) URL with a token query param, else the first delimited localhost URL. */ +export const parseInspectorStdoutUrl = (stdout: string): string | undefined => { + const text = stripAnsi(stdout); + const found: { readonly delimited: boolean; readonly url: URL }[] = []; + for (const match of text.matchAll(httpUrl)) { + const url = inspectableUrl(match[0]!); + if (url === undefined || match.index === undefined) continue; + const next = text[match.index + match[0].length]; + found.push(Object.freeze({ + delimited: next !== undefined && urlDelimiter.test(next), + url, + })); + } + return (found.find((entry) => hasTokenQuery(entry.url)) ?? found.find((entry) => entry.delimited && isLocalhost(entry.url)))?.url.href; +}; + +const alreadyClosed = (child: ChildProcess): boolean => + typeof child.exitCode === 'number' || typeof child.signalCode === 'string'; + +const waitForClose = (child: ChildProcess): Promise => new Promise((resolvePromise) => { + if (alreadyClosed(child)) { + resolvePromise(); + return; + } + child.once('close', () => resolvePromise()); +}); + +const delay = (ms: number): Promise => new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); +}); + +const terminateTree = (child: ChildProcess, signal: NodeJS.Signals): Promise => + terminateProcessTree(child, signal, { + onTreeTerminationFailure: () => undefined, + platform: process.platform, + taskkill, + }); + +const terminateChild = async (child: ChildProcess, graceMs: number): Promise => { + await terminateTree(child, 'SIGTERM'); + const terminated = await Promise.race([ + waitForClose(child).then(() => true), + delay(graceMs).then(() => false), + ]); + if (terminated) return; + await terminateTree(child, 'SIGKILL'); + await Promise.race([waitForClose(child), delay(graceMs)]); +}; + +const statusSnapshot = (state: InspectorLauncherState, url: string | undefined): InspectorLauncherStatus => { + switch (state) { + case 'idle': + case 'starting': + case 'exited': + return Object.freeze({ state }); + case 'running': + return Object.freeze({ state, ...(url === undefined ? {} : { url }) }); + default: { + const exhaustive: never = state; + throw new Error(`Unexpected inspector state: ${String(exhaustive)}`); + } + } +}; + +/** Opt-in launcher for the standalone MCP Inspector app. Starts only on launch(). */ +export const createInspectorLauncher = (options: InspectorLauncherOptions): InspectorLauncher => { + const projectRoot = resolve(options.projectRoot); + const spawnChild = options.spawn ?? defaultSpawn; + const inheritedEnv = options.env ?? process.env; + const graceMs = terminateGrace(options); + const timeoutMs = startupTimeout(options); + let child: ChildProcess | undefined; + let closePromise: Promise | undefined; + let launchPromise: Promise<{ readonly url: string }> | undefined; + let state: InspectorLauncherState = 'idle'; + let url: string | undefined; + + const clearChild = (): void => { + child?.stdout?.removeAllListeners('data'); + child?.stderr?.removeAllListeners('data'); + child = undefined; + }; + + const launch = async (): Promise<{ readonly url: string }> => { + if (closePromise !== undefined) await closePromise; + if (launchPromise !== undefined && (state === 'starting' || state === 'running')) return launchPromise; + if (state === 'running' && url !== undefined) return Object.freeze({ url }); + + launchPromise = new Promise<{ readonly url: string }>((resolvePromise, rejectPromise) => { + let settled = false; + let stdout = ''; + const timer: { id?: NodeJS.Timeout } = {}; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + if (timer.id !== undefined) clearTimeout(timer.id); + action(); + }; + const succeed = (resolved: string): void => { + settle(() => { + state = 'running'; + url = resolved; + resolvePromise(Object.freeze({ url: resolved })); + }); + }; + const fail = (error: InspectorLauncherError): void => { + settle(() => { + if (state === 'starting') state = child === undefined ? 'idle' : 'exited'; + url = undefined; + launchPromise = undefined; + rejectPromise(error); + }); + }; + const consume = (chunk: Buffer | string): void => { + if (state !== 'starting') return; + stdout += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + if (stdout.length > 64 * 1024) stdout = stdout.slice(-64 * 1024); + const parsed = parseInspectorStdoutUrl(stdout); + if (parsed !== undefined) succeed(parsed); + }; + + state = 'starting'; + url = undefined; + let spawned: ChildProcess; + try { + spawned = spawnChild(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['--yes', inspectorPackage], { + cwd: projectRoot, + detached: process.platform !== 'win32', + env: { ...inheritedEnv, MCP_AUTO_OPEN_ENABLED: 'false' }, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + ...(process.platform === 'win32' ? { windowsHide: true } : {}), + }); + } catch (error) { + fail(inspectorLauncherError( + 'INSPECTOR_LAUNCH_FAILED', + error instanceof Error ? error.message : 'MCP Inspector could not be spawned.', + )); + return; + } + child = spawned; + timer.id = setTimeout(() => { + const running = child; + if (running !== undefined) void terminateChild(running, graceMs).catch(() => undefined); + fail(inspectorLauncherError('INSPECTOR_STARTUP_TIMEOUT', 'MCP Inspector did not publish a URL before the startup budget elapsed.')); + }, timeoutMs); + spawned.stdout?.on('data', (chunk: Buffer | string) => consume(chunk)); + spawned.stderr?.on('data', () => undefined); + spawned.once('error', (error) => { + fail(inspectorLauncherError('INSPECTOR_LAUNCH_FAILED', error.message)); + }); + spawned.once('close', () => { + // A timed-out launch rejects before its child finishes closing, so a + // retry may have replaced `child` by the time this exit arrives; the + // stale cleanup must not touch the replacement process. + if (child !== spawned) return; + if (state === 'running' && closePromise === undefined) { + state = 'exited'; + url = undefined; + launchPromise = undefined; + clearChild(); + return; + } + fail(inspectorLauncherError('INSPECTOR_EXITED', 'MCP Inspector exited before publishing a URL.')); + if (closePromise === undefined) clearChild(); + }); + }); + return launchPromise; + }; + + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + closePromise = (async () => { + const running = child; + if (running !== undefined) await terminateChild(running, graceMs); + clearChild(); + launchPromise = undefined; + url = undefined; + state = 'idle'; + })().finally(() => { + closePromise = undefined; + }); + return closePromise; + }; + + return Object.freeze({ + close, + launch, + status: () => statusSnapshot(state, url), + }); +}; diff --git a/packages/agent-bundle/src/dev/inspector-routes.ts b/packages/agent-bundle/src/dev/inspector-routes.ts new file mode 100644 index 000000000..2cbd38f1f --- /dev/null +++ b/packages/agent-bundle/src/dev/inspector-routes.ts @@ -0,0 +1,115 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { + diagnostic, + hasOnly, + isRequestDiagnostic, + rawPathname, + readJsonBody, + requestError, + responseDiagnostic, + responseJson as writeJsonResponse, +} from './http.ts'; +import type { InspectorLauncherStatus } from './inspector-launcher.ts'; + +type Route = 'launch' | 'status'; + +export interface InspectorRouteService { + launch(): Promise<{ readonly url: string }>; + status(): InspectorLauncherStatus; +} + +export interface InspectorRoutesOptions { + /** The foreground server injects its existing same-origin, same-session guard. */ + readonly authorize: (request: IncomingMessage) => void; + /** Omitted until the workbench composes the opt-in inspector launcher. */ + readonly service?: InspectorRouteService; +} + +const responseJson = (response: ServerResponse, body: unknown): void => + writeJsonResponse(response, body, { destroyIfEnded: true }); + +const pathError = (): never => { + throw requestError(diagnostic('AB8110', 'Inspector route path is not valid.', 400)); +}; + +const invalidShape = (): never => { + throw requestError(diagnostic('AB8111', 'Inspector request has an invalid shape.', 400)); +}; + +const route = (requestTarget: string | undefined): Route | undefined => { + const pathname = rawPathname(requestTarget); + if (pathname !== '/api/inspector' && !pathname.startsWith('/api/inspector/')) return undefined; + const parts = pathname.split('/'); + if (parts[0] !== '' || parts[1] !== 'api' || parts[2] !== 'inspector') return pathError(); + if (parts.length !== 4) return pathError(); + const kind = parts[3]; + if (kind === 'launch' || kind === 'status') return kind; + return pathError(); +}; + +const noQuery = (requestTarget: string | undefined): void => { + if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); +}; + +/** + * HTTP boundary for the opt-in standalone MCP Inspector. The browser never + * selects the child command, environment, or working directory. + */ +export class InspectorRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #service: InspectorRouteService | undefined; + #closed = false; + + constructor(options: InspectorRoutesOptions) { + this.#authorize = options.authorize; + this.#service = options.service; + } + + close(): void { + this.#closed = true; + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const parsed = route(request.url); + if (parsed === undefined) return false; + this.#authorize(request); + if (this.#closed) throw this.#unavailable(503); + const service = this.#service; + if (service === undefined) throw this.#unavailable(404); + try { + await this.#dispatch(parsed, request, response, service); + } catch (error) { + if (isRequestDiagnostic(error)) throw error; + throw requestError(diagnostic('AB8112', 'MCP Inspector could not be launched.', 502)); + } + return true; + } + + async #dispatch( + parsed: Route, + request: IncomingMessage, + response: ServerResponse, + service: InspectorRouteService, + ): Promise { + const method = request.method ?? 'GET'; + noQuery(request.url); + switch (parsed) { + case 'status': + if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return responseJson(response, { status: service.status() }); + case 'launch': + if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + if (!hasOnly(await readJsonBody(request, { invalidShape }), [])) invalidShape(); + return responseJson(response, { url: (await service.launch()).url }); + default: { + const exhaustive: never = parsed; + throw new Error(`Unexpected inspector route: ${String(exhaustive)}`); + } + } + } + + #unavailable(status: number): Error { + return requestError(diagnostic('AB8113', 'Inspector routes are not available.', status)); + } +} diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 9584368c7..e3ee5ff6b 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -11,6 +11,7 @@ import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogge import { EpochStore } from './epoch-store.ts'; import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; +import { createInspectorLauncher } from './inspector-launcher.ts'; import { HookPlaygroundService } from './playground/hook-playground-service.ts'; import { startForegroundServer, @@ -66,7 +67,7 @@ interface Closeable { export interface DevServerLifecycleCloseFailure { readonly error: unknown; - readonly resource: 'coordinator' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; + readonly resource: 'coordinator' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; } /** Reports session and coordinator cleanup failures without hiding either resource. */ @@ -396,6 +397,7 @@ export interface DevServerLifecycleOptions { readonly detachProjectLogs?: () => void; readonly logs?: DevLogService; readonly mcpApps?: Closeable; + readonly inspector?: Closeable; readonly mcpSessions: Closeable; readonly playground?: Closeable; readonly runtimeResources?: DevServerRuntimeLifecycleResources; @@ -405,6 +407,7 @@ export interface DevServerLifecycleOptions { export const closeDevServerLifecycle = async ({ coordinator, detachProjectLogs, + inspector, logs, mcpApps, mcpSessions, @@ -423,6 +426,7 @@ export const closeDevServerLifecycle = async ({ // the ordering is load-bearing for the sessions the coordinator drains. const producers: readonly (readonly [DevServerLifecycleCloseFailure['resource'], Closeable | undefined])[] = [ ['playground', playground], + ['inspector', inspector], ['mcp-apps', mcpApps], ['runtime-client-surfaces', runtimeResources?.clientSurfaces], ['runtime', runtimeResources?.runtime], @@ -460,12 +464,14 @@ const withMcpSessionLifecycle = ( playground: Closeable, logs: DevLogService, detachProjectLogs: () => void, + inspector: Closeable, ): ForegroundCoordinator => Object.freeze({ close: () => { clientSurfaces.beginClose(); return closeDevServerLifecycle({ coordinator, detachProjectLogs, + inspector, logs, mcpApps: mcpApps(), mcpSessions, @@ -693,6 +699,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise | undefined; const packedEnvironment = (): NodeJS.ProcessEnv => { @@ -44,17 +44,15 @@ const availablePort = async (): Promise => { }; describe.sequential('workbench package build', () => { -it('copies stable prebuilt workbench assets and exact Inspector provenance into the package distribution', async () => { +it('copies stable prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => { await buildPackage(); await expect(access(join(packageRoot, 'dist', 'workbench', 'index.html'))).resolves.toBeUndefined(); await expect(readFile(join(packageRoot, 'dist', 'workbench', 'static', 'js', 'index.js'), 'utf8')).resolves.toContain('Bundle dashboard'); await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toContain('MCP Inspector'); - await Promise.all(inspectorProvenanceFiles.map(async (file) => { - await expect(readFile(join(packageRoot, 'dist', 'workbench', 'src', 'inspector', file), 'utf8')).resolves.toBe( - await readFile(join(workbenchRoot, 'src', 'inspector', file), 'utf8'), - ); - })); + await expect(readFile(join(packageRoot, 'dist', 'workbench', appRendererLicense), 'utf8')).resolves.toBe( + await readFile(join(workbenchRoot, appRendererLicense), 'utf8'), + ); }, 60_000); it('prunes stale copied workbench assets without removing the package library output', async () => { @@ -83,9 +81,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos const listing = await execFile('tar', ['-tf', tarball]); expect(listing.stdout).toContain('package/dist/workbench/index.html'); expect(listing.stdout).toContain('package/dist/workbench/THIRD_PARTY_NOTICES'); - for (const file of inspectorProvenanceFiles) { - expect(listing.stdout).toContain(`package/dist/workbench/src/inspector/${file}`); - } + expect(listing.stdout).toContain('package/dist/workbench/src/mcp/APP-RENDERER-LICENSE'); expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*\.map$/mu); expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu); diff --git a/packages/agent-bundle/tests/inspector-launcher.test.ts b/packages/agent-bundle/tests/inspector-launcher.test.ts new file mode 100644 index 000000000..0a1d3fb78 --- /dev/null +++ b/packages/agent-bundle/tests/inspector-launcher.test.ts @@ -0,0 +1,215 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { resolve } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { expect, it } from '@rstest/core'; + +import { + createInspectorLauncher, + InspectorLauncherError, + parseInspectorStdoutUrl, + type InspectorLauncherOptions, + type InspectorSpawn, + type InspectorSpawnOptions, +} from '../src/dev/inspector-launcher.ts'; + +const startupTimeoutKey = Symbol.for('agent-bundle.inspector-launcher.startup-timeout-ms'); +const terminateGraceKey = Symbol.for('agent-bundle.inspector-launcher.terminate-grace-ms'); +const tokenUrl = 'http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=inspector-token'; + +interface SpawnInvocation { + readonly args: readonly string[]; + readonly command: string; + readonly options: InspectorSpawnOptions; +} + +class FakeChild extends EventEmitter { + readonly stderr = new PassThrough(); + readonly stdout = new PassThrough(); + readonly signals: NodeJS.Signals[] = []; + autoCloseOnKill = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + + kill = (signal: NodeJS.Signals = 'SIGTERM'): boolean => { + this.signals.push(signal); + if (!this.autoCloseOnKill) return true; + this.signalCode = signal; + queueMicrotask(() => this.emit('close', 0, signal)); + return true; + }; +} + +const withSeams = ( + options: InspectorLauncherOptions, + seams: { readonly startupTimeoutMs?: number; readonly terminateGraceMs?: number } = {}, +): InspectorLauncherOptions => Object.assign(options, { + ...(seams.startupTimeoutMs === undefined ? {} : { [startupTimeoutKey]: seams.startupTimeoutMs }), + ...(seams.terminateGraceMs === undefined ? {} : { [terminateGraceKey]: seams.terminateGraceMs }), +}); + +const fakeSpawn = (): { + readonly children: FakeChild[]; + readonly invocations: SpawnInvocation[]; + readonly spawn: InspectorSpawn; +} => { + const children: FakeChild[] = []; + const invocations: SpawnInvocation[] = []; + return Object.freeze({ + children, + invocations, + spawn: (command, args, options) => { + invocations.push(Object.freeze({ args: Object.freeze([...args]), command, options })); + const child = new FakeChild(); + children.push(child); + return child as unknown as ChildProcess; + }, + }); +}; + +it('stays idle until launch is requested and never auto-spawns', () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + expect(launcher.status()).toEqual({ state: 'idle' }); + expect(spawned.invocations).toEqual([]); +}); + +it('parses the first token-bearing inspector URL from stdout and is idempotent', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + env: { PATH: '/bin', EXTRA: 'keep' }, + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + expect(launcher.status()).toEqual({ state: 'starting' }); + expect(spawned.invocations).toHaveLength(1); + expect(spawned.invocations[0]).toMatchObject({ + args: ['--yes', '@modelcontextprotocol/inspector'], + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + options: { + cwd: resolve('/work/project'), + env: { EXTRA: 'keep', MCP_AUTO_OPEN_ENABLED: 'false', PATH: '/bin' }, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }, + }); + + const second = launcher.launch(); + spawned.children[0]!.stdout.write(`MCP Inspector is up at ${tokenUrl}\n`); + await expect(Promise.all([pending, second])).resolves.toEqual([{ url: tokenUrl }, { url: tokenUrl }]); + expect(spawned.invocations).toHaveLength(1); + expect(launcher.status()).toEqual({ state: 'running', url: tokenUrl }); + await expect(launcher.launch()).resolves.toEqual({ url: tokenUrl }); + expect(spawned.invocations).toHaveLength(1); +}); + +it('prefers a token query URL and falls back to the first localhost URL', () => { + expect(parseInspectorStdoutUrl([ + 'proxy http://localhost:6277', + `open ${tokenUrl}`, + ].join('\n'))).toBe(tokenUrl); + expect(parseInspectorStdoutUrl('listening on http://127.0.0.1:6274/inspector\n')).toBe( + 'http://127.0.0.1:6274/inspector', + ); + expect(parseInspectorStdoutUrl('https://localhost:6274/?sessionToken=abc')).toBe( + 'https://localhost:6274/?sessionToken=abc', + ); + expect(parseInspectorStdoutUrl('http://example.com/nope')).toBeUndefined(); + expect(parseInspectorStdoutUrl('http://localhost:6274/?MCP_PROXY_AUTH_')).toBeUndefined(); +}); + +it('joins a URL split across stdout chunks before resolving', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + spawned.children[0]!.stdout.write('http://localhost:6274/?MCP_PROXY_AUTH_'); + spawned.children[0]!.stdout.write('TOKEN=split-token\n'); + await expect(pending).resolves.toEqual({ + url: 'http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=split-token', + }); +}); + +it('kills the child and rejects when the startup budget elapses', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher(withSeams({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }, { startupTimeoutMs: 20, terminateGraceMs: 10 })); + + await expect(launcher.launch()).rejects.toEqual(expect.objectContaining({ + code: 'INSPECTOR_STARTUP_TIMEOUT', + name: InspectorLauncherError.name, + })); + expect(spawned.children[0]!.signals).toContain('SIGTERM'); + expect(launcher.status()).toEqual({ state: 'exited' }); +}); + +it('rejects when the child exits before a URL is published', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + spawned.children[0]!.emit('close', 1, null); + await expect(pending).rejects.toEqual(expect.objectContaining({ + code: 'INSPECTOR_EXITED', + name: InspectorLauncherError.name, + })); + expect(launcher.status()).toEqual({ state: 'exited' }); +}); + +it('ignores a superseded child exit after a timed-out launch is retried', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher(withSeams({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }, { startupTimeoutMs: 20, terminateGraceMs: 5 })); + + const first = launcher.launch(); + spawned.children[0]!.autoCloseOnKill = false; + await expect(first).rejects.toEqual(expect.objectContaining({ + code: 'INSPECTOR_STARTUP_TIMEOUT', + name: InspectorLauncherError.name, + })); + + const retried = launcher.launch(); + expect(spawned.invocations).toHaveLength(2); + spawned.children[0]!.emit('close', 1, null); + spawned.children[1]!.stdout.write(`${tokenUrl}\n`); + await expect(retried).resolves.toEqual({ url: tokenUrl }); + expect(launcher.status()).toEqual({ state: 'running', url: tokenUrl }); +}); + +it('closes the child tree idempotently and can launch again afterwards', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher(withSeams({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }, { terminateGraceMs: 10 })); + + const pending = launcher.launch(); + spawned.children[0]!.stdout.write(`${tokenUrl}\n`); + await pending; + await launcher.close(); + await launcher.close(); + expect(spawned.children[0]!.signals[0]).toBe('SIGTERM'); + expect(launcher.status()).toEqual({ state: 'idle' }); + + const relaunched = launcher.launch(); + expect(spawned.invocations).toHaveLength(2); + spawned.children[1]!.stdout.write(`${tokenUrl}\n`); + await expect(relaunched).resolves.toEqual({ url: tokenUrl }); +}); diff --git a/packages/agent-bundle/tests/inspector-routes.test.ts b/packages/agent-bundle/tests/inspector-routes.test.ts new file mode 100644 index 000000000..afa87bffc --- /dev/null +++ b/packages/agent-bundle/tests/inspector-routes.test.ts @@ -0,0 +1,175 @@ +import { expect, it } from '@rstest/core'; + +import { InspectorRoutes, type InspectorRouteService } from '../src/dev/inspector-routes.ts'; +import type { InspectorLauncherStatus } from '../src/dev/inspector-launcher.ts'; +import { + authorize, + originHeaders as headers, + startRoutes as startRouteServer, + type StartedRoutes, +} from './support/route-harness.ts'; + +const tokenUrl = 'http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=inspector-token'; + +class RecordingService implements InspectorRouteService { + readonly calls: string[] = []; + failure: Error | undefined; + state: InspectorLauncherStatus = Object.freeze({ state: 'idle' }); + + async launch(): Promise<{ readonly url: string }> { + this.calls.push('launch'); + if (this.failure !== undefined) throw this.failure; + return Object.freeze({ url: tokenUrl }); + } + + status(): InspectorLauncherStatus { + this.calls.push('status'); + return this.state; + } +} + +const startRoutes = async (service?: InspectorRouteService): Promise> => + startRouteServer(new InspectorRoutes({ + authorize, + ...(service === undefined ? {} : { service }), + }), { closeMode: 'awaited' }); + +const jsonHeaders = (): Readonly> => ({ ...headers(), 'content-type': 'application/json' }); + +const launchRequest = (url: string, body = '{}'): Promise => fetch(`${url}/api/inspector/launch`, { + body, + headers: jsonHeaders(), + method: 'POST', +}); + +it('reports the launcher status without starting anything', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + const idle = await fetch(`${started.url}/api/inspector/status`, { headers: headers() }); + expect(idle.status).toBe(200); + await expect(idle.json()).resolves.toEqual({ status: { state: 'idle' } }); + + service.state = Object.freeze({ state: 'running', url: tokenUrl }); + const running = await fetch(`${started.url}/api/inspector/status`, { headers: headers() }); + expect(running.status).toBe(200); + await expect(running.json()).resolves.toEqual({ status: { state: 'running', url: tokenUrl } }); + + expect(service.calls).toEqual(['status', 'status']); + } finally { + await started.close(); + } +}); + +it('launches the inspector on demand and returns its tokenized URL', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + const launched = await launchRequest(started.url); + expect(launched.status).toBe(200); + await expect(launched.json()).resolves.toEqual({ url: tokenUrl }); + expect(service.calls).toEqual(['launch']); + } finally { + await started.close(); + } +}); + +it('rejects invalid inspector paths, queries, methods, and smuggled bodies', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + for (const path of ['/api/inspector', '/api/inspector/', '/api/inspector/launch/extra', '/api/inspector/unknown']) { + const rejected = await fetch(`${started.url}${path}`, { headers: headers() }); + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toEqual({ + diagnostic: { code: 'AB8110', message: 'Inspector route path is not valid.' }, + }); + } + + const query = await fetch(`${started.url}/api/inspector/status?extra=1`, { headers: headers() }); + expect(query.status).toBe(400); + await expect(query.json()).resolves.toEqual({ + diagnostic: { code: 'AB8111', message: 'Inspector request has an invalid shape.' }, + }); + + const statusPost = await fetch(`${started.url}/api/inspector/status`, { headers: headers(), method: 'POST' }); + expect(statusPost.status).toBe(405); + await expect(statusPost.json()).resolves.toEqual({ + diagnostic: { code: 'AB8007', message: 'Route does not accept this method.' }, + }); + + const launchGet = await fetch(`${started.url}/api/inspector/launch`, { headers: headers() }); + expect(launchGet.status).toBe(405); + + const smuggled = await launchRequest(started.url, JSON.stringify({ command: '/tmp/untrusted' })); + expect(smuggled.status).toBe(400); + await expect(smuggled.json()).resolves.toEqual({ + diagnostic: { code: 'AB8111', message: 'Inspector request has an invalid shape.' }, + }); + + const media = await fetch(`${started.url}/api/inspector/launch`, { + body: 'launch=1', + headers: { ...headers(), 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }); + expect(media.status).toBe(415); + + const unrelated = await fetch(`${started.url}/api/other`, { headers: headers() }); + expect(unrelated.status).toBe(404); + + expect(service.calls).toEqual([]); + } finally { + await started.close(); + } +}); + +it('requires the same-session guard before reaching the launcher', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + const unauthorized = await fetch(`${started.url}/api/inspector/status`, { + headers: { origin: 'http://127.0.0.1:4567' }, + }); + expect(unauthorized.status).toBe(403); + expect(service.calls).toEqual([]); + } finally { + await started.close(); + } +}); + +it('reports an absent or closed launcher without leaking internals', async () => { + const absent = await startRoutes(); + try { + const unavailable = await fetch(`${absent.url}/api/inspector/status`, { headers: headers() }); + expect(unavailable.status).toBe(404); + await expect(unavailable.json()).resolves.toEqual({ + diagnostic: { code: 'AB8113', message: 'Inspector routes are not available.' }, + }); + } finally { + await absent.close(); + } + + const service = new RecordingService(); + service.failure = new Error('/private/npx/path could not be spawned'); + const started = await startRoutes(service); + try { + const failed = await launchRequest(started.url); + expect(failed.status).toBe(502); + await expect(failed.json()).resolves.toEqual({ + diagnostic: { code: 'AB8112', message: 'MCP Inspector could not be launched.' }, + }); + + started.routes.close(); + const closed = await fetch(`${started.url}/api/inspector/status`, { headers: headers() }); + expect(closed.status).toBe(503); + await expect(closed.json()).resolves.toEqual({ + diagnostic: { code: 'AB8113', message: 'Inspector routes are not available.' }, + }); + } finally { + await started.close(); + } +}); diff --git a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts index 084a57d4c..017cdba35 100644 --- a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts @@ -32,7 +32,7 @@ const expectedTree = `packages/ tests/playground-service.test.ts tests/runtime-provider.test.ts workbench/ - src/inspector/adapter/runtime-app-bridge.ts + src/mcp/runtime-app-bridge.ts src/mcp/runtime-consent-dialog.tsx src/mcp/runtime-consent-queue.ts src/runtime-model.ts @@ -83,7 +83,7 @@ describe('rsc runtime topology script', () => { 'packages/agent-bundle/tests/normalization.test.ts', 'packages/agent-bundle/tests/playground-service.test.ts', 'packages/agent-bundle/tests/runtime-provider.test.ts', - 'packages/workbench/src/inspector/adapter/runtime-app-bridge.ts', + 'packages/workbench/src/mcp/runtime-app-bridge.ts', 'packages/workbench/src/mcp/runtime-consent-dialog.tsx', 'packages/workbench/src/mcp/runtime-consent-queue.ts', 'packages/workbench/src/runtime-model.ts', diff --git a/packages/workbench/THIRD_PARTY_NOTICES b/packages/workbench/THIRD_PARTY_NOTICES index 862df943b..403fe1d8b 100644 --- a/packages/workbench/THIRD_PARTY_NOTICES +++ b/packages/workbench/THIRD_PARTY_NOTICES @@ -1,12 +1,10 @@ -Agent Bundle workbench includes an allowlisted source snapshot from the MCP -Inspector project: +Agent Bundle workbench includes an MCP App renderer derived from the MCP +Inspector project's AppRenderer component: MCP Inspector 2.2.0 https://github.com/modelcontextprotocol/inspector commit 672f9f41c548487a468b9e7007d2f9de14da5a69 MIT License -The copied path list, source and post-patch SHA-256 digests, declared package -imports, and retained upstream test provenance are in -src/inspector/UPSTREAM.json. The MIT license text is in -src/inspector/LICENSE.inspector. +The derived code is src/mcp/app-renderer.tsx. The MIT license text is in +src/mcp/APP-RENDERER-LICENSE. diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 7edc26412..c1da31f05 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -12,23 +12,17 @@ "typecheck": "tsc --project tsconfig.json" }, "dependencies": { - "@mantine/core": "9.5.2", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/ext-apps": "1.7.5", "@modelcontextprotocol/sdk": "1.30.0", - "papaparse": "5.6.0", - "pino": "10.3.1", "react": "19.2.8", "react-dom": "19.2.8", - "react-icons": "5.7.0", "react-markdown": "10.1.0", - "react-syntax-highlighter": "16.1.1", "remark-gfm": "4.0.1", "shiki": "4.4.3", "zod": "4.4.3" }, "devDependencies": { - "@inspector/core": "workspace:*", "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", "@types/react": "19.2.18", diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index 08da060e5..7a01b7aa6 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -18,9 +18,7 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_ assetPrefix: '/', copy: [ { from: resolve(import.meta.dirname, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' }, - { from: resolve(sourceRoot, 'inspector', 'UPSTREAM.json'), to: 'src/inspector/UPSTREAM.json', toType: 'file' }, - { from: resolve(sourceRoot, 'inspector', 'LICENSE.inspector'), to: 'src/inspector/LICENSE.inspector', toType: 'file' }, - { from: resolve(sourceRoot, 'inspector', 'PATCHES.md'), to: 'src/inspector/PATCHES.md', toType: 'file' }, + { from: resolve(sourceRoot, 'mcp', 'APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' }, ], distPath: { root: 'dist', diff --git a/packages/workbench/src/inspector/PATCHES.md b/packages/workbench/src/inspector/PATCHES.md deleted file mode 100644 index f4caf910a..000000000 --- a/packages/workbench/src/inspector/PATCHES.md +++ /dev/null @@ -1,19 +0,0 @@ -# Inspector local patches - -`001-rstest-inspector-tabs-import.patch` mechanically changes the retained -upstream `inspectorTabs.test.ts` import from `vitest` to `@rstest/core`. It -allows the exact upstream assertions to execute under this repository's Rstest -runner; no assertion or production-source content is changed. - -`002-remove-legacy-sse-mcp-types.patch` removes the legacy `SseServerConfig` -export, its `MCPServerConfig` union arm, and the `"sse"` `ServerType` literal. -It also updates transport comments in that file so they no longer claim legacy -SSE support. Workbench accepts only stdio and Streamable HTTP server -configurations. Its scope is `core/mcp/types.ts`; `core/mcp/fetchTracking.ts` -and the Network UI retain their `text/event-stream` tracing for modern -Streamable HTTP responses. - -Apart from files explicitly targeted by these numbered patches, allowlisted -Inspector files remain byte-identical. Every vendor change must be represented -by a numbered `patches/*.patch` file and recorded by -`scripts/sync-inspector.mjs` in `UPSTREAM.json`. diff --git a/packages/workbench/src/inspector/UPSTREAM.json b/packages/workbench/src/inspector/UPSTREAM.json deleted file mode 100644 index 31ae9d3d1..000000000 --- a/packages/workbench/src/inspector/UPSTREAM.json +++ /dev/null @@ -1,575 +0,0 @@ -{ - "aliases": [ - [ - "@", - "clients/web/src" - ], - [ - "@inspector/core", - "core" - ] - ], - "commit": "672f9f41c548487a468b9e7007d2f9de14da5a69", - "dependencies": [ - "@mantine/core", - "@modelcontextprotocol/client", - "@modelcontextprotocol/ext-apps", - "papaparse", - "pino", - "react", - "react-icons", - "react-markdown", - "react-syntax-highlighter", - "remark-gfm", - "zod" - ], - "files": [ - { - "path": "clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx", - "sha256": "9e3f70129ac6bfcb044e98a0d7e6157f88b418812108ccf5416b7fc882cb9e17", - "upstreamSha256": "9e3f70129ac6bfcb044e98a0d7e6157f88b418812108ccf5416b7fc882cb9e17" - }, - { - "path": "clients/web/src/components/elements/AppRenderer/AppRenderer.tsx", - "sha256": "e985fb6b0a1ea6dc0709ad73884dd2f26ede87e11a18c668b95184da3e4910ec", - "upstreamSha256": "e985fb6b0a1ea6dc0709ad73884dd2f26ede87e11a18c668b95184da3e4910ec" - }, - { - "path": "clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts", - "sha256": "8d5d5811d2e36248cb744ddeed59da30fb8305fa8208c32a29a3fdf822771553", - "upstreamSha256": "8d5d5811d2e36248cb744ddeed59da30fb8305fa8208c32a29a3fdf822771553" - }, - { - "path": "clients/web/src/components/elements/AppRenderer/hostContext.ts", - "sha256": "5d95cce4d9aeb7e54a73e85120dc88eaf068419e82e1d5fb53d6e11a93df23c6", - "upstreamSha256": "5d95cce4d9aeb7e54a73e85120dc88eaf068419e82e1d5fb53d6e11a93df23c6" - }, - { - "path": "clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx", - "sha256": "62dfb7a59c57bba278b45e8aa577c3e023f04898f9b5d7cac3e108928e6c8a06", - "upstreamSha256": "62dfb7a59c57bba278b45e8aa577c3e023f04898f9b5d7cac3e108928e6c8a06" - }, - { - "path": "clients/web/src/components/elements/ClearButton/ClearButton.tsx", - "sha256": "88ef2eec2c9ed5f1cf36ab5bd49fb6dca31765581ea5e7e1e7c590d01f46c9de", - "upstreamSha256": "88ef2eec2c9ed5f1cf36ab5bd49fb6dca31765581ea5e7e1e7c590d01f46c9de" - }, - { - "path": "clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx", - "sha256": "598dea88e6f2ac20c8695ceedabfb1fc33fb9085ac317062f4c47a2d77ce28c1", - "upstreamSha256": "598dea88e6f2ac20c8695ceedabfb1fc33fb9085ac317062f4c47a2d77ce28c1" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx", - "sha256": "98844a612e71f7cee3fa26e4c18a3389da57c9246e31acc829b9eb113a2aad5c", - "upstreamSha256": "98844a612e71f7cee3fa26e4c18a3389da57c9246e31acc829b9eb113a2aad5c" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/ContentViewer.tsx", - "sha256": "04cf2a650a98f08b9985a5f3cde69ba125e98a2dce1ec449dfa349b205f563a6", - "upstreamSha256": "04cf2a650a98f08b9985a5f3cde69ba125e98a2dce1ec449dfa349b205f563a6" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/CsvTable.tsx", - "sha256": "7513fd327da5377399543910ebfa6bfdefea079ffd3a2db43c9b640383277ca9", - "upstreamSha256": "7513fd327da5377399543910ebfa6bfdefea079ffd3a2db43c9b640383277ca9" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx", - "sha256": "08768e239fc4816ecf50e8b31dba8f3a223b3af063e82ffd21c86e896c1e5506", - "upstreamSha256": "08768e239fc4816ecf50e8b31dba8f3a223b3af063e82ffd21c86e896c1e5506" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/PdfFrame.tsx", - "sha256": "b135480177db1b35ddd668d2126956a92904d8aa9e42ed647468f64f0cac5a0b", - "upstreamSha256": "b135480177db1b35ddd668d2126956a92904d8aa9e42ed647468f64f0cac5a0b" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts", - "sha256": "5bd92c7dcaa159dc166bbd0dd1376eef883a5f6dcc90a5a8be5911f97e3fdf32", - "upstreamSha256": "5bd92c7dcaa159dc166bbd0dd1376eef883a5f6dcc90a5a8be5911f97e3fdf32" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/useObjectUrl.ts", - "sha256": "b1be3bb3dd0a4f79c1d0a4e9be06f6af9291b62eceeffd512486306f81216e01", - "upstreamSha256": "b1be3bb3dd0a4f79c1d0a4e9be06f6af9291b62eceeffd512486306f81216e01" - }, - { - "path": "clients/web/src/components/elements/CopyButton/CopyButton.tsx", - "sha256": "4f9a22e16a6a83426a8f8ea25f7ba5a8be1a14499816d452663014ef8930bcfc", - "upstreamSha256": "4f9a22e16a6a83426a8f8ea25f7ba5a8be1a14499816d452663014ef8930bcfc" - }, - { - "path": "clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx", - "sha256": "afa2d1342a75a2a7a85a3d6aaee87e4630a2bae7b8af5dd98f1a59fac1130b84", - "upstreamSha256": "afa2d1342a75a2a7a85a3d6aaee87e4630a2bae7b8af5dd98f1a59fac1130b84" - }, - { - "path": "clients/web/src/components/elements/EraBadge/EraBadge.tsx", - "sha256": "77497dd8044948815c2cb9f211de2701c69967bc5cd66e7540f613ac74ee3f73", - "upstreamSha256": "77497dd8044948815c2cb9f211de2701c69967bc5cd66e7540f613ac74ee3f73" - }, - { - "path": "clients/web/src/components/elements/EraBadge/eraUtils.ts", - "sha256": "117ca576d7996e8ab71162c426ef9b7e12fed37415d7d2e93fe5608637f2541d", - "upstreamSha256": "117ca576d7996e8ab71162c426ef9b7e12fed37415d7d2e93fe5608637f2541d" - }, - { - "path": "clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx", - "sha256": "4b7485c9e85a5f4a4197e4fb940d4e2103cee131a325631b58b7548a5fcccba4", - "upstreamSha256": "4b7485c9e85a5f4a4197e4fb940d4e2103cee131a325631b58b7548a5fcccba4" - }, - { - "path": "clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx", - "sha256": "e5593c3f08fdcf0c181f9a08808104f95a5114b8aa76e090b7dc2d05a7f1624f", - "upstreamSha256": "e5593c3f08fdcf0c181f9a08808104f95a5114b8aa76e090b7dc2d05a7f1624f" - }, - { - "path": "clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx", - "sha256": "dce37e85db459fcbf42f32f14fefc92da483c4bf486c62e52f320691cc47c6d3", - "upstreamSha256": "dce37e85db459fcbf42f32f14fefc92da483c4bf486c62e52f320691cc47c6d3" - }, - { - "path": "clients/web/src/components/elements/ListLoadError/ListLoadError.tsx", - "sha256": "11bab79f7551bf99e3c1eb3146299a6199069bcf5d8f5e690c4e06542f716876", - "upstreamSha256": "11bab79f7551bf99e3c1eb3146299a6199069bcf5d8f5e690c4e06542f716876" - }, - { - "path": "clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx", - "sha256": "8f0fc02d40ef799ee227ca27b8bc921f7b6ae7c86f8bfb494ecd4b7499d3c9a9", - "upstreamSha256": "8f0fc02d40ef799ee227ca27b8bc921f7b6ae7c86f8bfb494ecd4b7499d3c9a9" - }, - { - "path": "clients/web/src/components/elements/ListToggle/ListToggle.tsx", - "sha256": "374d716d7fc8be60782b5e58e25f2650cda35809c73eab3d41e45ddcba403ddd", - "upstreamSha256": "374d716d7fc8be60782b5e58e25f2650cda35809c73eab3d41e45ddcba403ddd" - }, - { - "path": "clients/web/src/components/elements/LogEntry/LogEntry.tsx", - "sha256": "3cfed2ff30458a1a8231f08260699c66b07ad22e6c1dea8c1c459843a446305e", - "upstreamSha256": "3cfed2ff30458a1a8231f08260699c66b07ad22e6c1dea8c1c459843a446305e" - }, - { - "path": "clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx", - "sha256": "d63187cefc4a87897526c8b0dccd3178649fd151dc8cb72b43c768940c2b848a", - "upstreamSha256": "d63187cefc4a87897526c8b0dccd3178649fd151dc8cb72b43c768940c2b848a" - }, - { - "path": "clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx", - "sha256": "eb7be755bd2eb2a8a28bcb92cedeba785f670da2a784b590c5d03343ed698c2f", - "upstreamSha256": "eb7be755bd2eb2a8a28bcb92cedeba785f670da2a784b590c5d03343ed698c2f" - }, - { - "path": "clients/web/src/components/elements/MessageBubble/MessageBubble.tsx", - "sha256": "a8b76cb90cc2d2f582b8e954e2972549aa31d3128225d76550906d99ce4a86da", - "upstreamSha256": "a8b76cb90cc2d2f582b8e954e2972549aa31d3128225d76550906d99ce4a86da" - }, - { - "path": "clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx", - "sha256": "fa0744a9a86cb9060a56ab53d3b966005add690519175bcb2c3961b588025a1c", - "upstreamSha256": "fa0744a9a86cb9060a56ab53d3b966005add690519175bcb2c3961b588025a1c" - }, - { - "path": "clients/web/src/components/elements/MethodBadge/MethodBadge.tsx", - "sha256": "b388b10b1c113db7786c2b195befb9e3439851b5670cd657c3e6077ebf4fe526", - "upstreamSha256": "b388b10b1c113db7786c2b195befb9e3439851b5670cd657c3e6077ebf4fe526" - }, - { - "path": "clients/web/src/components/elements/PinToggle/PinToggle.tsx", - "sha256": "878cc0d8b078fb29da3f8fbcde442c5e9def9899398b93541a086f46f3816373", - "upstreamSha256": "878cc0d8b078fb29da3f8fbcde442c5e9def9899398b93541a086f46f3816373" - }, - { - "path": "clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx", - "sha256": "9cd255ca44489af5af1041301e8a6e4822988ca22c55c50ac662aa5b00f194e6", - "upstreamSha256": "9cd255ca44489af5af1041301e8a6e4822988ca22c55c50ac662aa5b00f194e6" - }, - { - "path": "clients/web/src/components/elements/ReplayButton/ReplayButton.tsx", - "sha256": "e8b31a40198f54645ca7e03a2bead8376cd3c56071e1f514a319969a02ba6bfc", - "upstreamSha256": "e8b31a40198f54645ca7e03a2bead8376cd3c56071e1f514a319969a02ba6bfc" - }, - { - "path": "clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx", - "sha256": "319ef13f663df458928735380189c069bcaf7198f97eec71b322670ffcae3aa1", - "upstreamSha256": "319ef13f663df458928735380189c069bcaf7198f97eec71b322670ffcae3aa1" - }, - { - "path": "clients/web/src/components/elements/SortToggle/SortToggle.tsx", - "sha256": "65c831ba8375ca6cea7b7daffb66d2b5dba1fedeeaeca38d02c265410dd29dd8", - "upstreamSha256": "65c831ba8375ca6cea7b7daffb66d2b5dba1fedeeaeca38d02c265410dd29dd8" - }, - { - "path": "clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx", - "sha256": "3a953c17586e292cdc6a5e6da8c7a2bb23430e4f042ab8536e22d14a8f53f041", - "upstreamSha256": "3a953c17586e292cdc6a5e6da8c7a2bb23430e4f042ab8536e22d14a8f53f041" - }, - { - "path": "clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx", - "sha256": "396257b7930a04abe36279c7f5dc563892a53f91faa87634e430ebe034ff0903", - "upstreamSha256": "396257b7930a04abe36279c7f5dc563892a53f91faa87634e430ebe034ff0903" - }, - { - "path": "clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts", - "sha256": "20bcc6707c2ab8fe198c500bb4a9a15ed7790641baca6695d7d6992130d5431e", - "upstreamSha256": "20bcc6707c2ab8fe198c500bb4a9a15ed7790641baca6695d7d6992130d5431e" - }, - { - "path": "clients/web/src/components/elements/accessibleTextColor.ts", - "sha256": "5927490b3818113d24aaa220ca0684bbd10af170fc5ef35979d1508b4b9dc7c0", - "upstreamSha256": "5927490b3818113d24aaa220ca0684bbd10af170fc5ef35979d1508b4b9dc7c0" - }, - { - "path": "clients/web/src/components/elements/filledBadgeColor.ts", - "sha256": "2a9b4a2421e14b1e6a58758439400d7f2a1d88e56e7dc221bd2185399329c096", - "upstreamSha256": "2a9b4a2421e14b1e6a58758439400d7f2a1d88e56e7dc221bd2185399329c096" - }, - { - "path": "clients/web/src/components/groups/AppControls/AppControls.tsx", - "sha256": "51c27e1f95f60c8de19d749754c860721e210d763053a76dbe7ce59ea3d090a1", - "upstreamSha256": "51c27e1f95f60c8de19d749754c860721e210d763053a76dbe7ce59ea3d090a1" - }, - { - "path": "clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx", - "sha256": "36b4fcd5a0899e1d6c8ce9fb52fc6da89bdbc2130a0fc21979abacf9bd7dc8af", - "upstreamSha256": "36b4fcd5a0899e1d6c8ce9fb52fc6da89bdbc2130a0fc21979abacf9bd7dc8af" - }, - { - "path": "clients/web/src/components/groups/AppListItem/AppListItem.tsx", - "sha256": "5dcbce4591e14c9b2c23b6e1a71dbf1e3204ec0996373ec2fc2017b5766dd03f", - "upstreamSha256": "5dcbce4591e14c9b2c23b6e1a71dbf1e3204ec0996373ec2fc2017b5766dd03f" - }, - { - "path": "clients/web/src/components/groups/LogControls/LogControls.tsx", - "sha256": "489265baceba4ebb632ead9d8bbb9d1d7d8758690247ab0faaaa14c0335aabb7", - "upstreamSha256": "489265baceba4ebb632ead9d8bbb9d1d7d8758690247ab0faaaa14c0335aabb7" - }, - { - "path": "clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx", - "sha256": "0ed9931bf2fe2132a0421a7a477dc8225d8ea8fba8895492aef056bbff58f610", - "upstreamSha256": "0ed9931bf2fe2132a0421a7a477dc8225d8ea8fba8895492aef056bbff58f610" - }, - { - "path": "clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx", - "sha256": "c3f65eee909077557cac521fd9acf346c76f98f0b5f7409d0c85a1a4042c5676", - "upstreamSha256": "c3f65eee909077557cac521fd9acf346c76f98f0b5f7409d0c85a1a4042c5676" - }, - { - "path": "clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx", - "sha256": "47e31bf3b7c07c817659b380a05bdab3ee7d15b2736fcec8a4a64d551809a53a", - "upstreamSha256": "47e31bf3b7c07c817659b380a05bdab3ee7d15b2736fcec8a4a64d551809a53a" - }, - { - "path": "clients/web/src/components/groups/NetworkControls/NetworkControls.tsx", - "sha256": "6f1296e038fea1e77f5046d0a46930f9866f076adce1038165320c333b1bc94d", - "upstreamSha256": "6f1296e038fea1e77f5046d0a46930f9866f076adce1038165320c333b1bc94d" - }, - { - "path": "clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx", - "sha256": "830d2768445f77741379eecffb428ed2653acd1111f03a9b3e440c895ebecb0c", - "upstreamSha256": "830d2768445f77741379eecffb428ed2653acd1111f03a9b3e440c895ebecb0c" - }, - { - "path": "clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx", - "sha256": "3281e49e1aa22b90b1d6ba38bacbfbcc406afdbcf657d4cf9429faca4b436dca", - "upstreamSha256": "3281e49e1aa22b90b1d6ba38bacbfbcc406afdbcf657d4cf9429faca4b436dca" - }, - { - "path": "clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx", - "sha256": "9ab9a7cd5746ea5fef03da4f5c3abb0e954bd93d4c5a08a904b63bacabe2c007", - "upstreamSha256": "9ab9a7cd5746ea5fef03da4f5c3abb0e954bd93d4c5a08a904b63bacabe2c007" - }, - { - "path": "clients/web/src/components/groups/PromptControls/PromptControls.tsx", - "sha256": "4a9ede42314bb9c9b8021e385ed546c6909f3d40ef01c10f5efd6424eb29aa79", - "upstreamSha256": "4a9ede42314bb9c9b8021e385ed546c6909f3d40ef01c10f5efd6424eb29aa79" - }, - { - "path": "clients/web/src/components/groups/PromptListItem/PromptListItem.tsx", - "sha256": "8207507aa6c6403bbb12fe31334143541d63ad521a7b13d1b7ec1ff760613260", - "upstreamSha256": "8207507aa6c6403bbb12fe31334143541d63ad521a7b13d1b7ec1ff760613260" - }, - { - "path": "clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx", - "sha256": "56ba091e42c7b9c7cff111ca8838c39f111d274d56c840ffddf4f83b5e6171c1", - "upstreamSha256": "56ba091e42c7b9c7cff111ca8838c39f111d274d56c840ffddf4f83b5e6171c1" - }, - { - "path": "clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx", - "sha256": "1ecbd7de964c22a62fb691b0ec5208de0fcf63a6a76e17f1e9e7cac4b1e98cf5", - "upstreamSha256": "1ecbd7de964c22a62fb691b0ec5208de0fcf63a6a76e17f1e9e7cac4b1e98cf5" - }, - { - "path": "clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx", - "sha256": "209801f3654837c20cc289d528c362d5bc9d8734fde487f4976f12a8345587da", - "upstreamSha256": "209801f3654837c20cc289d528c362d5bc9d8734fde487f4976f12a8345587da" - }, - { - "path": "clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx", - "sha256": "462addc7773e539528ae9cd86d29cd43c81a83e6a4f9ad15e8978f0749dfc68f", - "upstreamSha256": "462addc7773e539528ae9cd86d29cd43c81a83e6a4f9ad15e8978f0749dfc68f" - }, - { - "path": "clients/web/src/components/groups/ResourceControls/ResourceControls.tsx", - "sha256": "89bd6c99da4db63b0afb69fbfa9e74a7711c2a0b78fdf4dc2be0237827cfd6f3", - "upstreamSha256": "89bd6c99da4db63b0afb69fbfa9e74a7711c2a0b78fdf4dc2be0237827cfd6f3" - }, - { - "path": "clients/web/src/components/groups/ResourceLink/ResourceLink.tsx", - "sha256": "860c979724bbe45acfee0f1b86f140f0dcb62e51af9029d4fe922e6ca8a01006", - "upstreamSha256": "860c979724bbe45acfee0f1b86f140f0dcb62e51af9029d4fe922e6ca8a01006" - }, - { - "path": "clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx", - "sha256": "f2bb6d31fa877200d2c6fcfaaee7f1555fffe7dd61bac5876b60ae1eda00f063", - "upstreamSha256": "f2bb6d31fa877200d2c6fcfaaee7f1555fffe7dd61bac5876b60ae1eda00f063" - }, - { - "path": "clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx", - "sha256": "bb3dde69d7b30105b3b01c5fbf92c87e55fd2e018dad820a38abf9c9828c3256", - "upstreamSha256": "bb3dde69d7b30105b3b01c5fbf92c87e55fd2e018dad820a38abf9c9828c3256" - }, - { - "path": "clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx", - "sha256": "b5f0621ff7baded6eea378792bf52fcb6cd1a6b2b0654a0938397c9c4ab2c57c", - "upstreamSha256": "b5f0621ff7baded6eea378792bf52fcb6cd1a6b2b0654a0938397c9c4ab2c57c" - }, - { - "path": "clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx", - "sha256": "bfabf183b854622837093f6cded5441f9ae57b5bb891896d97e8fca4c4a8d219", - "upstreamSha256": "bfabf183b854622837093f6cded5441f9ae57b5bb891896d97e8fca4c4a8d219" - }, - { - "path": "clients/web/src/components/groups/SchemaForm/SchemaForm.tsx", - "sha256": "58c197220118b6941d50e725a4a48323c0d8429f6142d9675541929b7383b9f9", - "upstreamSha256": "58c197220118b6941d50e725a4a48323c0d8429f6142d9675541929b7383b9f9" - }, - { - "path": "clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx", - "sha256": "d7e001945f955ed4d75f73be3dc516b6455310b5b0005cfb2fb8be78fab6e508", - "upstreamSha256": "d7e001945f955ed4d75f73be3dc516b6455310b5b0005cfb2fb8be78fab6e508" - }, - { - "path": "clients/web/src/components/groups/ToolControls/ToolControls.tsx", - "sha256": "5760455bc25d4a2d9ee0d0f0a988c1d365c721a8d9eba774d1097d1039801d3f", - "upstreamSha256": "5760455bc25d4a2d9ee0d0f0a988c1d365c721a8d9eba774d1097d1039801d3f" - }, - { - "path": "clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx", - "sha256": "e8986c43a34e4a033255b59fa46ef3da27c4f194ee8aaaa7dbe9cdfcf392bc53", - "upstreamSha256": "e8986c43a34e4a033255b59fa46ef3da27c4f194ee8aaaa7dbe9cdfcf392bc53" - }, - { - "path": "clients/web/src/components/groups/ToolListItem/ToolListItem.tsx", - "sha256": "2c3e9a3431bd325b3ac5d1cb9a080af04d9c86e18ce8c73776fe09b66d11eab6", - "upstreamSha256": "2c3e9a3431bd325b3ac5d1cb9a080af04d9c86e18ce8c73776fe09b66d11eab6" - }, - { - "path": "clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx", - "sha256": "6d5b60615f261e2920cf0d4687a57882559304eaf85be7edc8d1e831b1067da5", - "upstreamSha256": "6d5b60615f261e2920cf0d4687a57882559304eaf85be7edc8d1e831b1067da5" - }, - { - "path": "clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx", - "sha256": "1410c4a9a1a07e2167fcac527fe080b5da5734d46d7abb81339e326aa8d49825", - "upstreamSha256": "1410c4a9a1a07e2167fcac527fe080b5da5734d46d7abb81339e326aa8d49825" - }, - { - "path": "clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts", - "sha256": "b39d6ac8b787992009939637bd53b68e7f44a3375e15cd783a0ea2609ec2c01b", - "upstreamSha256": "b39d6ac8b787992009939637bd53b68e7f44a3375e15cd783a0ea2609ec2c01b" - }, - { - "path": "clients/web/src/components/groups/protocolUtils.ts", - "sha256": "a8f7405db58c415e451ca14ecd9a8dc6f406a1fa3df662e33e0da2cf07cf5952", - "upstreamSha256": "a8f7405db58c415e451ca14ecd9a8dc6f406a1fa3df662e33e0da2cf07cf5952" - }, - { - "path": "clients/web/src/components/screens/AppsScreen/AppsScreen.tsx", - "sha256": "aba238f3006b8438e57db5d106cfb2775b7a707ca492f704362bca1f993c82a0", - "upstreamSha256": "aba238f3006b8438e57db5d106cfb2775b7a707ca492f704362bca1f993c82a0" - }, - { - "path": "clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx", - "sha256": "6ff4c2e80b986be8b425d971261876b7e85139c738cb7a83998008a566d1e9c5", - "upstreamSha256": "6ff4c2e80b986be8b425d971261876b7e85139c738cb7a83998008a566d1e9c5" - }, - { - "path": "clients/web/src/components/screens/LoggingScreen/logLevels.ts", - "sha256": "617f65415b58e97e5fc1a829c14317b694554036042cac18f6ec072cee3a26ee", - "upstreamSha256": "617f65415b58e97e5fc1a829c14317b694554036042cac18f6ec072cee3a26ee" - }, - { - "path": "clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx", - "sha256": "b65175c08d4ee2bbfce83db88c1440bdb19ae4939eb806422430805f3cdcb3fe", - "upstreamSha256": "b65175c08d4ee2bbfce83db88c1440bdb19ae4939eb806422430805f3cdcb3fe" - }, - { - "path": "clients/web/src/components/screens/NetworkScreen/fetchCategories.ts", - "sha256": "7b40687626ce3f0f88888c22b367b5e07f042d8ce1085c853914116a2d3388d3", - "upstreamSha256": "7b40687626ce3f0f88888c22b367b5e07f042d8ce1085c853914116a2d3388d3" - }, - { - "path": "clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx", - "sha256": "a86f58de5889810bced42c1f069dd538ba56f75180a976564875fc6950ea8ad8", - "upstreamSha256": "a86f58de5889810bced42c1f069dd538ba56f75180a976564875fc6950ea8ad8" - }, - { - "path": "clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx", - "sha256": "7b4b8ecdf0708d732364db5bc156868ca4f819d98117aa7b03b1349658b29937", - "upstreamSha256": "7b4b8ecdf0708d732364db5bc156868ca4f819d98117aa7b03b1349658b29937" - }, - { - "path": "clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx", - "sha256": "e7c64625c977fa5c1f87fe7b4a1ffd029d6886eb4607aabbea15aeadcd234bfc", - "upstreamSha256": "e7c64625c977fa5c1f87fe7b4a1ffd029d6886eb4607aabbea15aeadcd234bfc" - }, - { - "path": "clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx", - "sha256": "132a85ddb7fbe8119d1e4ca1271c22eb423b2dd0262ea16b92ccb0775eebe288", - "upstreamSha256": "132a85ddb7fbe8119d1e4ca1271c22eb423b2dd0262ea16b92ccb0775eebe288" - }, - { - "path": "clients/web/src/hooks/useScrollMemory.ts", - "sha256": "8c5e431b438f398cce8339ff531ee8828502332a81f2ea4ab8246d437826ea5f", - "upstreamSha256": "8c5e431b438f398cce8339ff531ee8828502332a81f2ea4ab8246d437826ea5f" - }, - { - "path": "clients/web/src/hooks/useValueChange.ts", - "sha256": "2917c940c3ffb572709efa08528bbb7c31edc61af2a3b6ca267c4c8952ee53a5", - "upstreamSha256": "2917c940c3ffb572709efa08528bbb7c31edc61af2a3b6ca267c4c8952ee53a5" - }, - { - "path": "clients/web/src/lib/downloadFile.ts", - "sha256": "b3bda21ccacd36fa85bba4a58177b9590701fd0fabc83fe22bc1dd5dd6ca6057", - "upstreamSha256": "b3bda21ccacd36fa85bba4a58177b9590701fd0fabc83fe22bc1dd5dd6ca6057" - }, - { - "path": "clients/web/src/utils/inspectorTabs.test.ts", - "sha256": "9e1093cdc193798a63e2ad1587bb2621f244de35806506fee86c9c8e8fa87191", - "upstreamSha256": "fa716dc85b09ba39b64ae0017a28ff74b9cfe395eae6191cfbbbccd6a74700d2" - }, - { - "path": "clients/web/src/utils/inspectorTabs.ts", - "sha256": "2bada46300f4a4ff3d3736a69f66606f7ddb3f2790ae66524134139ad800780e", - "upstreamSha256": "2bada46300f4a4ff3d3736a69f66606f7ddb3f2790ae66524134139ad800780e" - }, - { - "path": "clients/web/src/utils/jsonUtils.ts", - "sha256": "a6688d034fbd2048199af0c0b832e04a03cbdb5088a9c61cd84ec16beb92cbdb", - "upstreamSha256": "a6688d034fbd2048199af0c0b832e04a03cbdb5088a9c61cd84ec16beb92cbdb" - }, - { - "path": "clients/web/src/utils/maskSecrets.ts", - "sha256": "9bdf2a98a843912639d0a0ac86f4a24c8b1355ce81a055932f0c949c8442e2b0", - "upstreamSha256": "9bdf2a98a843912639d0a0ac86f4a24c8b1355ce81a055932f0c949c8442e2b0" - }, - { - "path": "clients/web/src/utils/mcpNetworkHeaders.ts", - "sha256": "3388f31d77cbf670946b09da16ba7a2cdf8842bf041a3fb8a4d91bb154f20526", - "upstreamSha256": "3388f31d77cbf670946b09da16ba7a2cdf8842bf041a3fb8a4d91bb154f20526" - }, - { - "path": "clients/web/src/utils/oauthNetworkPhase.ts", - "sha256": "75c2a99f02033c613b250f926f400d4666606dbf65765201935c2b2d76d7c14e", - "upstreamSha256": "75c2a99f02033c613b250f926f400d4666606dbf65765201935c2b2d76d7c14e" - }, - { - "path": "clients/web/src/utils/sandbox-csp.ts", - "sha256": "d70dbbcab522ded755a5acb8f99942b564045c2db4d21519e7af662dadfd72f3", - "upstreamSha256": "d70dbbcab522ded755a5acb8f99942b564045c2db4d21519e7af662dadfd72f3" - }, - { - "path": "clients/web/src/utils/toolUtils.ts", - "sha256": "db271668aa36755f17cb32ad3dd4859da6c7244fe1b676bc180325939efed233", - "upstreamSha256": "db271668aa36755f17cb32ad3dd4859da6c7244fe1b676bc180325939efed233" - }, - { - "path": "core/auth/providers.ts", - "sha256": "ca2c9191679b9383cb3a5179e3732db33d8ea147e2991c9de4a842714f319c41", - "upstreamSha256": "ca2c9191679b9383cb3a5179e3732db33d8ea147e2991c9de4a842714f319c41" - }, - { - "path": "core/auth/storage.ts", - "sha256": "ab9b8d78cde7f626b45134c5582f2d1dff509c2cf143873b8ae3c46f9069af76", - "upstreamSha256": "ab9b8d78cde7f626b45134c5582f2d1dff509c2cf143873b8ae3c46f9069af76" - }, - { - "path": "core/auth/types.ts", - "sha256": "9ee8b27f521e76246974d8627b3e470a1de763325a46620b30543411b6c6d5dc", - "upstreamSha256": "9ee8b27f521e76246974d8627b3e470a1de763325a46620b30543411b6c6d5dc" - }, - { - "path": "core/auth/utils.ts", - "sha256": "62509317c257047f4a3771f26117acbf89789195d35867d18d540f887f18d506", - "upstreamSha256": "62509317c257047f4a3771f26117acbf89789195d35867d18d540f887f18d506" - }, - { - "path": "core/client/types.ts", - "sha256": "2a59a53f811a5893c39b87960d401484e2f7f1553a1938a2bc836717adfb5bca", - "upstreamSha256": "2a59a53f811a5893c39b87960d401484e2f7f1553a1938a2bc836717adfb5bca" - }, - { - "path": "core/json/jsonUtils.ts", - "sha256": "2805dc0e482975d05a201b58ab6e8bc47191cc2695e44858c3c4f2d95e3d4a5c", - "upstreamSha256": "2805dc0e482975d05a201b58ab6e8bc47191cc2695e44858c3c4f2d95e3d4a5c" - }, - { - "path": "core/json/xMcpHeader.ts", - "sha256": "6eac71266c20354bd621527ae4d47769396bbc892a89650df54a08fa4ca337ed", - "upstreamSha256": "6eac71266c20354bd621527ae4d47769396bbc892a89650df54a08fa4ca337ed" - }, - { - "path": "core/logging/logger.ts", - "sha256": "317c2722b2c343eb4cdfbcfa80f30d2d7197081c952b10c508f6ad5d024a9a97", - "upstreamSha256": "317c2722b2c343eb4cdfbcfa80f30d2d7197081c952b10c508f6ad5d024a9a97" - }, - { - "path": "core/mcp/fetchTracking.ts", - "sha256": "197e86d947afda50310c1ea1001587fca33a96dc77ff06b2384a7565e29af15f", - "upstreamSha256": "197e86d947afda50310c1ea1001587fca33a96dc77ff06b2384a7565e29af15f" - }, - { - "path": "core/mcp/types.ts", - "sha256": "24cc496f63818123a7d2ecdcb39d6ccef36703d1e786e97fb3f33eb81e79f0d1", - "upstreamSha256": "b8e68e59784b3372e8c9dede6cc3daa6f39ac982c851553a20305523c0c00abf" - } - ], - "license": { - "path": "repository:LICENSE.inspector", - "sha256": "fcf5eb4c9424e8cc443554f22e0dbf42a5a6a5dbfcf07b5e7f742efdf2ff280a" - }, - "mcpSdkVersion": "2.0.0", - "patches": [ - { - "path": "patches/001-rstest-inspector-tabs-import.patch", - "sha256": "eb679e8e02a5d89f631c99a9a6857be14a0a08fd5944cd0fa87e5dffbaf22574" - }, - { - "path": "patches/002-remove-legacy-sse-mcp-types.patch", - "sha256": "cde5921826bb9629dffc9827ba2f1ad6a6395f70e4ada64128b43781d03c8cf5" - } - ], - "publicImports": [ - "@modelcontextprotocol/ext-apps/app-bridge", - "react-icons/md", - "react-icons/ri", - "react-icons/tb", - "react-icons/ti", - "react-syntax-highlighter/dist/esm/languages/prism/css", - "react-syntax-highlighter/dist/esm/languages/prism/json", - "react-syntax-highlighter/dist/esm/languages/prism/markdown", - "react-syntax-highlighter/dist/esm/languages/prism/markup", - "react-syntax-highlighter/dist/esm/languages/prism/yaml", - "react-syntax-highlighter/dist/esm/prism-light", - "react-syntax-highlighter/dist/esm/styles/prism/tomorrow" - ], - "repository": "https://github.com/modelcontextprotocol/inspector.git", - "retainedTests": [ - "clients/web/src/utils/inspectorTabs.test.ts" - ], - "testDependencies": [ - "@rstest/core" - ], - "version": "2.2.0" -} diff --git a/packages/workbench/src/inspector/adapter/closure-screens.d.ts b/packages/workbench/src/inspector/adapter/closure-screens.d.ts deleted file mode 100644 index 97d9052c5..000000000 --- a/packages/workbench/src/inspector/adapter/closure-screens.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ComponentType } from 'react'; - -export const AppsScreen: ComponentType>; -export const LoggingScreen: ComponentType>; -export const NetworkScreen: ComponentType>; -export const PromptsScreen: ComponentType>; -export const ProtocolScreen: ComponentType>; -export const ResourcesScreen: ComponentType>; -export const ToolsScreen: ComponentType>; diff --git a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts b/packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts deleted file mode 100644 index 3423781ff..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts +++ /dev/null @@ -1,84 +0,0 @@ -export const AppsScreen: unknown; -export const LoggingScreen: unknown; -export const NetworkScreen: unknown; -export const PromptsScreen: unknown; -export const ProtocolScreen: unknown; -export const ResourcesScreen: unknown; -export const ToolsScreen: unknown; - -import type { CallToolResult, Tool } from '@modelcontextprotocol/client'; -import type { Ref, RefObject } from 'react'; - -export type McpAppRendererDisplayMode = 'fullscreen' | 'inline' | 'pip'; - -export type McpAppRendererJsonValue = - | null - | boolean - | number - | string - | readonly McpAppRendererJsonValue[] - | Readonly>; - -export type McpAppRendererTool = Tool; - -export interface McpAppRendererMessage { - readonly content: readonly McpAppRendererJsonValue[]; - readonly role: 'user'; -} - -export interface AppRendererBridge { - addEventListener(type: 'initialized', listener: () => void): void; - addEventListener(type: 'loggingmessage', listener: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void): void; - addEventListener(type: 'sizechange', listener: (params: Readonly<{ readonly height?: number; readonly width?: number }>) => void): void; - close(): Promise; - onmessage?: (params: McpAppRendererMessage) => Promise>; - onrequestdisplaymode?: (params: Readonly<{ readonly mode: McpAppRendererDisplayMode }>) => Promise>; - sendHostContextChange(context: Partial): Promise; - sendToolCancelled(params: Readonly<{ readonly reason: string }>): Promise; - sendToolInput(params: Readonly<{ readonly arguments: Record }>): Promise; - sendToolInputPartial(params: Readonly<{ readonly arguments: Record }>): Promise; - sendToolResult(result: CallToolResult): Promise; - teardownResource(params: Readonly>): Promise>>; -} - -export type BridgeFactory = ( - iframe: HTMLIFrameElement, - tool: McpAppRendererTool, -) => AppRendererBridge | Promise; - -export interface AppRendererHandle { - sendToolCancelled(reason: string): Promise; - sendToolInput(args: Record): Promise; - sendToolResult(result: CallToolResult): Promise; - teardown(): Promise; -} - -export interface AppRendererProps { - readonly bridgeFactory: BridgeFactory; - readonly displayMode?: McpAppRendererDisplayMode; - readonly onAppStatusChange?: (status: 'error' | 'loading' | 'ready') => void; - readonly onError?: (error: Error) => void; - readonly onLog?: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void; - readonly onMessage?: (params: McpAppRendererMessage) => void; - readonly onRequestDisplayMode?: (requested: McpAppRendererDisplayMode) => McpAppRendererDisplayMode; - readonly onSizeChange?: (size: Readonly<{ readonly height?: number; readonly width?: number }>) => void; - readonly partialInputs?: readonly Readonly>[]; - readonly containerRef?: RefObject; - readonly ref?: Ref; - readonly sandboxPath: string; - readonly tool: McpAppRendererTool; -} - -export const AppRenderer: (props: AppRendererProps) => import('react').ReactNode; - -export interface McpAppRendererHostContext { - readonly availableDisplayModes?: readonly McpAppRendererDisplayMode[]; - readonly containerDimensions?: Readonly<{ readonly height: number; readonly width: number }>; - readonly displayMode?: McpAppRendererDisplayMode; - readonly theme?: 'dark' | 'light'; -} - -export const snapshotHostContext: ( - container: HTMLElement | null, - availableDisplayModes: readonly McpAppRendererDisplayMode[], -) => McpAppRendererHostContext; diff --git a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.js b/packages/workbench/src/inspector/adapter/inspector-closure-vendor.js deleted file mode 100644 index f188ea52b..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.js +++ /dev/null @@ -1,15 +0,0 @@ -import { lazy } from 'react'; - -import './vendor-react-runtime.jsx'; - -const screen = (load, name) => lazy(async () => ({ default: (await load())[name] })); - -export const AppsScreen = screen(() => import('../vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx'), 'AppsScreen'); -export const LoggingScreen = screen(() => import('../vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx'), 'LoggingScreen'); -export const NetworkScreen = screen(() => import('../vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx'), 'NetworkScreen'); -export const PromptsScreen = screen(() => import('../vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx'), 'PromptsScreen'); -export const ProtocolScreen = screen(() => import('../vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx'), 'ProtocolScreen'); -export const ResourcesScreen = screen(() => import('../vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx'), 'ResourcesScreen'); -export const ToolsScreen = screen(() => import('../vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx'), 'ToolsScreen'); -export { AppRenderer } from '../vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx'; -export { snapshotHostContext } from '../vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts'; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts b/packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts deleted file mode 100644 index 9f4e6d138..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './vendor-react-runtime.jsx'; -import '@mantine/core/styles.css'; - -import './inspector-session-adapter.css'; - -export * from './inspector-session-adapter.tsx'; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx b/packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx deleted file mode 100644 index 5ac711f40..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - -import type { McpBrowserSessionModel } from '../../mcp/mcp-session-model.ts'; -import type { McpSessionControllerRequest } from '../../mcp/mcp-session-controller.ts'; - -import { InspectorRuntimeEvidence, InspectorSessionAdapter } from './inspector-session-adapter-entry.ts'; - -const model = { - activeRequests: {}, - binding: { epochId: 'fixture-epoch', serverName: 'fixture', target: 'codex' }, - catalogs: { - prompts: [{ description: 'Fixture prompt', name: 'fixture-prompt' }], - resourceTemplates: [], - resources: [{ description: 'Fixture resource', mimeType: 'text/plain', name: 'fixture-resource', uri: 'fixture://resource' }], - tools: [{ description: 'Fixture tool', inputSchema: { properties: {}, type: 'object' }, name: 'fixture-tool' }], - }, - conciseTrace: [], - connection: { protocolVersion: '2026-06-01' }, - diagnostics: [], - logs: [], - phase: 'ready', - progress: [], - sessionId: 'fixture-session', - timeline: { - droppedThroughSequence: 0, - entries: [ - { direction: 'client', kind: 'frame', message: { id: 1, jsonrpc: '2.0', method: 'initialize' }, occurredAt: 1_700_000_000_001, sequence: 1 }, - { direction: 'server', kind: 'frame', message: { id: 1, jsonrpc: '2.0', result: { protocolVersion: '2026-06-01' } }, occurredAt: 1_700_000_000_002, sequence: 2 }, - { direction: 'client', kind: 'frame', message: { id: 2, jsonrpc: '2.0', method: 'tools/call', params: { name: 'fixture-tool' } }, occurredAt: 1_700_000_000_003, sequence: 3 }, - { direction: 'server', kind: 'frame', message: { id: 2, jsonrpc: '2.0', result: { content: [] } }, occurredAt: 1_700_000_000_004, sequence: 4 }, - { kind: 'logging', occurredAt: 1_700_000_000_005, payload: { data: 'Fixture connected', level: 'info' }, sequence: 5 }, - ], - lastSequence: 5, - }, -} as unknown as McpBrowserSessionModel; - -interface FixtureDeferred { - readonly promise: Promise; - readonly resolve: (value: unknown) => void; -} - -interface InspectorSessionAdapterFixtureHarness { - readonly resolveNextTool: (text: string) => void; - readonly setRuntimeBinding: (revision: number, definitionDigest: string) => void; -} - -declare global { - interface Window { - __inspectorSessionAdapterFixture?: InspectorSessionAdapterFixtureHarness; - } -} - -const deferred = (): FixtureDeferred => { - let resolve: (value: unknown) => void = () => undefined; - const promise = new Promise((next) => { resolve = next; }); - return Object.freeze({ promise, resolve }); -}; - -const runtimeModel = (sessionRevision: number, definitionDigest: string): McpBrowserSessionModel => ({ - ...model, - binding: { - binding: { - definitionDigest, - registryRevision: 1, - serverDigest: 'fixture-server-digest', - serverName: 'fixture', - sessionId: 'fixture-runtime-session', - sessionRevision, - target: 'portable', - transportDigest: 'fixture-transport-digest', - }, - kind: 'runtime', - }, -} as McpBrowserSessionModel); - -const pendingTools: FixtureDeferred[] = []; - -const controller = { - cancel: () => false, - invoke: (request: McpSessionControllerRequest): Promise => { - if (request.operation !== 'callTool') return Promise.resolve({ content: [] }); - const next = deferred(); - pendingTools.push(next); - return next.promise; - }, -}; - -let currentModel = model; -const root = createRoot(document.getElementById('root')!); - -const render = (): void => root.render( - - -); - -window.__inspectorSessionAdapterFixture = Object.freeze({ - resolveNextTool: (text: string): void => { - pendingTools.shift()?.resolve({ content: [{ text, type: 'text' }] }); - }, - setRuntimeBinding: (revision: number, definitionDigest: string): void => { - currentModel = runtimeModel(revision, definitionDigest); - render(); - }, -} satisfies InspectorSessionAdapterFixtureHarness); - -render(); diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts b/packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts deleted file mode 100644 index 980ec6f8d..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { McpSessionTraceEntry } from '../../../../agent-bundle/src/contracts/mcp-session.ts'; -import type { DevRuntimeDiagnostic, DevRuntimeInspectionEnvelope, DevRuntimeTraceSpan } from '../../../../agent-bundle/src/contracts/runtime.ts'; -import type { McpBrowserSessionModel, McpBrowserSessionTimelineEntry } from '../../mcp/mcp-session-model.ts'; - -export type InspectorTab = 'tools' | 'resources' | 'prompts' | 'protocol' | 'logging'; - -export interface InspectorProtocolEntry { - readonly direction: 'request' | 'response' | 'notification'; - readonly id: string; - readonly message: Readonly>; - readonly origin: 'client' | 'server'; - readonly sequence: number; - readonly timestamp: Date; -} - -export interface InspectorLogEntry { - readonly params: Readonly<{ readonly data: unknown; readonly level: string; readonly logger?: string }>; - readonly receivedAt: Date; - readonly sequence: number; -} - -export type InspectorRuntimeEvidenceInput = - | Readonly<{ readonly kind: 'protocol'; readonly protocol?: DevRuntimeInspectionEnvelope['protocol']; readonly trace: readonly DevRuntimeTraceSpan[] }> - | Readonly<{ readonly diagnostics: readonly DevRuntimeDiagnostic[]; readonly kind: 'diagnostics' }> - | Readonly<{ - /** Presentation-only span disclosure; details always render when absent. */ - readonly expansion?: Readonly<{ - readonly expandedIds: readonly string[]; - readonly onToggle: (spanId: string) => void; - }>; - readonly kind: 'trace'; - readonly trace: readonly DevRuntimeTraceSpan[]; - }>; - -type FrameTraceEntry = McpSessionTraceEntry & Readonly<{ - readonly direction: 'client' | 'server'; - readonly kind: 'frame'; - readonly message: unknown; -}>; - -type LoggingTraceEntry = McpSessionTraceEntry & Readonly<{ - readonly kind: 'logging'; - readonly payload: unknown; -}>; - -export const inspectorSessionTabs: readonly Readonly<{ readonly id: InspectorTab; readonly label: string }>[] = [ - { id: 'tools', label: 'Tools' }, - { id: 'resources', label: 'Resources' }, - { id: 'prompts', label: 'Prompts' }, - { id: 'protocol', label: 'Protocol' }, - { id: 'logging', label: 'Logging' }, -]; - -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const isFrame = (entry: McpBrowserSessionTimelineEntry): entry is FrameTraceEntry => - 'kind' in entry && entry.kind === 'frame'; - -const isLogging = (entry: McpBrowserSessionTimelineEntry): entry is LoggingTraceEntry => - 'kind' in entry && entry.kind === 'logging'; - -const jsonRpcDirection = (message: Readonly>): InspectorProtocolEntry['direction'] | undefined => { - const hasId = Object.hasOwn(message, 'id'); - const hasMethod = typeof message.method === 'string'; - if (hasId && hasMethod) return 'request'; - if (!hasId && hasMethod) return 'notification'; - if (hasId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))) return 'response'; - return undefined; -}; - -const logParams = (payload: unknown): InspectorLogEntry['params'] | undefined => { - if (!isRecord(payload) || typeof payload.level !== 'string' || !Object.hasOwn(payload, 'data')) return undefined; - return payload as InspectorLogEntry['params']; -}; - -export const inspectorSessionBindingKey = (binding: McpBrowserSessionModel['binding']): string => { - if (binding === undefined) return ''; - if ('kind' in binding) { - return binding.kind === 'runtime' - ? `runtime\u0000${binding.binding.sessionId}\u0000${binding.binding.sessionRevision}\u0000${binding.binding.target}\u0000${binding.binding.serverName}` - : ''; - } - return `${binding.epochId}\u0000${binding.target}\u0000${binding.serverName}`; -}; - -export const inspectorProtocolEntries = ( - timeline: readonly McpBrowserSessionTimelineEntry[], -): InspectorProtocolEntry[] => timeline.flatMap((entry) => { - if (!isFrame(entry) || !isRecord(entry.message)) return []; - const direction = jsonRpcDirection(entry.message); - if (direction === undefined) return []; - return [{ - direction, - id: `trace-${entry.sequence}`, - message: entry.message, - origin: entry.direction, - sequence: entry.sequence, - timestamp: new Date(entry.occurredAt), - }]; -}); - -export const inspectorLogEntries = ( - timeline: readonly McpBrowserSessionTimelineEntry[], -): InspectorLogEntry[] => timeline.flatMap((entry) => { - if (!isLogging(entry)) return []; - const params = logParams(entry.payload); - return params === undefined ? [] : [{ params, receivedAt: new Date(entry.occurredAt), sequence: entry.sequence }]; -}); diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts b/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts deleted file mode 100644 index 51ab63362..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { ComponentType } from 'react'; -import type { - CallToolResult, - GetPromptResult, - LoggingLevel, - Prompt, - Resource, - ResourceTemplateType as ResourceTemplate, - Tool, -} from '@modelcontextprotocol/client'; - -export type SortDirection = 'oldest-first' | 'newest-first'; - -export interface ListPaginationControlsProps { - readonly canLoadMore: boolean; - readonly loadedPages: number; - readonly onLoadMore: () => void; - readonly onPaginatedChange: (paginated: boolean) => void; - readonly paginated: boolean; -} - -export interface LogEntryData { - readonly params: Readonly<{ readonly data: unknown; readonly level: LoggingLevel; readonly logger?: string }>; - readonly receivedAt: Date; -} - -export interface ToolCallState { - readonly error?: string; - readonly result?: CallToolResult; - readonly status: 'idle' | 'pending' | 'ok' | 'error'; -} - -export interface ToolsUiState { - readonly formValues: Record; - readonly runAsTask: boolean; - readonly search: string; - readonly selectedToolName?: string; -} - -export interface ReadResourceState { - readonly error?: string; - readonly result?: unknown; - readonly status: 'idle' | 'pending' | 'ok' | 'error'; - readonly uri?: string; -} - -export interface ResourcesUiState { - readonly openSections?: string[]; - readonly originatingTemplateUri?: string; - readonly search: string; - readonly selectedResourceUri?: string; - readonly selectedTemplateUri?: string; -} - -export interface GetPromptState { - readonly error?: string; - readonly promptName?: string; - readonly result?: GetPromptResult; - readonly status: 'idle' | 'pending' | 'ok' | 'error'; -} - -export interface PromptsUiState { - readonly argumentValues: Record; - readonly search: string; - readonly selectedPromptName?: string; - readonly submittedFor?: string; -} - -export interface MessageEntry { - readonly direction: 'request' | 'response' | 'notification'; - readonly id: string; - readonly message: unknown; - readonly origin?: 'client' | 'server'; - readonly timestamp: Date; -} - -export interface ProtocolUiState { - readonly search: string; - readonly visibleDirections: Record<'client' | 'server', boolean>; -} - -export interface LogsUiState { - readonly filterText: string; - readonly visibleLevels: Record; -} - -export const ToolsScreen: ComponentType<{ - readonly callState?: ToolCallState; - readonly listChanged: boolean; - readonly onCallTool: (name: string, args: Record) => void; - readonly onCancelCall?: () => void; - readonly onClearResult?: () => void; - readonly onRefreshList: () => void; - readonly onUiChange: (next: ToolsUiState) => void; - readonly pagination: ListPaginationControlsProps; - readonly serverSupportsTaskToolCalls: boolean; - readonly tools: Tool[]; - readonly ui: ToolsUiState; -}>; -export const ResourcesScreen: ComponentType<{ - readonly compact: boolean; - readonly listChanged: boolean; - readonly onCompactChange: (next: boolean) => void; - readonly onReadResource: (uri: string) => void; - readonly onRefreshList: () => void; - readonly onSubscribeResource: (uri: string) => void; - readonly onUiChange: (next: ResourcesUiState) => void; - readonly onUnsubscribeResource: (uri: string) => void; - readonly pagination: ListPaginationControlsProps; - readonly readState?: ReadResourceState; - readonly resources: Resource[]; - readonly subscriptions: unknown[]; - readonly subscriptionsSupported?: boolean; - readonly templates: ResourceTemplate[]; - readonly ui: ResourcesUiState; -}>; -export const PromptsScreen: ComponentType<{ - readonly getPromptState?: GetPromptState; - readonly listChanged: boolean; - readonly onGetPrompt: (name: string, args: Record) => void; - readonly onRefreshList: () => void; - readonly onUiChange: (next: PromptsUiState) => void; - readonly pagination: ListPaginationControlsProps; - readonly prompts: Prompt[]; - readonly ui: PromptsUiState; -}>; -export const ProtocolScreen: ComponentType<{ - readonly compact: boolean; - readonly entries: MessageEntry[]; - readonly onClearAll: () => void; - readonly onClearSection: (section: 'pinned' | 'history') => void; - readonly onExport: () => void; - readonly onExportSection: (section: 'pinned' | 'history') => void; - readonly onReplay: (id: string) => void; - readonly onSortChange: (next: SortDirection) => void; - readonly onToggleCompact: () => void; - readonly onTogglePin: (id: string) => void; - readonly onUiChange: (next: ProtocolUiState) => void; - readonly pinnedIds: Set; - readonly sortDirection: SortDirection; - readonly ui: ProtocolUiState; -}>; -export const LoggingScreen: ComponentType<{ - readonly currentLevel: LoggingLevel; - readonly embedded?: boolean; - readonly entries: LogEntryData[]; - readonly onClear: () => void; - readonly onExport: () => void; - readonly onSetLevel: (level: LoggingLevel) => void; - readonly onSortChange: (next: SortDirection) => void; - readonly onUiChange: (next: LogsUiState) => void; - readonly sortDirection: SortDirection; - readonly ui: LogsUiState; -}>; -export const ALL_LEVELS_VISIBLE: Record; -export const clearScrollMemory: () => void; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js b/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js deleted file mode 100644 index 32dc826a5..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js +++ /dev/null @@ -1,11 +0,0 @@ -import './vendor-react-runtime.jsx'; - -export { - LoggingScreen, - PromptsScreen, - ProtocolScreen, - ResourcesScreen, - ToolsScreen, -} from './vendor-screens.jsx'; -export { ALL_LEVELS_VISIBLE } from '../vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts'; -export { clearScrollMemory } from '../vendor/clients/web/src/hooks/useScrollMemory.ts'; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter.css b/packages/workbench/src/inspector/adapter/inspector-session-adapter.css deleted file mode 100644 index be3b16a66..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter.css +++ /dev/null @@ -1,31 +0,0 @@ -.inspector-session-adapter { - min-width: 0; -} - -.inspector-runtime-evidence { - display: grid; - gap: 0.65rem; - min-width: 0; -} - -.inspector-runtime-evidence h3, -.inspector-runtime-evidence p { - margin: 0; -} - -.inspector-runtime-evidence ol { - display: grid; - gap: 0.5rem; - margin: 0; - padding-left: 1.25rem; -} - -.inspector-runtime-evidence li { - min-width: 0; - overflow-wrap: anywhere; -} - -.inspector-runtime-evidence pre { - max-width: 100%; - overflow: auto; -} diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx b/packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx deleted file mode 100644 index fec998aa1..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { createTheme, MantineProvider } from '@mantine/core'; -import type { - CallToolResult, - GetPromptResult, - LoggingLevel, - Prompt, - Resource, - ResourceTemplateType as ResourceTemplate, - Tool, -} from '@modelcontextprotocol/client'; -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import type { McpBrowserSessionModel, McpBrowserSessionTimelineEntry } from '../../mcp/mcp-session-model.ts'; -import type { McpSessionControllerRequest } from '../../mcp/mcp-session-controller.ts'; -import { McpProtocolEvidence } from '../../mcp/mcp-page.tsx'; -import { - ALL_LEVELS_VISIBLE, - clearScrollMemory, - LoggingScreen, - PromptsScreen, - ProtocolScreen, - ResourcesScreen, - ToolsScreen, - type GetPromptState, - type LogEntryData, - type LogsUiState, - type PromptsUiState, - type ProtocolUiState, - type ReadResourceState, - type ResourcesUiState, - type SortDirection, - type ToolCallState, - type ToolsUiState, -} from './inspector-session-adapter-vendor.js'; -import { - inspectorLogEntries, - inspectorProtocolEntries, - inspectorSessionBindingKey, - inspectorSessionTabs, - type InspectorRuntimeEvidenceInput, - type InspectorTab, -} from './inspector-session-adapter-model.ts'; - -export { - inspectorLogEntries, - inspectorProtocolEntries, - inspectorSessionBindingKey, - inspectorSessionTabs, - type InspectorRuntimeEvidenceInput, -} from './inspector-session-adapter-model.ts'; - -export interface InspectorSessionAdapterController { - cancel(id: string): boolean; - invoke(input: McpSessionControllerRequest): Promise; -} - -export interface InspectorSessionOperationAvailability { - readonly prompts: 'available' | 'not-routed'; - readonly resourceTemplates: 'available' | 'not-routed'; - readonly resources: 'available'; - readonly tools: 'available'; -} - -export interface InspectorSessionAdapterProps { - readonly availability?: InspectorSessionOperationAvailability; - readonly controller: InspectorSessionAdapterController; - readonly initialTab?: InspectorTab; - readonly model: McpBrowserSessionModel; - readonly onExportTrace?: (entries: readonly McpBrowserSessionTimelineEntry[]) => void; -} - -export interface InspectorRuntimeEvidenceProps { - readonly evidence: InspectorRuntimeEvidenceInput; -} - -const emptyPagination = { - canLoadMore: false, - loadedPages: 1, - onLoadMore: () => undefined, - onPaginatedChange: () => undefined, - paginated: false, -}; - -const initialToolsUi: ToolsUiState = { formValues: {}, runAsTask: false, search: '' }; -const initialResourcesUi: ResourcesUiState = { search: '' }; -const initialPromptsUi: PromptsUiState = { argumentValues: {}, search: '' }; -const initialProtocolUi: ProtocolUiState = { - search: '', - visibleDirections: { client: true, server: true }, -}; -const initialLogsUi: LogsUiState = { filterText: '', visibleLevels: ALL_LEVELS_VISIBLE }; - -const allOperationsAvailable: InspectorSessionOperationAvailability = Object.freeze({ - prompts: 'available', - resourceTemplates: 'available', - resources: 'available', - tools: 'available', -}); - -const availableTab = (tab: InspectorTab, availability: InspectorSessionOperationAvailability): InspectorTab => - tab === 'prompts' && availability.prompts === 'not-routed' ? 'tools' : tab; - -export const agentBundleInspectorTheme = createTheme({ - defaultRadius: 'sm', - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', - primaryColor: 'violet', -}); - -const operationError = (reason: unknown): string => reason instanceof Error ? reason.message : 'The Inspector operation failed.'; -const unsupportedLogLevelMessage = 'Log-level changes are unavailable because this session does not support logging/setLevel.'; - -export const InspectorRuntimeEvidence = ({ evidence }: InspectorRuntimeEvidenceProps): React.ReactNode => { - if (evidence.kind === 'protocol') return
- -
; - if (evidence.kind === 'diagnostics') return
-

Provider diagnostics

- {evidence.diagnostics.length === 0 ?

No provider diagnostics.

:
    {evidence.diagnostics.map((diagnostic, index) =>
  1. {diagnostic.phase} {diagnostic.severity} {diagnostic.code} {diagnostic.message}
  2. )}
} -
; - const expansion = evidence.expansion; - const expandedIds = expansion === undefined ? undefined : new Set(expansion.expandedIds); - return
-

Render trace

- {evidence.trace.length === 0 ?

No render evidence yet.

:
    {evidence.trace.map((span) => { - const expanded = expandedIds === undefined || expandedIds.has(span.id); - return
  1. - {span.phase} {span.status}{span.durationMs === undefined ? undefined : {span.durationMs} ms} - {span.details === undefined || expansion === undefined ? undefined : - } - {span.details === undefined || !expanded ? undefined :
    {JSON.stringify(span.details, null, 2)}
    } -
  2. ; - })}
} -
; -}; - -export const InspectorSessionAdapter = ({ availability = allOperationsAvailable, controller, initialTab = 'tools', model, onExportTrace }: InspectorSessionAdapterProps) => { - const bindingKey = inspectorSessionBindingKey(model.binding); - const previousBindingKey = useRef(bindingKey); - const lastResetBindingKey = useRef(bindingKey); - const requestNumber = useRef(0); - const actionGeneration = useRef(0); - const bindingChanged = previousBindingKey.current !== bindingKey; - if (bindingChanged) { - previousBindingKey.current = bindingKey; - actionGeneration.current += 1; - } - const [tab, setTab] = useState(() => availableTab(initialTab, availability)); - const [toolsUi, setToolsUi] = useState(initialToolsUi); - const [resourcesUi, setResourcesUi] = useState(initialResourcesUi); - const [promptsUi, setPromptsUi] = useState(initialPromptsUi); - const [protocolUi, setProtocolUi] = useState(initialProtocolUi); - const [logsUi, setLogsUi] = useState(initialLogsUi); - const [toolCall, setToolCall] = useState(); - const [toolRequestId, setToolRequestId] = useState(); - const [readResource, setReadResource] = useState(); - const [getPrompt, setGetPrompt] = useState(); - const [pinnedIds, setPinnedIds] = useState>(() => new Set()); - const [protocolCleared, setProtocolCleared] = useState(false); - const [loggingCleared, setLoggingCleared] = useState(false); - const [loggingDiagnostic, setLoggingDiagnostic] = useState(unsupportedLogLevelMessage); - const [sortDirection, setSortDirection] = useState('oldest-first'); - const [compact, setCompact] = useState(false); - const [protocolReplayUnavailable, setProtocolReplayUnavailable] = useState(false); - - useLayoutEffect(() => { - if (lastResetBindingKey.current === bindingKey) return; - lastResetBindingKey.current = bindingKey; - clearScrollMemory(); - setTab(availableTab(initialTab, availability)); - setToolsUi(initialToolsUi); - setResourcesUi(initialResourcesUi); - setPromptsUi(initialPromptsUi); - setProtocolUi(initialProtocolUi); - setLogsUi(initialLogsUi); - setToolCall(undefined); - setToolRequestId(undefined); - setReadResource(undefined); - setGetPrompt(undefined); - setPinnedIds(new Set()); - setProtocolCleared(false); - setLoggingCleared(false); - setLoggingDiagnostic(unsupportedLogLevelMessage); - setSortDirection('oldest-first'); - setCompact(false); - setProtocolReplayUnavailable(false); - }, [availability, bindingKey, initialTab]); - - const protocolEntries = useMemo(() => inspectorProtocolEntries(model.timeline.entries), [model.timeline.entries]); - const loggingEntries = useMemo(() => inspectorLogEntries(model.timeline.entries), [model.timeline.entries]); - const tools = useMemo(() => [...model.catalogs.tools] as unknown as Tool[], [model.catalogs.tools]); - const resources = useMemo(() => [...model.catalogs.resources] as unknown as Resource[], [model.catalogs.resources]); - const templates = useMemo(() => availability.resourceTemplates === 'available' - ? [...model.catalogs.resourceTemplates] as unknown as ResourceTemplate[] - : [], [availability.resourceTemplates, model.catalogs.resourceTemplates]); - const prompts = useMemo(() => [...model.catalogs.prompts] as unknown as Prompt[], [model.catalogs.prompts]); - const displayedProtocol = protocolCleared ? [] : protocolEntries; - const displayedLogs = loggingCleared ? [] : loggingEntries; - const exportedTimeline = useMemo(() => Object.freeze([...model.timeline.entries]), [model.timeline.entries]); - const currentTab = availableTab(tab, availability); - - const nextRequest = (operation: McpSessionControllerRequest['operation'], request: Readonly>): McpSessionControllerRequest => { - requestNumber.current += 1; - return { id: `inspector-${model.sessionId}-${requestNumber.current}`, operation, request }; - }; - - const run = (operation: McpSessionControllerRequest['operation'], request: Readonly>): Promise => - controller.invoke(nextRequest(operation, request)); - - const runTool = (name: string, args: Record): void => { - const generation = actionGeneration.current; - const request = nextRequest('callTool', { arguments: args, name }); - setToolRequestId(request.id); - setToolCall({ status: 'pending' }); - void controller.invoke(request).then((result) => { - if (generation === actionGeneration.current) setToolCall({ result: result as CallToolResult, status: 'ok' }); - }, (reason: unknown) => { - if (generation === actionGeneration.current) setToolCall({ error: operationError(reason), status: 'error' }); - }); - }; - - const runReadResource = (uri: string): void => { - const generation = actionGeneration.current; - setReadResource({ status: 'pending', uri }); - void run('readResource', { uri }).then((result) => { - if (generation === actionGeneration.current) setReadResource({ result: result as ReadResourceState['result'], status: 'ok', uri }); - }, (reason: unknown) => { - if (generation === actionGeneration.current) setReadResource({ error: operationError(reason), status: 'error', uri }); - }); - }; - - const runGetPrompt = (name: string, args: Record): void => { - const generation = actionGeneration.current; - setGetPrompt({ promptName: name, status: 'pending' }); - void run('getPrompt', { arguments: args, name }).then((result) => { - if (generation === actionGeneration.current) setGetPrompt({ promptName: name, result: result as GetPromptResult, status: 'ok' }); - }, (reason: unknown) => { - if (generation === actionGeneration.current) setGetPrompt({ error: operationError(reason), promptName: name, status: 'error' }); - }); - }; - - const refresh = (operation: McpSessionControllerRequest['operation']): void => { void run(operation, {}); }; - const negotiatedProtocol = model.connection?.protocolVersion ?? 'Not negotiated'; - - return -
-
-

Inspector

-

Negotiated protocol: {negotiatedProtocol}

- -
- {availability.prompts === 'not-routed' ?

Prompts are unavailable for this runtime session.

: undefined} - {currentTab === 'tools' ? { - if (toolRequestId !== undefined) controller.cancel(toolRequestId); - setToolCall(undefined); - setToolRequestId(undefined); - }} - onClearResult={() => { - setToolCall(undefined); - setToolRequestId(undefined); - }} - onRefreshList={() => refresh('listTools')} - onUiChange={setToolsUi} - pagination={emptyPagination} - serverSupportsTaskToolCalls={false} - tools={tools} - ui={toolsUi} - /> : undefined} - {currentTab === 'resources' ? <> - {availability.resourceTemplates === 'not-routed' ?

Resource templates are unavailable for this runtime session.

: undefined} - refresh('listResources')} - onSubscribeResource={() => undefined} - onUiChange={setResourcesUi} - onUnsubscribeResource={() => undefined} - pagination={emptyPagination} - readState={readResource} - resources={resources} - subscriptions={[]} - subscriptionsSupported={false} - templates={templates} - ui={resourcesUi} - /> - : undefined} - {currentTab === 'prompts' ? refresh('listPrompts')} - onUiChange={setPromptsUi} - pagination={emptyPagination} - prompts={prompts} - ui={promptsUi} - /> : undefined} - {currentTab === 'protocol' ? setProtocolCleared(true)} - onClearSection={(section) => section === 'history' ? setProtocolCleared(true) : setPinnedIds(new Set())} - onExport={() => onExportTrace?.(exportedTimeline)} - onExportSection={() => onExportTrace?.(exportedTimeline)} - onReplay={() => setProtocolReplayUnavailable(true)} - onSortChange={setSortDirection} - onToggleCompact={() => setCompact((value) => !value)} - onTogglePin={(id: string) => setPinnedIds((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - })} - onUiChange={setProtocolUi} - pinnedIds={pinnedIds} - sortDirection={sortDirection} - ui={protocolUi} - /> : undefined} - {protocolReplayUnavailable ?

Replay is unavailable for raw W13 trace frames.

: undefined} - {currentTab === 'logging' ?
-

{loggingDiagnostic}

- setLoggingCleared(true)} - onExport={() => onExportTrace?.(model.timeline.entries)} - onSetLevel={() => setLoggingDiagnostic(unsupportedLogLevelMessage)} - onSortChange={setSortDirection} - onUiChange={setLogsUi} - sortDirection={sortDirection} - ui={logsUi} - /> -
: undefined} -
-
; -}; diff --git a/packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx b/packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx deleted file mode 100644 index c4b5b3fc2..000000000 --- a/packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Badge, Button, Card, Code, Group, Stack, Text, TextInput, Title } from '@mantine/core'; -import { useMemo } from 'react'; - -import type { InspectorProtocolEntry } from './inspector-session-adapter-model.ts'; - -type SortDirection = 'oldest-first' | 'newest-first'; - -interface ProtocolUiState { - readonly search: string; - readonly visibleDirections: Readonly>; -} - -interface ProtocolScreenWithoutReplayProps { - readonly compact: boolean; - readonly entries: readonly InspectorProtocolEntry[]; - readonly onClearAll: () => void; - readonly onExport: () => void; - readonly onSortChange: (direction: SortDirection) => void; - readonly onToggleCompact: () => void; - readonly onTogglePin: (id: string) => void; - readonly onUiChange: (ui: ProtocolUiState) => void; - readonly pinnedIds: ReadonlySet; - readonly sortDirection: SortDirection; - readonly ui: ProtocolUiState; -} - -const frameName = (entry: InspectorProtocolEntry): string => - entry.direction === 'response' - ? `response:${String(entry.message.id)}` - : `${entry.direction}:${String(entry.message.method)}`; - -const matchesSearch = (entry: InspectorProtocolEntry, search: string): boolean => - search.length === 0 || JSON.stringify(entry.message).toLowerCase().includes(search.toLowerCase()); - -/** - * The embedded Inspector exposes a raw transport timeline but no replay-capable invocation binding. - * This narrow Protocol presentation retains every frame while intentionally - * omitting the vendored Replay action, which has no supported implementation. - */ -export const ProtocolScreenWithoutReplay = ({ - compact, - entries, - onClearAll, - onExport, - onSortChange, - onToggleCompact, - onTogglePin, - onUiChange, - pinnedIds, - sortDirection, - ui, -}: ProtocolScreenWithoutReplayProps) => { - const displayedEntries = useMemo(() => entries - .filter((entry) => ui.visibleDirections[entry.origin] && matchesSearch(entry, ui.search)) - .sort((left, right) => sortDirection === 'oldest-first' - ? left.sequence - right.sequence - : right.sequence - left.sequence), [entries, sortDirection, ui.search, ui.visibleDirections]); - - return
- - Messages - - - - - - - - onUiChange({ ...ui, search: event.currentTarget.value })} - placeholder="Search raw JSON-RPC frames" - value={ui.search} - /> - {displayedEntries.length === 0 ? No request history : - {displayedEntries.map((entry) => - - - {entry.timestamp.toISOString()} - {entry.origin} - {entry.direction} - #{entry.sequence} - - - - {JSON.stringify(entry.message)} - )} - } -
; -}; diff --git a/packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts b/packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx b/packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx deleted file mode 100644 index c03ce7f36..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; - -// The vendored Inspector source was authored for the classic JSX runtime. -// Its modules are intentionally byte-for-byte preserved, so establish the -// compatibility global before their screen modules evaluate. -globalThis.React = React; diff --git a/packages/workbench/src/inspector/adapter/vendor-screens.d.ts b/packages/workbench/src/inspector/adapter/vendor-screens.d.ts deleted file mode 100644 index 010399035..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-screens.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ComponentType } from 'react'; - -export const LoggingScreen: ComponentType>; -export const PromptsScreen: ComponentType>; -export const ProtocolScreen: ComponentType>; -export const ResourcesScreen: ComponentType>; -export const ToolsScreen: ComponentType>; diff --git a/packages/workbench/src/inspector/adapter/vendor-screens.jsx b/packages/workbench/src/inspector/adapter/vendor-screens.jsx deleted file mode 100644 index 1ce781263..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-screens.jsx +++ /dev/null @@ -1,5 +0,0 @@ -export { LoggingScreen } from '../vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx'; -export { PromptsScreen } from '../vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx'; -export { ProtocolScreenWithoutReplay as ProtocolScreen } from './protocol-screen-without-replay.tsx'; -export { ResourcesScreen } from '../vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx'; -export { ToolsScreen } from '../vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx'; diff --git a/packages/workbench/src/inspector/package.json b/packages/workbench/src/inspector/package.json deleted file mode 100644 index 2c1f6dadb..000000000 --- a/packages/workbench/src/inspector/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@inspector/core", - "version": "0.0.0", - "private": true, - "description": "Links the vendored MCP Inspector core so `@inspector/core/*` specifiers resolve through the package manager instead of per-config aliases. Lives beside UPSTREAM.json rather than inside vendor/, which stays a byte-exact provenance snapshot.", - "type": "module", - "exports": { - "./*.js": "./vendor/core/*.ts", - "./*": "./vendor/core/*" - } -} diff --git a/packages/workbench/src/inspector/patches/.gitkeep b/packages/workbench/src/inspector/patches/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/packages/workbench/src/inspector/patches/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch b/packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch deleted file mode 100644 index c10768d5a..000000000 --- a/packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch +++ /dev/null @@ -1,9 +0,0 @@ -diff --git a/clients/web/src/utils/inspectorTabs.test.ts b/clients/web/src/utils/inspectorTabs.test.ts ---- a/clients/web/src/utils/inspectorTabs.test.ts -+++ b/clients/web/src/utils/inspectorTabs.test.ts -@@ -1,4 +1,4 @@ --import { describe, it, expect } from "vitest"; -+import { describe, it, expect } from "@rstest/core"; - import { - INSPECTOR_SERVERS_TAB, - INSPECTOR_TAB_IDS, diff --git a/packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch b/packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch deleted file mode 100644 index 0069369f8..000000000 --- a/packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch +++ /dev/null @@ -1,32 +0,0 @@ -diff --git a/core/mcp/types.ts b/core/mcp/types.ts ---- a/core/mcp/types.ts -+++ b/core/mcp/types.ts -@@ -49,8 +48,0 @@ export interface StdioServerConfig { --// SSE transport config --export interface SseServerConfig { -- type: "sse"; -- url: string; -- eventSourceInit?: Record; -- requestInit?: Record; --} -- -@@ -66 +57,0 @@ export type MCPServerConfig = -- | SseServerConfig -@@ -69 +60 @@ export type MCPServerConfig = --export type ServerType = "stdio" | "sse" | "streamable-http"; -+export type ServerType = "stdio" | "streamable-http"; -@@ -94 +85 @@ export type StoredMCPServer = MCPServerConfig & { -- * HTTP headers for SSE / streamable-http transports. Persisted as a flat -+ * HTTP headers for Streamable HTTP transports. Persisted as a flat -@@ -766 +757 @@ export interface CreateTransportOptions { -- * (SSE, streamable-http). Enables proxy fetch in browser (CORS bypass). -+ * (Streamable HTTP). Enables proxy fetch in browser (CORS bypass). -@@ -781 +772 @@ export interface CreateTransportOptions { -- * Optional callback to track HTTP fetch requests (for SSE and streamable-http transports). -+ * Optional callback to track HTTP fetch requests for Streamable HTTP transports. -@@ -795 +786 @@ export interface CreateTransportOptions { -- * Optional OAuth client provider for Bearer authentication (SSE, streamable-http). -+ * Optional OAuth client provider for Streamable HTTP Bearer authentication. -@@ -802 +793 @@ export interface CreateTransportOptions { -- * HTTP headers (settings.headers) for SSE / streamable-http transports. -+ * HTTP headers (settings.headers) for Streamable HTTP transports. diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx deleted file mode 100644 index d5cfc8fb3..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { Role } from "@modelcontextprotocol/client"; -import { filledBadgeColor } from "../filledBadgeColor"; - -export type AnnotationFacet = - | "audience" - | "priority" - | "readOnlyHint" - | "destructiveHint" - | "idempotentHint" - | "openWorldHint" - | "longRunHint"; - -export interface AnnotationBadgeProps { - facet: AnnotationFacet; - value: Role[] | number | boolean; -} - -const colorMap: Record = { - audience: "blue", - priority: "orange", - readOnlyHint: "green", - destructiveHint: "red", - idempotentHint: "teal", - openWorldHint: "grape", - longRunHint: "yellow", -}; - -const FilledBadge = Badge.withProps({ - variant: "filled", - fw: 500, - autoContrast: true, -}); - -function formatLabel( - facet: AnnotationFacet, - value: Role[] | number | boolean, -): string { - switch (facet) { - case "audience": - return `audience: ${(value as Role[]).join(", ")}`; - case "priority": { - const n = value as number; - if (n >= 0.7) return "priority: high"; - if (n >= 0.4) return "priority: medium"; - return "priority: low"; - } - case "readOnlyHint": - return "read-only"; - case "destructiveHint": - return "destructive"; - case "idempotentHint": - return "idempotent"; - case "openWorldHint": - return "open-world"; - case "longRunHint": - return "long-running"; - } -} - -export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) { - const color = filledBadgeColor(colorMap[facet]); - // `autoContrast` picks black or white text per the fill's luminance in each - // scheme, so the label stays legible (WCAG AA) on both the lighter light-mode - // fills and the darker dark-mode `-filled` shades — unlike a fixed - // scheme→black/white mapping, which inverted the contrast in dark mode. - // Amber fills are pinned to shade 5 first (see `filledBadgeColor`). - return {formatLabel(facet, value)}; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx deleted file mode 100644 index 19eb85838..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { Box } from "@mantine/core"; -import { - useCallback, - useEffect, - useImperativeHandle, - useRef, - type Ref, - type RefObject, -} from "react"; -import type { - AppBridge, - AppBridgeEventMap, - McpUiDisplayMode, - McpUiMessageRequest, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { - CallToolResult, - LoggingMessageNotification, - Tool, -} from "@modelcontextprotocol/client"; -import { - currentStyles, - currentTheme, - measureContainerDimensions, -} from "./hostContext"; - -/** - * Constructs the `AppBridge` for a freshly mounted sandbox iframe. Wrap with - * `useCallback` (or hoist out of render) — the renderer treats a new factory - * identity as a signal to tear down the current bridge and rebuild, so an - * unstable factory will thrash the iframe on every render. - */ -export type BridgeFactory = ( - iframe: HTMLIFrameElement, - tool: Tool, -) => AppBridge | Promise; - -export interface AppRendererHandle { - sendToolInput(args: Record): Promise; - sendToolResult(result: CallToolResult): Promise; - sendToolCancelled(reason: string): Promise; - teardown(): Promise; -} - -/** - * High-level lifecycle of a running app, surfaced so a host (or an automated - * driver polling a `data-app-status` attribute) can wait for the right moment: - * `loading` while the bridge is being built and the view's `ui/initialize` - * handshake is in flight; `ready` once the view has fired - * `notifications/initialized`; `error` when the bridge factory throws or - * rejects (no live view to wait on). - */ -export type AppRendererStatus = "loading" | "ready" | "error"; - -export interface AppRendererProps { - sandboxPath: string; - tool: Tool; - bridgeFactory: BridgeFactory; - onError?: (err: Error) => void; - /** - * Reports the renderer's high-level lifecycle (see {@link AppRendererStatus}). - * Fires `loading` at the start of every (re)build, `ready` when the view - * signals `initialized`, and `error` on a factory throw/rejection. - */ - onAppStatusChange?: (status: AppRendererStatus) => void; - /** - * Called when the running view reports a new rendered content size via - * `ui/notifications/size-changed` (typically driven by its `ResizeObserver`). - * Width and height (px) are both optional. The host uses this to resize the - * iframe's container so the widget is neither clipped nor padded with dead - * space. - */ - onSizeChange?: (size: AppBridgeEventMap["sizechange"]) => void; - /** - * Current host display mode for the app frame. Pushed to the running view - * via `host-context-changed` whenever it changes (e.g. Maximize/Restore), so - * an app can adapt its layout to inline vs fullscreen. - */ - displayMode?: McpUiDisplayMode; - /** - * Handles a view-originated `ui/request-display-mode`. Return the mode the - * host actually applied — the spec lets the host decline an unsupported mode - * by returning its current one. - */ - onRequestDisplayMode?: (requested: McpUiDisplayMode) => McpUiDisplayMode; - /** - * Called when the running view submits a user-role message via - * `ui/message`. The renderer returns the spec-required empty result on - * the host's behalf, so the callback is fire-and-forget. - */ - onMessage?: (params: McpUiMessageRequest["params"]) => void; - /** - * Called for each MCP log notification (`notifications/message`) the - * running view emits. Backs the advertised `logging` host capability. - */ - onLog?: (params: LoggingMessageNotification["params"]) => void; - /** - * Ordered tool-input fragments to replay via - * `ui/notifications/tool-input-partial` BEFORE the complete `tool-input`, - * exercising widgets that render progressively. Captured at bridge-build - * time (see `pendingPartialsRef`) so prop churn never rebuilds the iframe. - * Nothing is sent when omitted/empty. - */ - partialInputs?: Record[]; - /** - * The host-controlled box the app renders within, used to derive - * `hostContext.containerDimensions`. This MUST be an element whose size is - * driven by the host's layout (window resize, sidebar toggle, maximize) and - * NOT by the view's own `size-changed` reports — otherwise the two signals - * couple into a feedback loop. Falls back to the iframe element when omitted. - */ - containerRef?: RefObject; - ref?: Ref; -} - -function toError(err: unknown): Error { - return err instanceof Error ? err : new Error(String(err)); -} - -async function disposeBridge(bridge: AppBridge): Promise { - // Best-effort: still close the transport even if teardownResource fails, - // otherwise the iframe unmount would leak MessagePort listeners. - try { - await bridge.teardownResource({}); - } catch { - /* swallow — closing transport below is the load-bearing step */ - } - try { - await bridge.close(); - } catch { - /* swallow — already disposing */ - } -} - -/** - * Bridge lifecycle (the interlocking refs below): - * - * mount ─▶ build (buildId++) ─▶ factory(iframe,tool) ─async─▶ bridgeRef set - * │ on "initialized" - * ▼ → flushPending - * cleanup ─▶ scheduleDispose() ──microtask──▶ dispose (unless cancelled) - * ▲ │ - * └── re-setup with SAME inputs ─────┘ cancel + REUSE bridge - * - * - `buildId` (monotonic): a bridge resolved from an older build self-disposes. - * - `disposeScheduled`: a dispose is queued (microtask); a synchronous re-setup - * (StrictMode double-invoke, or a transient re-render) cancels it and reuses - * the live bridge instead of rebuilding (rebuild double-loads the sandbox and - * races the app handshake). A re-setup with CHANGED inputs disposes + rebuilds. - * - `lastDeps`: distinguishes "same inputs → reuse" from "changed → rebuild". - * - `initialized`: gates flushing buffered input/result until the view is ready. - * - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open. - * - `teardownStarted`: makes the imperative teardown() idempotent vs unmount. - */ -export function AppRenderer({ - sandboxPath, - tool, - bridgeFactory, - onError, - onAppStatusChange, - onSizeChange, - displayMode, - onRequestDisplayMode, - onMessage, - onLog, - partialInputs, - containerRef, - ref, -}: AppRendererProps) { - const iframeRef = useRef(null); - const bridgeRef = useRef(null); - const initializedRef = useRef(false); - const pendingPartialsRef = useRef[]>([]); - const pendingInputRef = useRef | null>(null); - const pendingResultRef = useRef(null); - const teardownStartedRef = useRef(false); - // Bridge-lifecycle bookkeeping for the deferred-dispose / reuse dance that - // keeps a single bridge alive across React StrictMode's dev-only - // setup→cleanup→setup double-invoke (see the build effect below). - const buildIdRef = useRef(0); - const disposeScheduledRef = useRef(false); - const lastDepsRef = useRef<{ - bridgeFactory: BridgeFactory; - sandboxPath: string; - tool: Tool; - } | null>(null); - const onErrorRef = useRef(onError); - const onAppStatusChangeRef = useRef(onAppStatusChange); - const onSizeChangeRef = useRef(onSizeChange); - const displayModeRef = useRef(displayMode); - const onRequestDisplayModeRef = useRef(onRequestDisplayMode); - const onMessageRef = useRef(onMessage); - const onLogRef = useRef(onLog); - const partialInputsRef = useRef(partialInputs); - useEffect(() => { - onErrorRef.current = onError; - onAppStatusChangeRef.current = onAppStatusChange; - onSizeChangeRef.current = onSizeChange; - displayModeRef.current = displayMode; - onRequestDisplayModeRef.current = onRequestDisplayMode; - onMessageRef.current = onMessage; - onLogRef.current = onLog; - partialInputsRef.current = partialInputs; - }); - - // Flush buffered tool input/result to the view, but only once the bridge - // exists AND the view has signalled `initialized`. The spec requires tool - // input/result to arrive after initialization, yet a host-initiated open - // (the Open App click) fires before the iframe's app has loaded — so we - // buffer the latest values and release them when the view is ready. Input is - // always sent before result. - const flushPending = useCallback(() => { - const bridge = bridgeRef.current; - if (!bridge || !initializedRef.current) return; - // Partial-input fragments first, in staged order, BEFORE the complete - // tool-input — the spec requires partials to precede the final input. - for (const args of pendingPartialsRef.current) { - void bridge.sendToolInputPartial({ arguments: args }); - } - pendingPartialsRef.current = []; - if (pendingInputRef.current !== null) { - const args = pendingInputRef.current; - pendingInputRef.current = null; - void bridge.sendToolInput({ arguments: args }); - } - if (pendingResultRef.current !== null) { - const result = pendingResultRef.current; - pendingResultRef.current = null; - // ext-apps' AppBridge peers on SDK v1's CallToolResult (whose - // `structuredContent` is typed narrower than v2's). Runtime-compatible; - // cast at this boundary. TODO: drop when ext-apps#702 ships a v2 peer. - void bridge.sendToolResult( - result as Parameters[0], - ); - } - }, []); - - // Dispose the live bridge, but deferred to a microtask. React StrictMode runs - // effects setup→cleanup→setup synchronously in dev; deferring lets the - // re-setup cancel the disposal and keep the SAME bridge, instead of tearing - // it down and rebuilding. A rebuild here spins up a second transport that - // re-posts sandbox-resource-ready (the sandbox loads the app twice) and - // races the app's ui/initialize handshake — which is what left apps stuck on - // an empty shell ("handshake timed out") in dev. - const scheduleDispose = useCallback(() => { - disposeScheduledRef.current = true; - queueMicrotask(() => { - if (!disposeScheduledRef.current) return; // cancelled by a re-setup - disposeScheduledRef.current = false; - // Invalidate any in-flight factory so a late-resolving bridge disposes - // itself instead of attaching to a torn-down component. - buildIdRef.current++; - const bridge = bridgeRef.current; - bridgeRef.current = null; - initializedRef.current = false; - lastDepsRef.current = null; - pendingPartialsRef.current = []; - if (bridge) void disposeBridge(bridge); - }); - }, []); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - - const prev = lastDepsRef.current; - const sameInputs = - prev !== null && - prev.bridgeFactory === bridgeFactory && - prev.sandboxPath === sandboxPath && - prev.tool === tool; - - // A disposal scheduled by the immediately-preceding cleanup means we are in - // a synchronous re-setup. If the inputs are identical (StrictMode's - // double-invoke, or a transient re-render) keep the live bridge: cancel the - // disposal and re-deliver any buffered input/result to it. - // This reuse path provably runs under React StrictMode's synchronous - // setup→cleanup→setup double-invoke (the "builds a single bridge… StrictMode" - // test proves the bridge is reused, not rebuilt — factory called once). v8 - // cannot attribute coverage to the body of an effect that React replays for - // the StrictMode dev-only double-invoke, so the branch + its three - // statements read as uncovered despite executing. - /* v8 ignore next 4 */ - if (disposeScheduledRef.current && sameInputs) { - disposeScheduledRef.current = false; - flushPending(); - return scheduleDispose; - } - - // Otherwise this is a real (re)build. If a disposal was pending (inputs - // changed), run it synchronously before building the replacement. - if (disposeScheduledRef.current) { - disposeScheduledRef.current = false; - buildIdRef.current++; - const old = bridgeRef.current; - bridgeRef.current = null; - initializedRef.current = false; - if (old) void disposeBridge(old); - } - - lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; - const buildId = ++buildIdRef.current; - teardownStartedRef.current = false; - initializedRef.current = false; - onAppStatusChangeRef.current?.("loading"); - // Snapshot the staged partial-input fragments for THIS bridge build (read - // via the ref so the prop is not a dep — adding/removing fragments must not - // rebuild the iframe). The StrictMode reuse path above returned before - // reaching here, so a reused bridge keeps the queue it was built with. - pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; - - let pending: Promise; - try { - pending = Promise.resolve(bridgeFactory(iframe, tool)); - } catch (err) { - onAppStatusChangeRef.current?.("error"); - onErrorRef.current?.(toError(err)); - return scheduleDispose; - } - - pending - .then((bridge) => { - if (buildIdRef.current !== buildId) { - void disposeBridge(bridge); - return; - } - bridgeRef.current = bridge; - // Registered before the inner app can finish loading (which only - // happens after the sandbox-resource-ready round-trip the factory - // drives), so the view's `initialized` signal is never missed. - bridge.addEventListener("initialized", () => { - initializedRef.current = true; - onAppStatusChangeRef.current?.("ready"); - // The factory already seeded theme/styles/displayMode into the - // handshake hostContext; the observers below cover any subsequent - // changes. Only containerDimensions can plausibly differ between - // bridge construction and initialization (layout settles), so push - // that one field now via the SDK's partial-change notification. - const container = containerRef?.current ?? iframeRef.current; - const containerDimensions = container - ? measureContainerDimensions(container) - : undefined; - if (containerDimensions) { - void bridge.sendHostContextChange({ containerDimensions }); - } - flushPending(); - }); - // Forward the view's content-size reports (ui/notifications/size-changed) - // so the host can resize the iframe container to fit the rendered widget. - bridge.addEventListener("sizechange", (size) => { - onSizeChangeRef.current?.(size); - }); - // Forward the view's MCP log notifications so the host can honor the - // advertised `logging` capability instead of dropping them. - bridge.addEventListener("loggingmessage", (params) => { - onLogRef.current?.(params); - }); - // Handle ui/request-display-mode: let the host (AppsScreen) decide what - // mode to actually apply and return that. With no handler the request is - // declined by returning the current host-side mode. - bridge.onrequestdisplaymode = async ({ mode }) => { - const handler = onRequestDisplayModeRef.current; - const applied = handler - ? handler(mode) - : (displayModeRef.current ?? "inline"); - return { mode: applied }; - }; - // Handle ui/message: surface the submitted content and return the - // spec-required empty result. With no handler the submission is - // declined by returning isError. - bridge.onmessage = async (params) => { - const handler = onMessageRef.current; - if (!handler) return { isError: true }; - handler(params); - return {}; - }; - flushPending(); - }) - .catch((err) => { - if (buildIdRef.current !== buildId) return; - onAppStatusChangeRef.current?.("error"); - onErrorRef.current?.(toError(err)); - }); - - return scheduleDispose; - // `containerRef` is listed for exhaustive-deps completeness, but a change to - // its identity does NOT force a rebuild: the `sameInputs` check above - // ignores it, so a new ref object hits the StrictMode reuse path (the - // `initialized` handler reads `containerRef?.current` lazily, so the live - // ref is always used regardless). The other deps are the real rebuild keys. - }, [ - bridgeFactory, - sandboxPath, - tool, - containerRef, - flushPending, - scheduleDispose, - ]); - - // Push live host-context changes to the running view as discrete partial - // updates via AppBridge.sendHostContextChange (the SDK's - // ui/notifications/host-context-changed sender). Each effect observes one - // host signal and sends only the field(s) it owns, so the view receives the - // spec's "only changed fields" partials without any host-side snapshot - // bookkeeping. Reading `bridgeRef.current` at callback time (not capturing a - // bridge) means the observers always target the live bridge, even though it - // resolves asynchronously after these effects run. - - // Theme + styles: Mantine writes the resolved scheme to - // ``; observe that attribute and forward - // changes through the live bridge. Gated on the view's `initialized` signal - // — like the container and displayMode pushes below — so a theme flip in the - // window between bridge construction and the handshake doesn't race - // `ui/initialize`. Nothing is lost by waiting: the factory seeds the - // construction-time theme/styles into the handshake hostContext, and the - // first post-init flip carries the current value. - useEffect(() => { - /* v8 ignore next 5 -- SSR/non-DOM guard: MutationObserver and document are - always defined under happy-dom, so this early return is unreachable in - the test environment. */ - if ( - typeof MutationObserver === "undefined" || - typeof document === "undefined" - ) { - return; - } - const observer = new MutationObserver(() => { - if (!initializedRef.current) return; - const styles = currentStyles(); - void bridgeRef.current?.sendHostContextChange({ - theme: currentTheme(), - ...(styles ? { styles } : {}), - }); - }); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-mantine-color-scheme"], - }); - return () => observer.disconnect(); - }, []); - - // Container size: observes the host-controlled container (or the iframe as a - // fallback) — NOT an element whose height is driven by the view's own - // size-changed reports, which would couple the two signals into a feedback - // loop. Gated on the view's `initialized` signal so the notification only - // fires once the handshake is complete; a 0×0 (not-yet-laid-out) measurement - // and a value-equal repeat are both skipped. - useEffect(() => { - const target = containerRef?.current ?? iframeRef.current; - /* v8 ignore next -- SSR/non-DOM guard: ResizeObserver is stubbed/defined - and the iframe (or containerRef) target is always present after mount in - tests, so neither disjunct is reachable here. */ - if (typeof ResizeObserver === "undefined" || !target) return; - let last: { width: number; height: number } | undefined; - const observer = new ResizeObserver(() => { - if (!initializedRef.current) return; - const next = measureContainerDimensions(target); - if (!next) return; - if (last && last.width === next.width && last.height === next.height) { - return; - } - last = next; - void bridgeRef.current?.sendHostContextChange({ - containerDimensions: next, - }); - }); - observer.observe(target); - return () => observer.disconnect(); - }, [containerRef]); - - // Display mode: pushes whenever the prop changes (Maximize/Restore). Gated on - // `initialized` for the same reason as the other host-context pushes. - useEffect(() => { - if (displayMode === undefined) return; - if (!initializedRef.current) return; - void bridgeRef.current?.sendHostContextChange({ displayMode }); - }, [displayMode]); - - useImperativeHandle( - ref, - () => ({ - async sendToolInput(args) { - // Buffered (latest-wins) and released by flushPending once the view is - // initialized — the handle may be invoked before the bridge resolves. - pendingInputRef.current = args; - flushPending(); - }, - async sendToolResult(result) { - pendingResultRef.current = result; - flushPending(); - }, - async sendToolCancelled(reason) { - const bridge = bridgeRef.current; - if (!bridge) return; - await bridge.sendToolCancelled({ reason }); - }, - async teardown() { - const bridge = bridgeRef.current; - if (!bridge || teardownStartedRef.current) return; - teardownStartedRef.current = true; - // Null the ref synchronously so a concurrent unmount cleanup cannot - // see a still-live bridge and dispose it a second time. Bumping the - // build id makes any in-flight factory self-dispose, and clearing the - // pending-dispose flag/cached deps prevents the deferred dispose from - // acting on an already torn-down bridge. - buildIdRef.current++; - disposeScheduledRef.current = false; - lastDepsRef.current = null; - bridgeRef.current = null; - initializedRef.current = false; - pendingInputRef.current = null; - pendingResultRef.current = null; - await disposeBridge(bridge); - }, - }), - [flushPending], - ); - - // The iframe deliberately has no `sandbox` attribute: `sandboxPath` resolves - // to the inspector's own bundled sandbox-proxy page (trusted, same-origin), - // which then loads the untrusted MCP App content into a nested sandboxed - // iframe. Sandboxing this outer frame would block the postMessage bridge - // that `AppBridge` relies on. - return ( - // Box+iframe is a native element (not a Mantine primitive), so the - // `.withProps()` extraction rule doesn't apply. - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts deleted file mode 100644 index 9294c5146..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - AppBridge, - PostMessageTransport, - getToolUiResourceUri, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { - McpUiDisplayMode, - McpUiHostCapabilities, - McpUiResourceMeta, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Client } from "@modelcontextprotocol/client"; -import type { - EmbeddedResource, - Implementation, - ReadResourceResult, - ResourceLink, -} from "@modelcontextprotocol/client"; -import { - approveCspSources, - buildSandboxCspPolicy, - wrapSandboxedHtml, -} from "../../../utils/sandbox-csp"; -import { - downloadBlob, - fileNameFromUri, - isHttpUrl, -} from "../../../lib/downloadFile"; -import { snapshotHostContext } from "./hostContext"; -import type { BridgeFactory } from "./AppRenderer"; - -/** - * Host identity advertised to MCP Apps during the bridge handshake. Static — - * the value is informational (shown by some apps), not a protocol version. - */ -export const HOST_INFO: Implementation = { - name: "MCP Inspector", - version: "2.0.0", -}; - -/** - * Capabilities the inspector host offers a running MCP App. Constructed WITH an - * MCP client (see {@link createAppBridgeFactory}), so the bridge auto-forwards - * tools/resources/prompts to the view; we only declare the host-side features - * we actually back: external links and file downloads (both handled below), - * tool/resource list-change forwarding, and logging passthrough. - */ -export const HOST_CAPABILITIES: McpUiHostCapabilities = { - openLinks: {}, - downloadFile: {}, - serverTools: { listChanged: true }, - serverResources: { listChanged: true }, - logging: {}, -}; - -/** - * Display modes the inspector host supports, advertised in the handshake - * hostContext (`availableDisplayModes`). AppsScreen renders an app either - * inline within its layout card or maximized to fill the screen, so only those - * two are offered. - */ -export const HOST_AVAILABLE_DISPLAY_MODES: readonly McpUiDisplayMode[] = [ - "inline", - "fullscreen", -]; - -export interface AppBridgeFactoryDeps { - /** The connected SDK client to back the bridge, or null when disconnected. */ - getClient: () => Client | null; - /** Reads a UI resource (resources/read) and returns the SDK result. */ - readResource: (uri: string) => Promise; - /** - * Called when reading or posting the UI resource fails after the sandbox - * proxy is ready. Without this the user is left staring at a blank-but-live - * frame; the error is also always console.error'd. - */ - onResourceError?: (err: Error) => void; -} - -/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */ -function extractHtmlAndMeta(result: ReadResourceResult): { - html: string; - meta: McpUiResourceMeta | undefined; -} { - for (const content of result.contents) { - const text = (content as { text?: unknown }).text; - if (typeof text === "string") { - return { - html: text, - meta: content._meta as McpUiResourceMeta | undefined, - }; - } - } - throw new Error("UI resource has no text (HTML) content"); -} - -/** - * Decode a base64-encoded blob resource into bytes for download. Allocates the - * backing store explicitly so the return type is `Uint8Array` - * (Blob accepts `ArrayBufferView`, not the wider - * `ArrayBufferLike`). - */ -function base64ToBytes(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(new ArrayBuffer(binary.length)); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -/** - * Upper bound on items honored from a single `ui/download-file` request. One - * user approval must not fan out into an unbounded number of saves / new tabs. - */ -const MAX_DOWNLOAD_ITEMS = 20; - -/** - * Strip control characters and clamp length so a server-supplied filename or - * URI cannot forge additional lines in the confirmation prompt or push the - * real summary off-screen. - */ -function sanitizeDownloadLabel(label: string): string { - // Cc = control chars (newlines, escape, etc.); Cf = format chars (bidi - // overrides, zero-width joiners, BOM) — both can spoof or reflow the prompt. - const cleaned = label.replace(/[\p{Cc}\p{Cf}]+/gu, " ").trim(); - // Keep the START of an over-long label: for a link that preserves the - // scheme+host, which is what the user needs to make a trust decision. - return cleaned.length > 80 ? cleaned.slice(0, 77) + "..." : cleaned; -} - -/** - * Human-readable label for a download item, shown in the confirmation prompt. - * `forPrompt` marks a resource_link with a leading "↗" so the user can tell a - * link that will *open in a tab* apart from an embedded file that will *save to - * disk* — the two item kinds share this "download" confirmation. - */ -function describeDownloadItem( - item: EmbeddedResource | ResourceLink, - forPrompt = false, -): string { - if (item.type === "resource_link") { - return forPrompt ? `↗ ${item.uri}` : item.uri; - } - return fileNameFromUri(item.resource.uri); -} - -/** - * Trigger a browser download for a single MCP resource item. Inline - * {@link EmbeddedResource}s (text or base64 blob) are written via - * {@link downloadBlob}. A {@link ResourceLink} is *opened* in a new tab — - * the inspector does not fetch the URL to disk itself, since the link may - * require auth or content negotiation the browser can supply but we cannot. - * Returns false when the item carries nothing downloadable or its URI is - * rejected by the http(s)-only allowlist. - */ -function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean { - if (item.type === "resource_link") { - const parsed = isHttpUrl(item.uri); - if (!parsed) return false; - window.open(parsed.href, "_blank", "noopener,noreferrer"); - return true; - } - const resource = item.resource; - // The types forbid it, but the payload is untrusted: a resource with neither - // `blob` nor a string `text` has nothing to save. Skip it (like a rejected - // link) rather than writing a file containing the literal text "undefined". - if (!("blob" in resource) && typeof resource.text !== "string") return false; - const blob = - "blob" in resource - ? new Blob([base64ToBytes(resource.blob)], { - type: resource.mimeType ?? "application/octet-stream", - }) - : new Blob([resource.text], { type: resource.mimeType ?? "text/plain" }); - downloadBlob(fileNameFromUri(resource.uri), blob); - return true; -} - -/** - * Builds the {@link BridgeFactory} the AppRenderer uses to bring a sandbox - * iframe to life. For each mounted iframe + tool it: - * - * 1. constructs a host-side {@link AppBridge} over the SDK client (so the view - * can call tools/resources/prompts directly), - * 2. on the sandbox proxy's `sandboxready` signal, reads the tool's UI - * resource and pushes its HTML + sandbox/permissions/CSP into the inner - * iframe, echoing the applied sandbox config back via hostCapabilities, - * 3. handles `openLinks` by opening http(s) URLs in a new tab, - * 4. handles `downloadFile` by confirming with the user, then writing each - * embedded resource to disk via an object-URL anchor (resource links are - * opened in a new tab), - * 5. connects a {@link PostMessageTransport} to the iframe and returns the - * live bridge. - * - * Host-initiated tool input/result are pushed separately through the renderer's - * imperative handle (see `AppRenderer`), gated on the view's `initialized` - * event. The factory throws when no client is connected; AppRenderer routes - * that to its `onError` so the user sees a clear failure instead of a blank - * frame. - */ -export function createAppBridgeFactory( - deps: AppBridgeFactoryDeps, -): BridgeFactory { - return async (iframe, tool) => { - const client = deps.getClient(); - if (!client) { - throw new Error("Cannot render MCP App: no connected MCP client."); - } - const targetWindow = iframe.contentWindow; - if (!targetWindow) { - throw new Error("Cannot render MCP App: sandbox iframe has no window."); - } - - // Per-app copy so the approved-sandbox echo (set on sandboxready below) - // never mutates the shared HOST_CAPABILITIES constant — each app may - // declare its own csp/permissions. - const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES }; - // ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both - // are runtime-compatible with v2's. Cast at this single construction - // boundary. TODO: drop when ext-apps#702 ships a v2 peer release. - const bridge = new AppBridge( - client as unknown as ConstructorParameters[0], - HOST_INFO as unknown as ConstructorParameters[1], - hostCapabilities, - { - hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), - }, - ); - - // The double-iframe proxy posts `sandboxready` once it can receive content. - // Read the tool's UI resource and hand its HTML (plus any sandbox/permission - // hints from the resource _meta) to the inner sandboxed iframe. A failure - // here is the case a developer most needs surfaced (their app's resource is - // erroring or malformed) — log it and report it via deps.onResourceError so - // the host can show something better than a blank frame. The bridge stays - // live so a retry path remains possible. - bridge.addEventListener("sandboxready", () => { - void (async () => { - try { - const uri = getToolUiResourceUri( - tool as Parameters[0], - ); - if (!uri) return; - const result = await deps.readResource(uri); - const { html, meta } = extractHtmlAndMeta(result); - // Build the per-app CSP host-side: filter the requested sources to - // ones the host accepts, render the policy string, and wrap the - // app's HTML in a fixed shell whose first child is the CSP - // . The proxy assigns that document to srcdoc verbatim — it - // never parses the untrusted bytes — so the policy is guaranteed to - // apply before any app content loads. The approved (post-filter) csp - // is what we echo back via hostCapabilities.sandbox so the view sees - // what was granted, not what it asked for. Set before - // sendSandboxResourceReady: the view only sends ui/initialize once it - // has the HTML, so the bridge reflects this in the initialize result. - const approvedCsp = approveCspSources(meta?.csp); - // NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered - // by approveCspSources because its sources are interpolated into the - // CSP content string. `permissions` is NOT filtered here — it is - // a structured object (camera/microphone/geolocation/clipboardWrite - // booleans), and its only consumer is the sandbox proxy's - // buildAllowAttribute(), which maps each known key to a fixed - // Permissions-Policy token and ignores anything else. Untrusted values - // therefore can't reach the iframe `sandbox`/`allow` attribute as raw - // text (that layer, and the allow-same-origin strip, is owned by the - // sandbox-hardening work in #1565), so no source-style allowlist applies. - hostCapabilities.sandbox = { - permissions: meta?.permissions, - csp: approvedCsp, - }; - await bridge.sendSandboxResourceReady({ - html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)), - permissions: meta?.permissions, - }); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - console.error( - "[mcp-app] failed to load UI resource into sandbox:", - error, - ); - deps.onResourceError?.(error); - } - })(); - }); - - bridge.onopenlink = async ({ url }) => { - if (/^https?:\/\//i.test(url)) { - window.open(url, "_blank", "noopener,noreferrer"); - return { isError: false }; - } - return { isError: true }; - }; - - // The view asks the host to save MCP resource contents to disk (sandboxed - // iframes can't download directly). Confirm with the user first — the spec - // requires a host-mediated confirmation — then write each item out. A - // declined prompt, an empty/oversized payload, or a thrown error all - // return isError. - bridge.ondownloadfile = async ({ contents }) => { - if (!Array.isArray(contents) || contents.length === 0) { - return { isError: true }; - } - // Sanity cap: one approval must not fan out into an unbounded number of - // downloads / new tabs. A buggy or hostile app requesting hundreds of - // items is rejected outright rather than acted on. - if (contents.length > MAX_DOWNLOAD_ITEMS) { - console.warn( - `[mcp-app] refusing download batch of ${contents.length} items (max ${MAX_DOWNLOAD_ITEMS})`, - ); - return { isError: true }; - } - const summary = contents - .map((item) => sanitizeDownloadLabel(describeDownloadItem(item, true))) - .join("\n"); - const approved = window.confirm( - `This MCP App wants to download or open ${contents.length} item(s):\n\n${summary}`, - ); - if (!approved) return { isError: true }; - let succeeded = 0; - const skipped: string[] = []; - for (const item of contents) { - try { - if (downloadResourceItem(item)) { - succeeded++; - } else { - skipped.push(describeDownloadItem(item)); - } - } catch (err) { - skipped.push(describeDownloadItem(item)); - console.error("[mcp-app] download item failed:", err); - } - } - if (skipped.length > 0) { - console.warn( - `[mcp-app] ${skipped.length} of ${contents.length} download item(s) skipped:`, - skipped, - ); - } - return { isError: succeeded === 0 }; - }; - - const transport = new PostMessageTransport(targetWindow, targetWindow); - await bridge.connect(transport); - return bridge; - }; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts deleted file mode 100644 index eca568827..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { - McpUiDisplayMode, - McpUiHostContext, - McpUiHostStyles, - McpUiStyles, - McpUiStyleVariableKey, -} from "@modelcontextprotocol/ext-apps/app-bridge"; - -/** - * Resolve the host theme from the DOM. Mantine writes the resolved color - * scheme to ``. Reading it here (rather than - * capturing React state) keeps the bridge factory's identity stable across - * theme toggles — the renderer treats a new factory identity as "rebuild the - * bridge", which would reload a running app's iframe on every theme flip. - * - * The attribute is only ever `"light"` or `"dark"` — Mantine resolves - * `defaultColorScheme="auto"` to the system value before paint and never - * writes `"auto"` here, so no `auto` branch is needed. The matchMedia - * fallback only covers the attribute being absent (e.g. a hydration race). - */ -export function currentTheme(): "light" | "dark" { - if (typeof document !== "undefined") { - const attr = document.documentElement.getAttribute( - "data-mantine-color-scheme", - ); - if (attr === "dark" || attr === "light") return attr; - } - if ( - typeof window !== "undefined" && - window.matchMedia?.("(prefers-color-scheme: dark)").matches - ) { - return "dark"; - } - return "light"; -} - -/** - * Maps the spec's host-style variable keys ({@link McpUiStyleVariableKey}) to - * the inspector's underlying CSS custom properties. The inspector themes itself - * with Mantine, so each spec token resolves to the matching Mantine design-token - * variable (or an `--inspector-*` token layered on top of one). Only a curated - * subset of the ~90 spec keys is mapped — the ones the inspector has a - * meaningful equivalent for; the rest are omitted, which the spec allows (hosts - * may provide any subset). - */ -const STYLE_VARIABLE_SOURCES: Partial> = { - "--color-background-primary": "--mantine-color-body", - "--color-background-secondary": "--inspector-surface-card", - "--color-background-tertiary": "--inspector-surface-subtle", - "--color-text-primary": "--mantine-color-text", - "--color-text-secondary": "--inspector-text-secondary", - "--color-text-inverse": "--inspector-text-inverse", - "--color-text-info": "--inspector-log-info", - "--color-text-danger": "--inspector-log-error", - "--color-text-success": "--inspector-status-connected", - "--color-text-warning": "--inspector-log-warning", - "--color-border-primary": "--inspector-border-default", - "--color-border-secondary": "--inspector-border-subtle", - "--font-sans": "--mantine-font-family", - "--font-mono": "--mantine-font-family-monospace", - "--font-text-xs-size": "--mantine-font-size-xs", - "--font-text-sm-size": "--mantine-font-size-sm", - "--font-text-md-size": "--mantine-font-size-md", - "--font-text-lg-size": "--mantine-font-size-lg", - "--border-radius-xs": "--mantine-radius-xs", - "--border-radius-sm": "--mantine-radius-sm", - "--border-radius-md": "--mantine-radius-md", - "--border-radius-lg": "--mantine-radius-lg", - "--border-radius-xl": "--mantine-radius-xl", - "--shadow-sm": "--mantine-shadow-sm", - "--shadow-md": "--mantine-shadow-md", - "--shadow-lg": "--mantine-shadow-lg", -}; - -const STYLE_VARIABLE_ENTRIES = Object.entries(STYLE_VARIABLE_SOURCES) as [ - McpUiStyleVariableKey, - string, -][]; - -/** - * Resolve the inspector's design tokens into a {@link McpUiHostStyles} for - * hostContext, so style-aware apps can theme themselves from the host instead - * of falling back to their own defaults. Reads the computed value of each - * mapped CSS variable from the document root — which reflects the active - * Mantine color scheme — and keeps only the ones that resolve to a non-empty - * value. Returns undefined when nothing resolves (e.g. a non-DOM/test - * environment) so we never advertise an empty styles object. - */ -export function currentStyles(): McpUiHostStyles | undefined { - if (typeof document === "undefined" || typeof window === "undefined") { - return undefined; - } - const computed = window.getComputedStyle(document.documentElement); - const variables: McpUiStyles = {} as McpUiStyles; - let resolved = false; - for (const [specKey, sourceVar] of STYLE_VARIABLE_ENTRIES) { - const value = computed.getPropertyValue(sourceVar).trim(); - if (value) { - variables[specKey] = value; - resolved = true; - } - } - return resolved ? { variables } : undefined; -} - -/** - * Spec shape for `hostContext.containerDimensions`. Derived from - * {@link McpUiHostContext} so the seed and live-push paths share one source of - * truth and stay in lockstep with the spec types. - */ -export type ContainerDimensions = NonNullable< - McpUiHostContext["containerDimensions"] ->; - -/** - * Measure the host container an app renders into and return its concrete - * `{ width, height }` (whole pixels). Returns undefined when the element has - * no layout box yet (0×0 — e.g. before the iframe is attached, or in a - * non-DOM/test environment) so a meaningless size is never seeded into - * hostContext. The return type is the concrete pair rather than the spec's - * {@link ContainerDimensions} union so callers can compare both fields. - */ -export function measureContainerDimensions( - element: HTMLElement, -): { width: number; height: number } | undefined { - if (typeof element.getBoundingClientRect !== "function") return undefined; - const rect = element.getBoundingClientRect(); - const width = Math.round(rect.width); - const height = Math.round(rect.height); - if (width <= 0 || height <= 0) return undefined; - return { width, height }; -} - -/** - * Read the live host UI state into a {@link McpUiHostContext} for the bridge - * handshake — the single place that decides which fields the inspector seeds. - * Optional fields are omitted (not set undefined) so the SDK's diff stays - * accurate; subsequent live changes are pushed by the renderer's observers as - * partial `host-context-changed` notifications. - */ -export function snapshotHostContext( - container: HTMLElement | null, - availableDisplayModes: readonly McpUiDisplayMode[], -): McpUiHostContext { - const styles = currentStyles(); - const containerDimensions = container - ? measureContainerDimensions(container) - : undefined; - return { - theme: currentTheme(), - // Seed assumes the app opens inline. AppsScreen always mounts the renderer - // inline (maximize is a later user action), so this holds today; the live - // displayMode push (AppRenderer's displayMode effect, wired by #1568) - // carries any subsequent inline↔fullscreen transition. If a caller ever - // mounts already-maximized, thread the actual mode in here instead. - displayMode: "inline", - availableDisplayModes: [...availableDisplayModes], - ...(styles ? { styles } : {}), - ...(containerDimensions ? { containerDimensions } : {}), - }; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx deleted file mode 100644 index a968c4ace..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; - -export interface CategoryBadgeProps { - /** - * Network request category: `transport` (MCP protocol traffic, blue) or - * `auth` (OAuth discovery/token requests, violet). - */ - category: FetchRequestCategory; -} - -const BG: Record = { - transport: "var(--inspector-badge-transport-bg)", - auth: "var(--inspector-badge-auth-bg)", -}; - -const FG: Record = { - transport: "var(--inspector-badge-transport-fg)", - auth: "var(--inspector-badge-auth-fg)", -}; - -/** - * Badge tagging a Network entry's request category — `transport` (blue) or - * `auth` (violet). Surfaces come from `--inspector-badge-*` tokens: a tinted - * fill in light mode, a deep saturated fill with light text in dark mode - * (matching the Protocol direction badges). Used by `NetworkEntry`. - */ -export function CategoryBadge({ category }: CategoryBadgeProps) { - return ( - - {category} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx deleted file mode 100644 index f64350973..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { CloseButton } from "@mantine/core"; - -/** - * The clear (×) affordance shown in a populated text input's right section - * (`rightSection`). Wraps Mantine's `CloseButton` with a fixed - * `aria-label="Clear"` and `tabIndex={-1}`, so the button stays mouse-clickable - * but is skipped during keyboard tab navigation — tabbing through a form lands - * on the next field, not on the clear button (see #1487). Pass `onClick` to - * reset the field's value to "". Both presets can still be overridden per-site. - */ -export const ClearButton = CloseButton.withProps({ - "aria-label": "Clear", - tabIndex: -1, -}); diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx deleted file mode 100644 index cfce77b84..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Code } from "@mantine/core"; -import { useEffect, useState } from "react"; -import type { ComponentType } from "react"; - -/** - * Lazy syntax-highlighting code block. The `react-syntax-highlighter` - * prism-light runtime, its theme chunk, and each language grammar are - * dynamic-imported on first use so a session that never opens a highlightable - * resource never pays for them. While a grammar is still loading (or the - * language is unknown) the raw code is shown in a plain Mantine `Code` block so - * users never see a flash of unstyled tokens. - */ -export interface CodeHighlightProps { - /** Highlight.js / Prism language tag (or an alias — see {@link LANGUAGE_ALIASES}). */ - language: string; - /** The source text to render. */ - code: string; -} - -/** A Prism grammar object; opaque to us — registered with the runtime as-is. */ -type Grammar = unknown; - -/** The prism-light runtime component plus its `registerLanguage` static. */ -type PrismRuntime = ComponentType<{ - language: string; - style: Record; - customStyle?: Record; - wrapLongLines?: boolean; - children: string; -}> & { registerLanguage: (name: string, grammar: Grammar) => void }; - -/** - * Static per-language import thunks. Each is a separate dynamic import so Vite - * emits one lazily-loaded chunk per grammar. Add an entry here (and an alias - * below if the canonical Prism name differs) as the type matrix grows. - */ -const LANGUAGE_LOADERS: Record Promise<{ default: Grammar }>> = { - json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"), - markup: () => - import("react-syntax-highlighter/dist/esm/languages/prism/markup"), - css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"), - yaml: () => import("react-syntax-highlighter/dist/esm/languages/prism/yaml"), - markdown: () => - import("react-syntax-highlighter/dist/esm/languages/prism/markdown"), -}; - -/** Friendly language tags → the canonical Prism grammar name they resolve to. */ -const LANGUAGE_ALIASES: Record = { - xml: "markup", - html: "markup", - htm: "markup", - svg: "markup", - yml: "yaml", - md: "markdown", -}; - -/** Grammars successfully loaded + registered this session. */ -const registeredLanguages = new Set(); -/** Grammars whose import rejected / are unknown — never retried. */ -const failedLoads = new Set(); -/** In-flight grammar loads, so concurrent mounts share one async call. */ -const loadingPromises = new Map>(); - -/** The shared prism-light runtime component + theme. */ -interface Runtime { - Prism: PrismRuntime; - style: Record; -} - -/** The shared prism-light runtime + theme, loaded once. */ -let runtime: Runtime | null = null; -let runtimePromise: Promise | null = null; - -/** Resolve an alias to its canonical Prism grammar name. */ -function resolveLanguage(language: string): string { - return LANGUAGE_ALIASES[language] ?? language; -} - -/** Load (and cache) the prism-light runtime component and the `tomorrow` theme. */ -async function ensureRuntime(): Promise { - if (runtime) return runtime; - if (!runtimePromise) { - runtimePromise = (async () => { - const [prismMod, styleMod] = await Promise.all([ - import("react-syntax-highlighter/dist/esm/prism-light"), - import("react-syntax-highlighter/dist/esm/styles/prism/tomorrow"), - ]); - runtime = { - Prism: prismMod.default as PrismRuntime, - style: styleMod.default, - }; - return runtime; - })(); - } - return runtimePromise; -} - -/** - * Ensure the grammar for `language` is loaded and registered. Resolves once the - * language is ready, a prior load failed, or the language is unknown — callers - * re-check {@link isLanguageReady} afterward rather than relying on this throwing. - */ -async function ensureLanguage(language: string): Promise { - const name = resolveLanguage(language); - if (registeredLanguages.has(name) || failedLoads.has(name)) return; - const inFlight = loadingPromises.get(name); - if (inFlight) return inFlight; - - const loader = LANGUAGE_LOADERS[name]; - if (!loader) { - failedLoads.add(name); - return; - } - - const load = (async () => { - try { - const rt = await ensureRuntime(); - const { default: grammar } = await loader(); - rt.Prism.registerLanguage(name, grammar); - registeredLanguages.add(name); - } catch { - failedLoads.add(name); - } finally { - loadingPromises.delete(name); - } - })(); - loadingPromises.set(name, load); - return load; -} - -const PlainCode = Code.withProps({ block: true }); - -export function CodeHighlight({ language, code }: CodeHighlightProps) { - // Readiness is derived from the module-level caches during render (so a - // language already loaded this session highlights on first paint, including - // after the `language` prop changes). The effect only bumps a tick when an - // async load finishes, forcing a re-render that re-reads the caches. - const [, bumpTick] = useState(0); - - useEffect(() => { - let cancelled = false; - void ensureLanguage(language).then(() => { - if (!cancelled) bumpTick((t) => t + 1); - }); - return () => { - cancelled = true; - }; - }, [language]); - - const resolved = resolveLanguage(language); - const rt = runtime; - // Plain block until the runtime has loaded and this grammar is registered. - if (!rt || !registeredLanguages.has(resolved)) { - return {code}; - } - - const { Prism, style } = rt; - return ( - - {code} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx deleted file mode 100644 index f2c2786a1..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Code, Flex, Stack } from "@mantine/core"; - -const ContentWrapper = Flex.withProps({ - pos: "relative", - direction: "column", -}); - -const NoticeCode = Code.withProps({ block: true, p: 36 }); - -/** - * Fallback shown when content can't be previewed — an unsupported binary MIME - * type, or a blob whose base64 fails to decode. Kept in its own module so blob - * renderers (e.g. {@link PdfFrame}) can degrade to it without importing back - * into {@link ContentViewer} (which would form an import cycle). - */ -export function BinaryNotice({ mimeType }: { mimeType: string }) { - return ( - - - - {`[Binary content (${mimeType}) — preview not supported]`} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx deleted file mode 100644 index fac6000f1..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { Code, Flex, Image, Stack } from "@mantine/core"; -import type { ReactNode } from "react"; -import type { - BlobResourceContents, - ContentBlock, - TextResourceContents, -} from "@modelcontextprotocol/client"; -import ReactMarkdown from "react-markdown"; -import type { Components } from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { CodeHighlight } from "../CodeHighlight/CodeHighlight"; -import { CopyButton } from "../CopyButton/CopyButton"; -import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo"; -import { - formatJson, - formatXml, - getMimeKind, - isSafeHref, - isTextualKind, - looksLikeJson, - tryDecodeBase64ToUtf8, -} from "./contentViewerUtils"; -import { BinaryNotice } from "./BinaryNotice"; -import { CsvTable } from "./CsvTable"; -import { HtmlFrame } from "./HtmlFrame"; -import { PdfFrame } from "./PdfFrame"; - -export interface ContentViewerProps { - /** - * A content block to render (tool results, prompt messages, server cards, …). - * Provide either `block` or `contents`. - */ - block?: ContentBlock; - /** - * Raw resource contents (Resources screen). When provided, the per-MIME - * dispatch keys off `mimeType` and the base64 `blob` / `text`, covering - * PDF / CSV / HTML / XML / CSS in addition to the content-block cases. - * Provide either `block` or `contents`. - */ - contents?: TextResourceContents | BlobResourceContents; - copyable?: boolean; - /** - * Effective MIME type for the content. Drives the per-MIME renderer dispatch - * (markdown, JSON, XML, CSS, CSV, HTML, PDF). When absent, text falls back to - * a JSON-shape heuristic then plain preformatted code. - */ - mimeType?: string; - /** - * Whether long plain-text content wraps onto multiple lines. When `false`, - * text is kept to a single line (overflow clipped with an ellipsis) so the - * viewer keeps a fixed height — used by hosts like the server card where the - * box height must stay constant regardless of command/URL length. The full - * value remains available via the copy button (and a native `title` - * tooltip). Defaults to `true`. - * - * Intended for single-line values only: `false` applies `white-space: - * nowrap`, which collapses embedded newlines (e.g. pretty-printed JSON) onto - * one line — don't pass it for multi-line content. - */ - wrap?: boolean; -} - -function buildDataUri(mimeType: string, data: string): string { - return `data:${mimeType};base64,${data}`; -} - -const ContentWrapper = Flex.withProps({ - pos: "relative", - direction: "column", -}); - -const CopyOverlay = Flex.withProps({ - pos: "absolute", - top: 4, - right: 4, -}); - -const MarkdownWrapper = Flex.withProps({ - className: "markdown-content", - direction: "column", -}); - -const PreviewImage = Image.withProps({ - alt: "Content preview", - maw: 400, - radius: "md", -}); - -const CodeBlock = Code.withProps({ - block: true, - p: 36, -}); - -// Markdown anchors are constrained to a safe-scheme allowlist: a non-matching -// href (e.g. `javascript:`, protocol-relative `//evil.com`) renders as inert -// text so user-supplied markdown can't smuggle a script-bearing link. -const SafeAnchor: Components["a"] = ({ href, children }) => - isSafeHref(href) ? {children} : {children}; - -const markdownComponents: Components = { a: SafeAnchor }; - -function CopyableWrapper({ - copyable, - copyValue, - children, -}: { - copyable: boolean; - copyValue: string; - children: ReactNode; -}) { - return ( - - - {children} - {copyable && ( - - - - )} - - - ); -} - -function MarkdownContent({ - text, - copyable, -}: { - text: string; - copyable: boolean; -}) { - return ( - - - - {text} - - - - ); -} - -function HighlightedContent({ - code, - language, - copyValue, - copyable, -}: { - code: string; - language: string; - copyValue: string; - copyable: boolean; -}) { - return ( - - - - ); -} - -function PlainTextContent({ - text, - copyable, - wrap, -}: { - text: string; - copyable: boolean; - wrap: boolean; -}) { - const displayText = looksLikeJson(text) ? formatJson(text) : text; - return ( - - - {displayText} - - - ); -} - -/** - * Render decoded text according to its MIME type: markdown, syntax-highlighted - * JSON / XML / CSS, a CSV table, a sandboxed HTML iframe, or — for plain or - * unrecognized text — a preformatted code block (with a JSON-shape heuristic so - * mimeless JSON still pretty-prints). - */ -function TextualContent({ - text, - mimeType, - copyable, - wrap, -}: { - text: string; - mimeType: string | undefined; - copyable: boolean; - wrap: boolean; -}) { - const kind = mimeType ? getMimeKind(mimeType) : "text"; - switch (kind) { - case "markdown": - return ; - case "json": - return ( - - ); - case "xml": - return ( - - ); - case "css": - return ( - - ); - case "csv": - return ( - - - - ); - case "html": - return ( - - - - ); - default: - return ; - } -} - -function ImageContent({ data, mimeType }: { data: string; mimeType: string }) { - return ( - - - - ); -} - -function AudioContent({ data, mimeType }: { data: string; mimeType: string }) { - return ( - - - - ); -} - -/** Dispatch raw resource contents (Resources screen) on their effective MIME. */ -function ResourceContent({ - contents, - mimeType, - copyable, - wrap, -}: { - contents: TextResourceContents | BlobResourceContents; - mimeType: string; - copyable: boolean; - wrap: boolean; -}) { - if ("text" in contents) { - return ( - - ); - } - const kind = getMimeKind(mimeType); - if (kind === "image") { - return ; - } - if (kind === "audio") { - return ; - } - if (kind === "pdf") { - return ( - - - - ); - } - if (isTextualKind(kind)) { - const decoded = tryDecodeBase64ToUtf8(contents.blob); - if (decoded === null) { - return ; - } - return ( - - ); - } - return ; -} - -/** Dispatch a content block (tool results, prompt messages, …) on its type. */ -function BlockContent({ - block, - mimeType, - copyable, - wrap, -}: { - block: ContentBlock; - mimeType: string | undefined; - copyable: boolean; - wrap: boolean; -}) { - switch (block.type) { - case "text": - return ( - - ); - case "image": - return ; - case "audio": - return ; - case "resource": - return ( - - - - {"text" in block.resource - ? block.resource.text - : `[blob: ${block.resource.uri}]`} - - - - ); - case "resource_link": - // Static metadata only. The interactive, read-on-demand presentation - // lives in the `groups/ResourceLink` group, rendered by content-block - // hosts (e.g. ToolResultPanel) that can supply a read handler. - return ( - - ); - default: - return null; - } -} - -export function ContentViewer({ - block, - contents, - copyable = false, - mimeType, - wrap = true, -}: ContentViewerProps) { - if (contents) { - const effective = - mimeType ?? contents.mimeType ?? "application/octet-stream"; - return ( - - ); - } - if (!block) return null; - return ( - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx deleted file mode 100644 index 81bf4fb68..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Code, Table } from "@mantine/core"; -import { useMemo } from "react"; -import Papa from "papaparse"; - -/** - * Render CSV text as a Mantine `Table`. Parsed with papaparse in header mode; - * only the first {@link MAX_ROWS} rows are shown to keep large files cheap. When - * the text doesn't parse as a header-bearing table (no detected columns), the - * raw text is shown in a plain wrapping `Code` block instead of throwing. - */ -export interface CsvTableProps { - /** The CSV document text. */ - text: string; -} - -/** Cap rendered rows so a huge CSV doesn't mount thousands of DOM nodes. */ -export const MAX_ROWS = 100; - -const PlainCode = Code.withProps({ block: true, variant: "wrapping" }); - -const CsvGrid = Table.withProps({ - striped: true, - highlightOnHover: true, - withTableBorder: true, - withColumnBorders: true, -}); - -interface ParsedCsv { - fields: string[]; - rows: string[][]; - /** Total parsed row count, before the {@link MAX_ROWS} display cap. */ - total: number; -} - -function parseCsv(text: string): ParsedCsv | null { - const result = Papa.parse>(text, { - header: true, - skipEmptyLines: true, - }); - const fields = result.meta.fields ?? []; - if (fields.length === 0 || result.data.length === 0) { - return null; - } - const rows = result.data - .slice(0, MAX_ROWS) - .map((row) => fields.map((field) => row[field] ?? "")); - return { fields, rows, total: result.data.length }; -} - -export function CsvTable({ text }: CsvTableProps) { - const parsed = useMemo(() => parseCsv(text), [text]); - - if (!parsed) { - return {text}; - } - - const truncated = parsed.total > MAX_ROWS; - return ( - - {truncated && ( - - {`Showing first ${MAX_ROWS} of ${parsed.total} rows`} - - )} - - - {parsed.fields.map((field) => ( - {field} - ))} - - - - {parsed.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - {cell} - ))} - - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx deleted file mode 100644 index 228b9a562..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Box } from "@mantine/core"; -import { useMemo } from "react"; -import { wrapHtmlWithCsp } from "./contentViewerUtils"; -import { useObjectUrl } from "./useObjectUrl"; - -/** - * Render an HTML resource inside a hardened iframe. Defense is layered: - * - * - `sandbox=""` — explicitly empty: no `allow-scripts`, `allow-forms`, or - * `allow-same-origin`, so scripts can't run and the frame is origin-isolated. - * - A `Content-Security-Policy` `` is injected (see {@link wrapHtmlWithCsp}) - * as defense-in-depth — correct even if the sandbox is later loosened. - * - The document is served from a `Blob` object URL (revoked on unmount) rather - * than `srcdoc`, keeping it off the parent's origin. - */ -export interface HtmlFrameProps { - /** The raw HTML document or fragment to preview. */ - html: string; -} - -export function HtmlFrame({ html }: HtmlFrameProps) { - const blob = useMemo( - () => new Blob([wrapHtmlWithCsp(html)], { type: "text/html" }), - [html], - ); - const url = useObjectUrl(blob); - return ( - // Box+iframe is a native element (not a Mantine primitive), so the - // `.withProps()` extraction rule doesn't apply. - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx deleted file mode 100644 index eb5664af2..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Box } from "@mantine/core"; -import { useMemo } from "react"; -import { BinaryNotice } from "./BinaryNotice"; -import { tryDecodeBase64ToBytes } from "./contentViewerUtils"; -import { useObjectUrl } from "./useObjectUrl"; - -/** - * Render a base64-encoded PDF in an in-page viewer. The bytes are wrapped in a - * `Blob` and served via an object URL (revoked on unmount / when the data - * changes) rather than a multi-megabyte `data:` URI. `#view=FitH` asks the - * browser's built-in viewer to fit the page width. - * - * The `blob` comes from an external MCP server; if its base64 fails to decode - * we degrade to the binary-content notice instead of throwing during render. - */ -export interface PdfFrameProps { - /** Base64-encoded PDF bytes (the `blob` field of a `BlobResourceContents`). */ - data: string; -} - -export function PdfFrame({ data }: PdfFrameProps) { - const bytes = useMemo(() => tryDecodeBase64ToBytes(data), [data]); - const blob = useMemo( - () => - bytes ? new Blob([bytes], { type: "application/pdf" }) : new Blob([]), - [bytes], - ); - const url = useObjectUrl(blob); - if (!bytes) { - return ; - } - return ( - // Box+iframe is a native element (not a Mantine primitive), so the - // `.withProps()` extraction rule doesn't apply. - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts deleted file mode 100644 index c56b6f78f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept in - * a dependency-free module so they can be unit-tested in isolation and reused by - * the blob renderers (PDF / CSV / HTML) without dragging in React. - */ - -/** - * The renderer family a MIME type maps to. `ContentViewer` switches on this to - * pick a branch; `binary` is the catch-all "preview not supported" fallback. - */ -export type MimeKind = - | "image" - | "audio" - | "pdf" - | "markdown" - | "json" - | "xml" - | "css" - | "csv" - | "html" - | "text" - | "binary"; - -/** Strip any `; charset=…` parameters and normalise case for comparison. */ -function baseMime(mimeType: string): string { - return mimeType.split(";")[0].trim().toLowerCase(); -} - -/** - * Classify a MIME type into the renderer family `ContentViewer` should use. - * Structured-suffix types (`application/foo+json`, `image/svg+xml`) fold into - * their base family. Unknown `application/*` types fall through to `binary`. - */ -export function getMimeKind(mimeType: string): MimeKind { - const base = baseMime(mimeType); - if (base.startsWith("image/")) return "image"; - if (base.startsWith("audio/")) return "audio"; - if (base === "application/pdf") return "pdf"; - if (base === "text/markdown" || base === "text/x-markdown") return "markdown"; - if (base === "application/json" || base.endsWith("+json")) return "json"; - if (base === "text/csv") return "csv"; - if (base === "text/html") return "html"; - if (base === "text/css") return "css"; - if ( - base === "text/xml" || - base === "application/xml" || - base.endsWith("+xml") - ) - return "xml"; - if ( - base === "application/javascript" || - base === "application/ecmascript" || - base === "application/x-javascript" - ) - return "text"; - if (base.startsWith("text/")) return "text"; - return "binary"; -} - -/** Renderer families that operate on decoded text rather than raw bytes. */ -const TEXTUAL_KINDS: ReadonlySet = new Set([ - "markdown", - "json", - "xml", - "css", - "csv", - "html", - "text", -]); - -/** Whether a MIME kind is rendered from decoded UTF-8 text. */ -export function isTextualKind(kind: MimeKind): boolean { - return TEXTUAL_KINDS.has(kind); -} - -/** - * Decode a base64 string to UTF-8 text. Used when a server delivers inherently - * textual content (CSV, XML, HTML, …) as a `BlobResourceContents` blob instead - * of as `text`. - */ -export function decodeBase64ToUtf8(base64: string): string { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return new TextDecoder("utf-8").decode(bytes); -} - -/** - * Decode a base64 string to raw bytes. Used to build a `Blob` URL for binary - * previews (e.g. PDF) without round-tripping through a `data:` URI. - */ -export function decodeBase64ToBytes(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -/** - * Decode base64 to UTF-8 text, returning `null` instead of throwing when the - * input isn't valid base64. `atob` raises `InvalidCharacterError` on malformed - * input; since the `blob` comes from an external MCP server and is decoded - * during render, a throw would take down the whole preview panel rather than - * degrading to the binary-content fallback. Callers treat `null` as "not - * decodable — show the fallback". - */ -export function tryDecodeBase64ToUtf8(base64: string): string | null { - try { - return decodeBase64ToUtf8(base64); - } catch { - return null; - } -} - -/** Like {@link decodeBase64ToBytes} but returns `null` on malformed base64. */ -export function tryDecodeBase64ToBytes( - base64: string, -): Uint8Array | null { - try { - return decodeBase64ToBytes(base64); - } catch { - return null; - } -} - -/** Pretty-print JSON text; returns the input unchanged when it doesn't parse. */ -export function formatJson(content: string): string { - try { - return JSON.stringify(JSON.parse(content), null, 2); - } catch { - return content; - } -} - -/** Heuristic: does this plain text (no MIME) look like a JSON document? */ -export function looksLikeJson(text: string): boolean { - const trimmed = text.trimStart(); - return trimmed.startsWith("{") || trimmed.startsWith("["); -} - -/** - * Indent a single-line or minified XML/HTML-ish document for readability before - * syntax highlighting. Hand-rolled: split on `>\s*<` boundaries, then track a - * nesting depth, decrementing on closing tags and incrementing after opening - * tags that aren't self-closing or a one-line `text` pair. - */ -export function formatXml(xml: string): string { - const withBreaks = xml.replace(/>\s*\n<").trim(); - let depth = 0; - const out: string[] = []; - for (const raw of withBreaks.split("\n")) { - const line = raw.trim(); - if (!line) continue; - const isClosing = /^<\//.test(line); - if (isClosing) depth = Math.max(depth - 1, 0); - out.push(" ".repeat(depth) + line); - const isOpening = - /^<[A-Za-z]/.test(line) && // a tag, not a comment / declaration - !/\/>$/.test(line) && // not self-closing - !isClosing && // not a closing tag - !/^<([A-Za-z][\w-]*)\b[^>]*>.*<\/\1>$/.test(line); // not a one-line pair - if (isOpening) depth++; - } - return out.join("\n"); -} - -/** - * Content-Security-Policy applied to previewed HTML resources. `script-src` is - * deliberately omitted so it falls through to `default-src 'none'` — that's what - * keeps the policy load-bearing if the iframe `sandbox` is ever loosened to - * allow scripts. Styles/fonts/images are permitted so reports render, but no - * navigation, plugins, or form submission. - */ -export const PREVIEW_HTML_CSP = - "default-src 'none'; " + - "style-src 'unsafe-inline' https://fonts.googleapis.com; " + - "img-src data: blob:; " + - "font-src data: https://fonts.gstatic.com; " + - "base-uri 'none'; " + - "object-src 'none'; " + - "form-action 'none';"; - -const CSP_META_TAG = ``; - -/** - * Inject the preview CSP `` into an HTML document before it's served to a - * sandboxed iframe. Handles three shapes: a full document with a `` (inject - * at the top of head), a document with `` but no `` (add a head), and - * a bare fragment (wrap in a minimal document). - */ -export function wrapHtmlWithCsp(html: string): string { - if (/]/i.test(html)) { - return html.replace(/]*)>/i, `${CSP_META_TAG}`); - } - if (/]/i.test(html)) { - return html.replace( - /]*)>/i, - `${CSP_META_TAG}`, - ); - } - return `${CSP_META_TAG}${html}`; -} - -/** - * Safe-scheme allowlist for markdown anchors. Permits absolute http(s), mailto, - * in-page fragments, and root-relative paths — but rejects protocol-relative - * `//evil.com` and dangerous schemes (`javascript:`, `data:`, …) so - * user-supplied markdown can't smuggle a script-bearing link. - */ -export const SAFE_HREF = /^(https?:|mailto:|#|\/(?!\/))/i; - -/** Whether a markdown anchor `href` is safe to render as a real ``. */ -export function isSafeHref(href: string | undefined): boolean { - return typeof href === "string" && SAFE_HREF.test(href); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts deleted file mode 100644 index 753375f7e..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; - -/** - * Create an object URL for `blob` and revoke it when the blob changes or the - * component unmounts. Memoize the `Blob` in the caller (e.g. with `useMemo`) so - * a stable blob identity doesn't re-create the URL on every render. - * - * The URL is derived during render (via `useMemo`) so consumers get a live URL - * on the first paint. Revocation is **deferred to a microtask** and guarded by - * `liveUrlRef`, which always points at the currently-mounted URL: - * - * - Under React StrictMode (dev) the effect runs setup → cleanup → setup with - * no re-render. The cleanup schedules the revoke; the re-setup restores - * `liveUrlRef` to the same URL, so when the microtask runs it sees the URL - * is still live and skips the revoke — the iframe keeps a valid `src`. - * - A real unmount (no re-setup) or a blob change (a new URL takes over) leaves - * `liveUrlRef` pointing elsewhere, so the stale URL is released. - * - * Revoking synchronously in the cleanup (the obvious shape) would instead kill - * the committed URL under StrictMode, blanking PDF/HTML previews in dev. This - * mirrors the deferred-disposal trick in `AppRenderer`. - */ -export function useObjectUrl(blob: Blob): string { - const url = useMemo(() => URL.createObjectURL(blob), [blob]); - const liveUrlRef = useRef(null); - - useEffect(() => { - liveUrlRef.current = url; - return () => { - liveUrlRef.current = null; - queueMicrotask(() => { - if (liveUrlRef.current !== url) { - URL.revokeObjectURL(url); - } - }); - }; - }, [url]); - - return url; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx deleted file mode 100644 index 7a86ca085..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { - ActionIcon, - CopyButton as MantineCopyButton, - Tooltip, -} from "@mantine/core"; - -export interface CopyButtonProps { - value: string; - /** - * Drop ActionIcon padding/height so the glyph top-aligns in tight aside - * rows (e.g. beside a Code block). Icon size is unchanged. - */ - flush?: boolean; -} - -const CopyActionIcon = ActionIcon.withProps({ - variant: "subtle", - fz: 24, -}); - -export function CopyButton({ value, flush = false }: CopyButtonProps) { - return ( - - {({ copied, copy }) => ( - - - {copied ? "\u2713" : "\u2398"} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx deleted file mode 100644 index ad9e87f9f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { ReactNode, Ref } from "react"; -import { ScrollArea, Stack } from "@mantine/core"; - -// Full-size, the monitor stream panels bound their scroll to the viewport -// minus the header and the panel's own chrome. -const FULLSIZE_MAH = - "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - 150px)"; - -export interface EmbeddableScrollAreaProps { - /** - * True when rendered inside the pinned monitoring sidebar (#1616): the scroll - * region fills its flex parent instead of using the viewport calc. A - * `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining - * below the column's controls (via `mah:100%`), so no viewport math is needed - * and the final rows never clip. - */ - embedded: boolean; - viewportRef: Ref; - children: ReactNode; - /** - * Constrain the scrolled content to the viewport width instead of letting it - * grow to its own `max-content`. Mantine's ScrollArea `content` slot defaults - * to `min-width: max-content`, so a row with non-wrapping content (e.g. a long - * network URL) stretches every card past the column and it bleeds out (#1623). - * When true, the content can shrink to the viewport and each row must manage - * its own overflow (the Network URL scrolls inside its own inner ScrollArea). - * Left off for panels whose rows already wrap/truncate (Logs, Protocol), where - * the default lets a long line scroll the list horizontally instead. - */ - constrainContentWidth?: boolean; -} - -// Relax the `content` slot's default `min-width: max-content` so the list can't -// grow wider than its viewport; see `constrainContentWidth`. -const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const; - -// Shared scroll region for both hosts; the differing props (viewportRef, mah, -// styles) are passed at each call site. -const StreamScrollArea = ScrollArea.Autosize.withProps({ - type: "scroll", - offsetScrollbars: true, - viewportProps: { tabIndex: 0 }, -}); - -// Fill-height wrapper for the embedded host, so the inner scroll region can claim -// the remaining space and scroll instead of overflowing. -const EmbeddedColumn = Stack.withProps({ flex: 1, mih: 0, gap: 0 }); - -/** - * The scroll region shared by the Logs / Protocol / Network stream panels, which - * render both full-size (their own tab) and embedded (the monitoring sidebar). - * Centralizes the one layout difference between those two hosts. - */ -export function EmbeddableScrollArea({ - embedded, - viewportRef, - children, - constrainContentWidth = false, -}: EmbeddableScrollAreaProps) { - const styles = constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined; - if (embedded) { - return ( - - - {children} - - - ); - } - return ( - - {children} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx deleted file mode 100644 index 37c4ebe43..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { ProtocolEra } from "@modelcontextprotocol/client"; -import { formatEra, isModernEra } from "./eraUtils"; - -export interface EraBadgeProps { - /** The negotiated protocol era; `undefined` renders as Legacy. */ - era: ProtocolEra | undefined; -} - -// Labels a connection's negotiated protocol era (SEP §7.8). Feed it from -// connection state only — see the note in `eraUtils` on why the era must never -// be inferred from individual message frames. -export function EraBadge({ era }: EraBadgeProps) { - return ( - - {formatEra(era)} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts deleted file mode 100644 index 1407dc633..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProtocolEra } from "@modelcontextprotocol/client"; - -// The SDK reports an era for every connected server, including a plain legacy -// connect (`"legacy"`); it's `undefined` only when not connected. Anything other -// than `"modern"` is the legacy era. IMPORTANT: this reflects the *negotiated* -// connection era — it must be fed from connection state, never inferred from -// individual message frames (the modern probe carries a `_meta` envelope before -// the era is known; spec §8.3). -export function isModernEra(era: ProtocolEra | undefined): boolean { - return era === "modern"; -} - -export function formatEra(era: ProtocolEra | undefined): string { - return isModernEra(era) ? "Modern" : "Legacy"; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx deleted file mode 100644 index 6ff611541..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { RiCollapseVerticalLine, RiExpandVerticalLine } from "react-icons/ri"; - -export interface ExpandToggleProps { - /** Whether the owning entry is currently expanded. */ - expanded: boolean; - onToggle: () => void; - /** - * Overrides the accessible name (aria-label). Defaults to the tooltip text - * ("Expand"/"Collapse"). Pass a per-entry name (e.g. including the resource - * URI) when several toggles sit in one list, so assistive tech can tell them - * apart; the visible tooltip stays the plain verb. - */ - ariaLabel?: string; -} - -const ExpandActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", -}); - -/** - * Icon toggle for a per-entry expand/collapse control (Protocol, Network, and - * Task cards). Uses the same expand/collapse-vertical icons as the list-level - * ListToggle: collapsed shows the expand icon, expanded shows the collapse - * icon. The tooltip stays "Expand"/"Collapse" (the same verb as the text button - * it replaced); `aria-expanded` exposes the disclosure state and `ariaLabel` - * can distinguish sibling toggles. - */ -export function ExpandToggle({ - expanded, - onToggle, - ariaLabel, -}: ExpandToggleProps) { - const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine; - const label = expanded ? "Collapse" : "Expand"; - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx deleted file mode 100644 index 92e217c6d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Text, UnstyledButton } from "@mantine/core"; -import { accessibleTextColor } from "../accessibleTextColor"; - -export interface FilterToggleButtonProps { - /** Visible label and accessible name for the toggle. */ - label: string; - /** Mantine text color for the label (e.g. "blue", "red", "dimmed"). */ - color: string; - /** Whether the filter is currently on (rendered as a filled background). */ - active: boolean; - /** Receives the next desired active state when the button is clicked. */ - onToggle: (active: boolean) => void; -} - -const ToggleLabel = Text.withProps({ - ta: "center", - fw: 500, -}); - -const ToggleButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "filterToggle", -}); - -/** - * A single full-width filter toggle used by the Logging, Protocol, and Network - * controls. The `filterToggle` theme variant + `.filter-toggle` rules own the - * styling: hover shows a thin border, the active (`aria-pressed`) state shows a - * filled background. Keeping hover as a border (not a fill) means toggling a - * button off while the cursor is still over it is visibly distinct from hover, - * instead of the two states sharing the same background. See issue #1460. - */ -export function FilterToggleButton({ - label, - color, - active, - onToggle, -}: FilterToggleButtonProps) { - return ( - onToggle(!active)}> - {label} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx deleted file mode 100644 index 1f3365b42..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Button, Group, Paper, Text } from "@mantine/core"; - -export interface ListChangedIndicatorProps { - visible: boolean; - onRefresh: () => void; -} - -const Dot = Paper.withProps({ - w: 8, - h: 8, - radius: "xl", - bg: "var(--inspector-status-connecting)", -}); - -const UpdateLabel = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const RefreshButton = Button.withProps({ - size: "sm", - variant: "subtle", -}); - -export function ListChangedIndicator({ - visible, - onRefresh, -}: ListChangedIndicatorProps) { - if (!visible) return null; - - return ( - - - List updated - Refresh - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx deleted file mode 100644 index fdb4ef839..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Alert, Button, Code, ScrollArea, Stack } from "@mantine/core"; - -export interface ListLoadErrorProps { - /** - * The failed load's error, or `null`/`undefined` when the last load - * succeeded (renders nothing). - */ - error?: Error | null; - /** What failed to load, for the alert title — e.g. "tools", "prompts". */ - what: string; - /** Retry the load. Omit to render the alert without a retry affordance. */ - onRetry?: () => void; -} - -// `variant="light"` + red: an error the user can act on (retry), not a fatal -// one. Sits above the list rather than replacing it — a stale list plus a -// visible "this didn't reload" beats an empty panel that looks like an answer. -const ErrorAlert = Alert.withProps({ - color: "red", - variant: "light", -}); - -// The raw message, monospaced and wrapping: these are validation failures -// (JSON paths, schema expectations) where the exact text is the diagnostic. -const ErrorMessage = Code.withProps({ - block: true, - variant: "wrapping", -}); - -// Caps the message: a schema-validation failure serializes to a dozen-plus -// lines, which would otherwise push the list itself off the sidebar. -const MessageScroll = ScrollArea.withProps({ - mah: 180, - type: "auto", -}); - -const RetryButton = Button.withProps({ - size: "xs", - variant: "light", - color: "red", - w: "fit-content", -}); - -/** - * The list panel's "couldn't load" state (#1953). - * - * A list fetch that fails — a transport error, or a result the SDK codec - * rejects as invalid for the negotiated protocol era — used to leave the panel - * empty, which is indistinguishable from a server that legitimately has no - * tools/prompts/resources. This says what happened and offers a retry. - */ -export function ListLoadError({ error, what, onRetry }: ListLoadErrorProps) { - if (!error) return null; - - return ( - - - - {error.message} - - {onRetry && Retry} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx deleted file mode 100644 index 3ef192118..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Button, Group, Stack, Switch, Text } from "@mantine/core"; - -export interface ListPaginationControlsProps { - /** - * True when the list is fetched one page at a time (backed by the server's - * `paginatedLists` setting). False = auto-aggregate every page on load. - */ - paginated: boolean; - /** Toggle paginated mode. Wired to write the `paginatedLists` setting. */ - onPaginatedChange: (paginated: boolean) => void; - /** Paginated mode only: the server returned a `nextCursor` to load. */ - canLoadMore: boolean; - /** Paginated mode only: number of pages loaded so far (status label). */ - loadedPages: number; - /** Paginated mode only: fetch the next page. */ - onLoadMore: () => void; -} - -// Switch fully left, "Load next page" fully right; the page-count sits on its -// own line under the row. -const ControlsRow = Group.withProps({ - gap: "sm", - align: "center", - justify: "space-between", - wrap: "nowrap", -}); - -const ModeSwitch = Switch.withProps({ - size: "sm", - "aria-label": "Fetch lists one page at a time", -}); - -const LoadMoreButton = Button.withProps({ - size: "compact-sm", - variant: "light", -}); - -const StatusText = Text.withProps({ - size: "xs", - ta: "center", - c: "var(--inspector-text-secondary)", -}); - -/** - * Sidebar control for a paginated list (Tools/Resources/Prompts). A "Paginated" - * switch toggles between auto-aggregating every page and fetching one page at a - * time; in paginated mode a "Load next page" button (to the right of the - * switch) surfaces the server's `nextCursor` and a status shows how many pages - * are loaded. Hidden entirely once the list is known to be a single page, since - * there's nothing to paginate. - */ -export function ListPaginationControls({ - paginated, - onPaginatedChange, - canLoadMore, - loadedPages, - onLoadMore, -}: ListPaginationControlsProps) { - // The list turned out to be a single page (loaded page 1, no `nextCursor`): - // pagination is moot, so hide the whole control rather than show a useless - // toggle + disabled button (#1721). - if (paginated && !canLoadMore && loadedPages === 1) return null; - - return ( - - - onPaginatedChange(e.currentTarget.checked)} - /> - {paginated ? ( - - Load next page - - ) : null} - - {paginated ? ( - - {loadedPages} {loadedPages === 1 ? "page" : "pages"} loaded - {canLoadMore ? "" : " · end"} - - ) : null} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx deleted file mode 100644 index 5a1efcc62..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri"; - -export interface ListToggleProps { - compact: boolean; - onToggle: () => void; - variant?: "default" | "subtle"; -} - -const SubtleActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", -}); - -// `size={36}` matches the header's theme / client-settings ActionIcons so the -// toolbar's toggle reads as the same size icon button. -const ToolbarActionIcon = ActionIcon.withProps({ - variant: "subtle", - size: 36, -}); - -export function ListToggle({ - compact, - onToggle, - variant = "default", -}: ListToggleProps) { - const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine; - const label = compact ? "Expand all" : "Collapse all"; - - if (variant === "subtle") { - return ( - - - - - - ); - } - - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx deleted file mode 100644 index 3fb53a649..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Group, Stack, Text } from "@mantine/core"; -import type { - LoggingLevel, - LoggingMessageNotification, -} from "@modelcontextprotocol/client"; -import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge"; -import { accessibleTextColor } from "../accessibleTextColor"; - -export interface LogEntryData { - receivedAt: Date; - params: LoggingMessageNotification["params"]; -} - -export interface LogEntryProps { - entry: LogEntryData; - /** - * Compact two-line layout for the narrow monitoring sidebar (#1661): the - * timestamp, level, and logger sit on the first line and the message wraps - * onto the line below, so a long message isn't clipped by the column width. - * The default (false) is the single-line row used on the full Logs screen. - */ - compact?: boolean; -} - -const levelMessageColor: Record = { - debug: "dimmed", - info: "blue", - notice: undefined, - warning: "yellow", - error: "red", - critical: "red", - alert: "red", - emergency: "red", -}; - -function formatTimestamp(date: Date): string { - return date.toLocaleTimeString(); -} - -function formatLogger(logger: string): string { - return `[${logger}]`; -} - -function formatData(data: unknown): string { - if (data === undefined || data === null) return ""; - if (typeof data === "string") return data; - return JSON.stringify(data); -} - -const TimestampText = Text.withProps({ - size: "sm", - ff: "monospace", - c: "dimmed", -}); - -const LoggerText = Text.withProps({ - size: "xs", - ff: "monospace", - c: "dimmed", -}); - -// Single-line message (full Logs screen): sits inline with the meta on one row. -const MessageText = Text.withProps({ - size: "sm", - ff: "monospace", -}); - -// Compact message (monitoring sidebar): wraps over-long lines within the narrow -// column via the `consoleLine` variant instead of overflowing its width. -const CompactMessageText = Text.withProps({ - size: "sm", - ff: "monospace", - variant: "consoleLine", -}); - -// The compact meta row: timestamp + level + logger on one line above the -// message. `wrap: nowrap` keeps them on a single line; the message wraps below. -const MetaRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - align: "center", -}); - -// The single-line row (full Logs screen): meta + message on one row. -const LogRow = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -export function LogEntry({ entry, compact = false }: LogEntryProps) { - const { receivedAt, params } = entry; - const message = formatData(params.data); - const logger = params.logger ? ( - {formatLogger(params.logger)} - ) : null; - - if (compact) { - return ( - - - {formatTimestamp(receivedAt)} - - {logger} - - - {message} - - - ); - } - - return ( - - {formatTimestamp(receivedAt)} - - {logger} - - {message} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx deleted file mode 100644 index 2cbee7f51..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { LoggingLevel } from "@modelcontextprotocol/client"; -import { filledBadgeColor } from "../filledBadgeColor"; - -export interface LogLevelBadgeProps { - level: LoggingLevel; -} - -const levelColor: Record = { - debug: "gray", - info: "blue", - notice: "teal", - warning: "yellow", - error: "red", - critical: "red", - alert: "red", - emergency: "red", -}; - -const boldLevels: Set = new Set(["alert", "emergency"]); - -const FilledBadge = Badge.withProps({ - variant: "filled", - autoContrast: true, -}); - -export function LogLevelBadge({ level }: LogLevelBadgeProps) { - const fw = boldLevels.has(level) ? 500 : undefined; - - // `autoContrast` keeps the label legible (WCAG AA) on both the light-mode - // fills and the darker dark-mode `-filled` shades — see AnnotationBadge. - return ( - - {level} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx deleted file mode 100644 index 5e5cd624f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Badge, Tooltip } from "@mantine/core"; -import { filledBadgeColor } from "../filledBadgeColor"; - -export interface McpErrorBadgeProps { - /** JSON-RPC error code, e.g. -32020. */ - code: number; - /** Spec name, e.g. "HeaderMismatch". */ - name: string; - /** Optional explanation shown on hover. */ - description?: string; -} - -// Each modern spec error gets a distinct colour so the four are told apart at a -// glance in a dense Protocol stream (SEP-2243 / SEP-2575). Falls back to red for -// any other code routed here. -const COLOR_BY_CODE: Record = { - [-32020]: "red", // HeaderMismatch - [-32021]: "orange", // MissingRequiredClientCapability - [-32022]: "grape", // UnsupportedProtocolVersion - [-32601]: "yellow", // MethodNotFound (modern 404) -}; - -const SpecErrorBadge = Badge.withProps({ - variant: "filled", - autoContrast: true, - // Keep the spec/SDK identifier's own casing (e.g. "UnsupportedProtocolVersion") - // rather than Mantine's default uppercase, which runs these long - // PascalCase names together and hurts readability. - tt: "none", -}); - -const DescriptionTooltip = Tooltip.withProps({ - multiline: true, - w: 280, - withArrow: true, -}); - -/** - * Distinct badge for one of the modern Streamable HTTP spec error codes shown in - * the Protocol tab. Labels the code and spec name (e.g. "-32020 HeaderMismatch") - * and, when a description is supplied, explains it on hover. - * - * Uses the filled + `autoContrast` treatment (via {@link filledBadgeColor}) like - * the other semantic badges, so the amber fills clear WCAG AA — a light-variant - * tint of these hues does not. - */ -export function McpErrorBadge({ code, name, description }: McpErrorBadgeProps) { - const badge = ( - - {code} {name} - - ); - if (!description) return badge; - return {badge}; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx deleted file mode 100644 index 1055604e6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Group, Paper, Stack, Text } from "@mantine/core"; -import type { - ContentBlock, - PromptMessage, - SamplingMessage, -} from "@modelcontextprotocol/client"; -import { ContentViewer } from "../ContentViewer/ContentViewer"; - -export interface MessageBubbleProps { - index: number; - message: SamplingMessage | PromptMessage; -} - -function formatRoleLabel(index: number, role: string): string { - return `[${index}] role: ${role}`; -} - -// PromptMessage/SamplingMessage content unions in the SDK are wider than -// ContentBlock (they admit tool_use, tool_result, etc. for the agent -// messages flowing into prompts). ContentViewer renders only the visual -// subset; everything else is silently dropped here. The bubble's role -// header keeps an empty message from being invisible. -const RENDERABLE_TYPES = new Set([ - "text", - "image", - "audio", - "resource", - "resource_link", -]); - -function isRenderableBlock(block: unknown): block is ContentBlock { - if (typeof block !== "object" || block === null) return false; - const t = (block as { type?: string }).type; - return typeof t === "string" && RENDERABLE_TYPES.has(t); -} - -// Prompt content blocks don't carry a mimeType on the text variant -// (SDK `TextContent` is just `{ type: "text", text }`). Render text as -// markdown by default so prompt prose with code fences, lists, and links -// looks like prose rather than a preformatted dump. Image / audio blocks -// already carry mimeType; ContentViewer routes them itself. -// -// Caveat: this is unconditional — a server that emits a raw shell -// snippet, log line, or string containing `#` / `_` / backticks will -// have it transformed. Most prompts are prose so the trade-off is -// worth it, but this differs from the resource side (where -// ResourcePreviewPanel only promotes to markdown when the server -// supplies `text/markdown` or the URI suffix matches). If the MCP -// spec ever adds a per-block mimeType for prompt messages, switch -// back to opt-in rendering here. -function effectiveMimeForBlock(block: ContentBlock): string | undefined { - if (block.type === "text") return "text/markdown"; - return undefined; -} - -const BubbleContainer = Paper.withProps({ - p: "md", - radius: "md", - withBorder: true, -}); - -const RoleLabel = Text.withProps({ - size: "xs", - c: "dimmed", - ff: "monospace", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", -}); - -export function MessageBubble({ index, message }: MessageBubbleProps) { - const content = message.content; - const rawBlocks = Array.isArray(content) ? content : [content]; - const blocks = rawBlocks.filter(isRenderableBlock); - - return ( - - - - {formatRoleLabel(index, message.role)} - - {blocks.map((block, blockIndex) => ( - - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx deleted file mode 100644 index 890dd3324..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Badge } from "@mantine/core"; - -export interface MessageDirectionBadgeProps { - /** - * Direction of travel for the entry: "outgoing" = the inspector sent it to - * the server (client → server); "incoming" = the server sent it to the - * inspector (server → client). - */ - direction: "outgoing" | "incoming"; -} - -const LABEL: Record = { - outgoing: "client → server", - incoming: "server → client", -}; - -const BG: Record = { - outgoing: "var(--inspector-badge-outgoing-bg)", - incoming: "var(--inspector-badge-incoming-bg)", -}; - -const FG: Record = { - outgoing: "var(--inspector-badge-outgoing-fg)", - incoming: "var(--inspector-badge-incoming-fg)", -}; - -/** - * Dual-state badge showing which way a Protocol/Network entry traveled. Outgoing - * (client → server) is green; incoming (server → client) is purple — not yellow, - * which (paired with green) reads as caution/ok status rather than direction. - * Surfaces come from `--inspector-badge-*` tokens: a tinted fill in light mode, - * a deep saturated fill with light text in dark mode. Shared by `ProtocolEntry` - * and `NetworkEntry`. - */ -export function MessageDirectionBadge({ - direction, -}: MessageDirectionBadgeProps) { - return ( - - {LABEL[direction]} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx deleted file mode 100644 index b1ea6a496..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Badge } from "@mantine/core"; - -export interface MethodBadgeProps { - /** Protocol/RPC method name, e.g. "tools/list". */ - method: string; -} - -const MethodChip = Badge.withProps({ - autoContrast: false, - bg: "var(--inspector-badge-method-bg)", - c: "var(--inspector-badge-method-fg)", -}); - -/** - * Badge labelling a Protocol/Network entry's method. A neutral charcoal chip with - * light text, driven by `--inspector-badge-method-*` so it stays legible (not a - * washed-out pale fill) in dark mode. Shared by `ProtocolEntry` and `NetworkEntry`. - */ -export function MethodBadge({ method }: MethodBadgeProps) { - return {method}; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx deleted file mode 100644 index 3336d9f19..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { TiPin, TiPinOutline } from "react-icons/ti"; - -export interface PinToggleProps { - /** Whether the owning entry is currently pinned. */ - pinned: boolean; - onToggle: () => void; -} - -const PinActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", -}); - -/** - * Icon toggle for pinning an entry. Unpinned shows an outline pin; pinned shows - * a filled pin. The aria-label stays "Pin"/"Unpin" so it reads the same as the - * text button it replaces. - */ -export function PinToggle({ pinned, onToggle }: PinToggleProps) { - const Icon = pinned ? TiPin : TiPinOutline; - const label = pinned ? "Unpin" : "Pin"; - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx deleted file mode 100644 index db47a9c74..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Group, Progress, Stack, Text } from "@mantine/core"; -import type { ProgressNotification } from "@modelcontextprotocol/client"; - -export interface ProgressDisplayProps { - params: Pick< - ProgressNotification["params"], - "progress" | "total" | "message" - >; - elapsed?: string; -} - -function computePercent(progress: number, total?: number): number { - if (total != null && total > 0) { - return Math.round((progress / total) * 100); - } - return progress; -} - -function formatPercent(percent: number): string { - return `${percent}%`; -} - -const ProgressLabel = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const ElapsedText = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -export function ProgressDisplay({ params, elapsed }: ProgressDisplayProps) { - const percent = computePercent(params.progress, params.total); - - return ( - - - {params.message && {params.message}} - {formatPercent(percent)} - - - {elapsed && {elapsed}} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx deleted file mode 100644 index 9d2fdc664..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { MdReplay } from "react-icons/md"; - -export interface ReplayButtonProps { - /** Re-send the owning history request. */ - onReplay: () => void; -} - -const ReplayActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", - "aria-label": "Replay", -}); - -/** - * Icon form of the "Replay" action, used in the compact (column) ProtocolEntry - * layout where the text button is replaced by a replay icon sitting next to the - * pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling. - */ -export function ReplayButton({ onReplay }: ReplayButtonProps) { - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx deleted file mode 100644 index a9cc1e4b6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { ReactNode } from "react"; -import { Badge, Group, Stack, Text } from "@mantine/core"; -import { CopyButton } from "../CopyButton/CopyButton"; - -export interface ResourceLinkInfoProps { - /** The linked resource's URI (always shown, with a copy button). */ - uri: string; - /** Optional human-friendly name shown above the URI. */ - name?: string; - /** Optional MIME type shown as a badge. */ - mimeType?: string; - /** - * Optional trailing element placed at the end of the URI row (opposite the - * copy button) — e.g. an expand/collapse control supplied by an interactive - * wrapper. Mirrors ProtocolEntry, whose toggle sits on the row below the - * badges. - */ - action?: ReactNode; -} - -const HeaderStack = Stack.withProps({ - gap: 4, -}); - -// Name (left) + MIME badge (right). `justify` is set per-instance: spread when -// a name is present, otherwise the badge hugs the right. -const HeaderRow = Group.withProps({ - wrap: "nowrap", - gap: "xs", - align: "center", -}); - -// Copy control + URI (left) and the optional expand/collapse control (right), -// on the line below the header — mirroring ProtocolEntry's controls row. -const UriRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "xs", - align: "center", -}); - -// Copy button + URI cluster; flexes so the URI fills and the action stays right. -const UriCluster = Group.withProps({ - wrap: "nowrap", - gap: "xs", - align: "center", - flex: 1, - miw: 0, -}); - -// Match how ProtocolEntry/NetworkEntry render a URL: `sm` / `fw: 500`, in the -// default sans-serif face and text color (not a blue monospace "link"). The -// `monoBreak` variant only adds `word-break: break-all` so a long URI wraps -// within the card instead of overflowing. -const UriText = Text.withProps({ - size: "sm", - fw: 500, - variant: "monoBreak", - flex: 1, - miw: 0, -}); - -const MimeBadge = Badge.withProps({ - // Match the point size of the ProtocolEntry method/status badges; the - // lowercase MIME text reads smaller than their uppercase labels at `sm`. - size: "md", - radius: "sm", - // MIME types are conventionally lowercase; keep them as-is rather than - // letting Badge's default uppercase transform mangle them. - tt: "none", - autoContrast: false, - // Light mode: the tinted blue-light chip (unchanged). Dark mode: a solid - // dark-blue fill with white text — matching the solid ProtocolEntry badges - // rather than a washed-out translucent tint. - bg: "light-dark(var(--mantine-color-blue-light), var(--mantine-color-blue-9))", - c: "light-dark(var(--mantine-color-blue-light-color), var(--mantine-color-white))", -}); - -const NameText = Text.withProps({ - size: "sm", - fw: 600, - flex: 1, - miw: 0, -}); - -/** - * Pure-display metadata for a `resource_link`: an optional name and MIME-type - * badge on the header row, then the URI on the line below with a copy button. - * The URI is styled like ProtocolEntry/NetworkEntry URLs (`sm` / `fw: 500`, - * default sans-serif face and color), not a blue monospace link. The optional - * `action` slot lets an interactive wrapper (e.g. {@link ResourceLink}) place - * an expand/collapse control at the end of the URI row. - */ -export function ResourceLinkInfo({ - uri, - name, - mimeType, - action, -}: ResourceLinkInfoProps) { - const hasHeader = Boolean(name || mimeType); - return ( - - {hasHeader && ( - - {name && {name}} - {mimeType && {mimeType}} - - )} - - - - {uri} - - {action} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx deleted file mode 100644 index de77d345f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Select } from "@mantine/core"; -import { TbSortAscending2, TbSortDescending2 } from "react-icons/tb"; - -export type SortDirection = "oldest-first" | "newest-first"; - -export interface SortToggleProps { - value: SortDirection; - onChange: (next: SortDirection) => void; - "aria-label"?: string; -} - -const OPTIONS: { value: SortDirection; label: string }[] = [ - { value: "newest-first", label: "Newest First" }, - { value: "oldest-first", label: "Oldest First" }, -]; - -function isSortDirection(value: string | null): value is SortDirection { - return value === "oldest-first" || value === "newest-first"; -} - -const SortSelect = Select.withProps({ - size: "sm", - w: 150, - allowDeselect: false, - withCheckIcon: false, -}); - -export function SortToggle({ - value, - onChange, - "aria-label": ariaLabel = "Sort direction", -}: SortToggleProps) { - const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; - return ( - { - // The guard's false arm is unreachable through the UI: Mantine's `data` - // only holds the two valid SortDirection values and allowDeselect={false} - // prevents a null deselect, so isSortDirection() is always true here. - /* v8 ignore next */ - if (isSortDirection(next)) onChange(next); - }} - rightSection={} - aria-label={ariaLabel} - /> - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx deleted file mode 100644 index cab899b0b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button } from "@mantine/core"; - -export interface SubscribeButtonProps { - subscribed: boolean; - onToggle: () => void; -} - -const ToggleButton = Button.withProps({ - variant: "filled", - size: "sm", -}); - -export function SubscribeButton({ - subscribed, - onToggle, -}: SubscribeButtonProps) { - return ( - - {subscribed ? "Unsubscribe" : "Subscribe"} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx deleted file mode 100644 index ec67545be..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Badge, Tooltip } from "@mantine/core"; -import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js"; -import { subscriptionStreamPresentation } from "./subscriptionStreamUtils"; - -export interface SubscriptionStreamBadgeProps { - /** Lifecycle status of the modern `subscriptions/listen` stream. */ - status: ResourceSubscriptionStreamStatus; -} - -const StreamTooltip = Tooltip.withProps({ - multiline: true, - w: 260, - withArrow: true, -}); - -/** - * Status indicator for the modern-era resource-subscription listen stream - * (#1630). Only meaningful on the modern era — the caller gates rendering on - * `streamState.active`. Renders a labelled dot badge (green/yellow/gray) in the - * Subscriptions section header, explaining the stream in a tooltip. - */ -export function SubscriptionStreamBadge({ - status, -}: SubscriptionStreamBadgeProps) { - const { color, label, tooltip } = subscriptionStreamPresentation(status); - return ( - - - {label} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts deleted file mode 100644 index c2c525368..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js"; - -export interface StreamPresentation { - /** Mantine palette color name conveying the status. */ - color: string; - /** Short label shown on the panel badge. */ - label: string; - /** Full explanation shown in the tooltip (both variants). */ - tooltip: string; -} - -const STREAM_INTRO = - "On modern (2026-07-28) servers, resource subscriptions are a filter over one long-lived subscriptions/listen stream."; - -// Keyed by status so it's exhaustive at compile time: a new -// `ResourceSubscriptionStreamStatus` that isn't handled here is a type error -// (no unreachable `default` needed, so it stays fully covered). -const PRESENTATION: Record< - ResourceSubscriptionStreamStatus, - StreamPresentation -> = { - connecting: { - color: "blue", - label: "Connecting…", - tooltip: `${STREAM_INTRO} Opening the stream — waiting for the server to acknowledge the subscription.`, - }, - acknowledged: { - color: "green", - label: "Listening", - tooltip: `${STREAM_INTRO} The server acknowledged the subscription and the stream is open, carrying resources/updated notifications.`, - }, - reconnecting: { - color: "yellow", - label: "Reconnecting…", - tooltip: `${STREAM_INTRO} The stream dropped unexpectedly; re-listening to re-establish it (there is no resumability, so the full filter is re-sent).`, - }, - ended: { - color: "gray", - label: "Stream ended", - tooltip: `${STREAM_INTRO} The stream is closed and won't reconnect on its own — either the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again.`, - }, -}; - -/** - * Maps a modern listen-stream status to its badge color, label, and tooltip - * copy (#1630). Kept in its own module so the badge component file exports only - * a component (react-refresh rule). - */ -export function subscriptionStreamPresentation( - status: ResourceSubscriptionStreamStatus, -): StreamPresentation { - return PRESENTATION[status]; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts deleted file mode 100644 index 79e5da83e..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Maps a bare Mantine color name to its scheme-aware `*-light-color` variable - * for use as **colored text**. A bare `c="yellow"` resolves to a mid `filled` - * shade that fails WCAG AA on light surfaces (amber/green/red text on white or - * on the `inspector-light` selected-chip tint land at ~3–4:1). The `-light-color` - * variable is scheme-aware — the app darkens it to shade 8/9 in light mode (see - * the `App.css` light-scheme block) and Mantine keeps a lighter shade in dark - * mode — so the same token clears AA against both light and dark backgrounds. - * - * `"dimmed"` is passed through unchanged (already AA-tuned to gray-7 / dark-1 in - * `App.css`), as is `undefined` (inherit the default body text color). - */ -export function accessibleTextColor(color?: string): string | undefined { - if (!color || color === "dimmed") return color; - return `var(--mantine-color-${color}-light-color)`; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts deleted file mode 100644 index 75f8a608c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Amber fills (`orange` / `yellow`) at Mantine's default filled shade land right - * at `autoContrast`'s luminance threshold, so it picks WHITE text that fails - * WCAG AA on amber (~2–4:1 in both schemes). Pinning those two colors to shade 5 - * keeps the fill bright while moving `autoContrast` decisively onto BLACK text - * (~9–10:1). Every other color is returned unchanged — its default - * filled + `autoContrast` pairing already clears AA. - * - * Used by the filled semantic badges (annotation / task-status / log-level) so - * the amber-contrast fix lives in one place rather than each color map. - */ -export function filledBadgeColor(color: string): string { - if (color === "yellow") return "yellow.5"; - if (color === "orange") return "orange.5"; - return color; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx deleted file mode 100644 index f157cc79d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { - Group, - ScrollArea, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { Tool } from "@modelcontextprotocol/client"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { AppListItem } from "../AppListItem/AppListItem"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface AppControlsProps { - tools: Tool[]; - selectedName?: string; - // Search text is controlled by the parent (App, via AppsScreen) so it - // persists across tab navigation within a live session — see #1417. - searchText?: string; - listChanged: boolean; - onRefreshList: () => void; - onSearchChange: (value: string) => void; - onSelectApp: (name: string) => void; -} - -const LIST_MAX_HEIGHT = - "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)"; - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -export function AppControls({ - tools, - selectedName, - searchText = "", - listChanged, - onRefreshList, - onSearchChange, - onSelectApp, -}: AppControlsProps) { - const viewportRef = useScrollMemory("apps-sidebar"); - const query = searchText.toLowerCase(); - const filteredTools = searchText - ? tools.filter( - (tool) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false), - ) - : tools; - - return ( - - - MCP Apps ({tools.length}) - - - onSearchChange(e.currentTarget.value)} - rightSectionPointerEvents="auto" - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - - {filteredTools.length === 0 ? ( - - {tools.length === 0 ? "No apps available" : "No matching apps"} - - ) : ( - filteredTools.map((tool) => ( - { - if (tool.name !== selectedName) onSelectApp(tool.name); - }} - /> - )) - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx deleted file mode 100644 index 8d5a2135d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core"; -import { MdPlayArrow } from "react-icons/md"; -import type { Tool } from "@modelcontextprotocol/client"; -import { SchemaForm } from "../SchemaForm/SchemaForm"; -import { hasInputFields } from "../../../utils/toolUtils"; -import { - hasMissingRequiredFields, - toFormSchema, -} from "../../../utils/jsonUtils"; - -export interface AppDetailPanelProps { - tool: Tool; - formValues: Record; - isOpening: boolean; - onFormChange: (values: Record) => void; - onOpenApp: () => void; -} - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Fills the available height inside AppsScreen's full-height card and scrolls -// the form (description + fields + Open App) when it would overflow, instead of -// bleeding past the viewport. `mih: 0` lets it shrink within the flex parent; -// standalone (no flex parent) it just sizes to content. -const PanelScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, -}); - -const OpenAppButton = Button.withProps({ - size: "md", - fullWidth: true, - leftSection: , -}); - -export function AppDetailPanel({ - tool, - formValues, - isOpening, - onFormChange, - onOpenApp, -}: AppDetailPanelProps) { - const { description, inputSchema } = tool; - // Narrow the SDK protocol schema to the form renderer's schema type. A Tool's - // `inputSchema` is always an object per the SDK types, so `toFormSchema` never - // returns null here — the `?? {}` is a defensive fallback that can't be hit. - /* v8 ignore next -- unreachable: Tool.inputSchema is always an object */ - const formSchema = toFormSchema(inputSchema) ?? {}; - const hasErrors = hasMissingRequiredFields(formSchema, formValues); - const disabled = isOpening || hasErrors; - const hasFields = hasInputFields(tool); - - return ( - - - {description && {description}} - - {hasFields && } - - {/* Form stays editable while validation fails so users can finish - filling required fields. The disabled-when-incomplete gate is on - the Open App button below, not on the form itself. */} - - - - Open App - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx deleted file mode 100644 index 27b4a5b6b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core"; -import { MdChevronRight } from "react-icons/md"; -import type { Tool } from "@modelcontextprotocol/client"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; - -export interface AppListItemProps { - tool: Tool; - selected: boolean; - onClick: () => void; -} - -const ItemLabel = Text.withProps({ - fw: 500, - truncate: true, -}); - -const ItemDescription = Text.withProps({ - size: "xs", - c: "dimmed", - lineClamp: 2, -}); - -const ItemBody = Stack.withProps({ - gap: 2, - flex: 1, - miw: 0, -}); - -const Row = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "flex-start", -}); - -const AppIcon = Image.withProps({ - w: 20, - h: 20, - fit: "contain", -}); - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export function AppListItem({ tool, selected, onClick }: AppListItemProps) { - const { name, title, description, icons } = tool; - const iconSrc = icons?.[0]?.src; - - return ( - - - {iconSrc && } - - {resolveDisplayLabel(name, title)} - {description && {description}} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx deleted file mode 100644 index 3197bd3a6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { - Button, - Group, - Select, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { LoggingLevel, ProtocolEra } from "@modelcontextprotocol/client"; -import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton"; -import { isModernEra } from "../../elements/EraBadge/eraUtils"; - -const LOG_LEVELS: LoggingLevel[] = [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency", -]; - -const LEVEL_COLORS: Record = { - debug: { c: "dimmed" }, - info: { c: "blue" }, - notice: { c: "teal" }, - warning: { c: "yellow" }, - error: { c: "red" }, - critical: { c: "red" }, - alert: { c: "red" }, - emergency: { c: "red" }, -}; - -// Sentinel `Select` value for "don't opt in" on the modern per-request control. -// Not a valid `LoggingLevel`, so it can never collide with a real level. -const MODERN_OFF_VALUE = "__off__"; - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -const HelpText = Text.withProps({ - size: "xs", - c: "var(--inspector-text-secondary)", -}); - -const ActiveLevelSelect = Select.withProps({ - "aria-label": "Set Active Level", - flex: 1, -}); - -const PerRequestLevelSelect = Select.withProps({ - "aria-label": "Log Level per Request", - allowDeselect: false, -}); - -const LEVEL_OPTIONS = LOG_LEVELS.map((level) => ({ - value: level, - label: level, -})); - -export interface LogControlsProps { - currentLevel: LoggingLevel; - filterText: string; - visibleLevels: Record; - onSetLevel: (level: LoggingLevel) => void; - onFilterChange: (text: string) => void; - onToggleLevel: (level: LoggingLevel, visible: boolean) => void; - onToggleAllLevels: () => void; - /** - * Negotiated protocol era. On the modern era (2026-07-28) `logging/setLevel` - * is gone; the level selector is replaced by the per-request opt-in control - * below. Undefined / legacy keeps the session-scoped `Set` selector (#1629). - */ - protocolEra?: ProtocolEra; - /** - * Modern-era per-request log level currently stamped on every request, or - * `null` when not opted in (no logs). Only meaningful on the modern era. - */ - modernLogLevel?: LoggingLevel | null; - /** Set (or clear, with `null`) the modern per-request log level. */ - onSetModernLogLevel?: (level: LoggingLevel | null) => void; -} - -// Legacy: session-scoped `logging/setLevel` — a level selector plus a "Set" -// button that sends the request. The value is optimistic (there's no echo). -const LegacyLevelControl = ({ - currentLevel, - onSetLevel, -}: Pick) => ( - <> - Set Active Level - - { - if (value && LOG_LEVELS.includes(value as LoggingLevel)) { - onSetLevel(value as LoggingLevel); - } - }} - /> - - - -); - -// Modern: per-request opt-in via the `io.modelcontextprotocol/logLevel` `_meta` -// key. There is no session level and no `Set` — the chosen level is stamped on -// every subsequent request and takes effect immediately. "Off" stops opting in. -const ModernLevelControl = ({ - modernLogLevel, - onSetModernLogLevel, -}: Pick) => ( - <> - Log Level per Request - - Modern servers only emit logs for requests that opt in. The level you - choose is stamped on every request, and logs arrive on the originating - request's stream. Choose Off to stop requesting logs. - - { - if (value === MODERN_OFF_VALUE) { - onSetModernLogLevel?.(null); - } else if (value && LOG_LEVELS.includes(value as LoggingLevel)) { - onSetModernLogLevel?.(value as LoggingLevel); - } - }} - /> - -); - -export function LogControls({ - currentLevel, - filterText, - visibleLevels, - onSetLevel, - onFilterChange, - onToggleLevel, - onToggleAllLevels, - protocolEra, - modernLogLevel = null, - onSetModernLogLevel, -}: LogControlsProps) { - return ( - - Logging - - onFilterChange(e.currentTarget.value)} - rightSectionPointerEvents="auto" - rightSection={ - filterText ? onFilterChange("")} /> : null - } - /> - - {isModernEra(protocolEra) ? ( - - ) : ( - - )} - - - Filter by Level - - {Object.values(visibleLevels).every(Boolean) - ? "Deselect All" - : "Select All"} - - - - {LOG_LEVELS.map((level) => ( - onToggleLevel(level, visible)} - /> - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx deleted file mode 100644 index 8302f5d40..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useMemo } from "react"; -import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; -import type { LoggingLevel } from "@modelcontextprotocol/client"; -import { LogEntry } from "../../elements/LogEntry/LogEntry"; -import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import { - SortToggle, - type SortDirection, -} from "../../elements/SortToggle/SortToggle"; -import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface LogStreamPanelProps { - entries: LogEntryData[]; - filterText: string; - visibleLevels: Record; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - /** - * True when this panel is rendered inside the monitoring sidebar. Switches the - * scroll region from the viewport-height calc to filling its flex parent, so - * it fits below the column's controls row without viewport math. - */ - embedded?: boolean; -} - -const PanelContainer = Paper.withProps({ - withBorder: true, - p: "lg", - flex: 1, - variant: "panel", -}); - -const EmptyCenter = Stack.withProps({ - flex: 1, - align: "center", - justify: "center", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - mb: "sm", -}); - -function formatData(data: unknown): string { - if (data === undefined || data === null) return ""; - if (typeof data === "string") return data; - return JSON.stringify(data); -} - -function matchesFilters( - entry: LogEntryData, - filterText: string, - visibleLevels: Record, - // The embedded column exposes only the search box (no level toggles), so it - // applies the text filter but skips the level filter (#1616). - ignoreLevels: boolean, -): boolean { - if (!ignoreLevels && !visibleLevels[entry.params.level]) return false; - if (filterText) { - const term = filterText.toLowerCase(); - const searchable = - `${formatData(entry.params.data)} ${entry.params.logger ?? ""} ${entry.params.level}`.toLowerCase(); - if (!searchable.includes(term)) return false; - } - return true; -} - -export function LogStreamPanel({ - entries, - filterText, - visibleLevels, - onClear, - onExport, - sortDirection, - onSortChange, - embedded = false, -}: LogStreamPanelProps) { - const viewportRef = useScrollMemory("logs-stream"); - const filteredEntries = useMemo(() => { - // The embedded column has only the search box (its level toggles live in the - // full-size sidebar), so it filters by text but ignores the level filter - // (#1616). `.filter()` returns a fresh array, so sorting in-place is safe. - const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleLevels, embedded)) - .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); - if (sortDirection === "newest-first") sorted.reverse(); - return sorted; - }, [entries, filterText, visibleLevels, sortDirection, embedded]); - - return ( - - - Log Stream - - - - - - - {filteredEntries.length > 0 ? ( - - - {filteredEntries.map((entry, index) => ( - // Compact (two-line) layout inside the narrow monitoring sidebar; - // the full single-line row on the standalone Logs screen. (#1661) - - ))} - - - ) : ( - - No log entries - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx deleted file mode 100644 index 13ed689c4..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Button, Group, Stack, Title } from "@mantine/core"; -import type { MessageOrigin } from "@inspector/core/mcp/types.js"; -import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton"; - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -// h5 (not h6) to sit one level below the screen's h4 heading (avoids an -// axe `heading-order` skip); `size="h6"` preserves the visual size. -const SectionTitle = Title.withProps({ - order: 5, - size: "h6", -}); - -// The two message directions, in display order. Label + color mirror the -// MessageDirectionBadge: outgoing (client → server) is green, incoming -// (server → client) is violet. -const MESSAGE_DIRECTIONS: { - origin: MessageOrigin; - label: string; - color: string; -}[] = [ - { origin: "client", label: "client → server", color: "green" }, - { origin: "server", label: "server → client", color: "violet" }, -]; - -export interface MessageDirectionFilterProps { - visibleDirections: Record; - onToggleDirection: (direction: MessageOrigin, visible: boolean) => void; - onToggleAllDirections: () => void; -} - -/** - * "Filter by Message Direction" section — a Select/Deselect All control plus a - * FilterToggleButton per direction (client → server / server → client). Used by - * the Protocol controls. (Kept as its own component so the section is testable in - * isolation and reusable if another screen ever needs a direction filter.) - */ -export function MessageDirectionFilter({ - visibleDirections, - onToggleDirection, - onToggleAllDirections, -}: MessageDirectionFilterProps) { - return ( - <> - - Filter by Message Direction - - {Object.values(visibleDirections).every(Boolean) - ? "Deselect All" - : "Select All"} - - - - {MESSAGE_DIRECTIONS.map(({ origin, label, color }) => ( - onToggleDirection(origin, visible)} - /> - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx deleted file mode 100644 index 0ad178043..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useState } from "react"; -import { - Badge, - Collapse, - Divider, - Group, - Paper, - Stack, - Text, -} from "@mantine/core"; -import type { MessageEntry } from "@inspector/core/mcp/types.js"; -import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; -import { extractMethod, extractResultType } from "../protocolUtils.js"; -import { useValueChange } from "../../../hooks/useValueChange"; - -export interface MrtrConversationProps { - /** The opaque MRTR token that links this conversation's rounds. */ - requestState: string; - /** The entries belonging to this conversation (one per JSON-RPC id). */ - rounds: MessageEntry[]; - /** Which of this conversation's rounds are pinned, by entry id. */ - pinnedIds: Set; - /** Whether rounds start expanded (mirrors the list-level compact toggle). */ - isListExpanded: boolean; - /** Compact per-round layout for the narrow monitoring column. */ - embedded?: boolean; - onReplay: (id: string) => void; - onTogglePin: (id: string) => void; -} - -// MRTR (multi-round-trip request, spec §7.3) makes one logical operation span -// several JSON-RPC ids: the original call returns `input_required`, the client -// answers and retries with a NEW id echoing `requestState`, repeating until a -// final `complete` result. This groups those rounds into one expandable unit so -// the operation reads as a single conversation instead of scattered calls. - -const ConversationContainer = Paper.withProps({ - withBorder: true, - p: "md", - radius: "md", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -const HeaderLeft = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, -}); - -const HeaderRight = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -const MrtrLabel = Text.withProps({ - size: "sm", - fw: 600, - c: "dimmed", -}); - -const RoundLabel = Text.withProps({ - size: "xs", - fw: 600, - c: "dimmed", -}); - -const RoundCountBadge = Badge.withProps({ - color: "blue", - variant: "outline", -}); - -type ConversationStatus = "pending" | "awaiting" | "error" | "complete"; - -// The conversation's status is that of its final (latest) round: still awaiting -// input if the last result is `input_required`, otherwise the ordinary -// pending/error/complete lifecycle of that round. -function conversationStatus(finalRound: MessageEntry): ConversationStatus { - if (!finalRound.response) return "pending"; - if ("error" in finalRound.response) return "error"; - if (extractResultType(finalRound) === "input_required") return "awaiting"; - return "complete"; -} - -function statusColor(status: ConversationStatus): string { - switch (status) { - case "complete": - return "green"; - case "error": - return "red"; - case "awaiting": - return "yellow"; - default: - return "gray"; - } -} - -function statusLabel(status: ConversationStatus): string { - switch (status) { - case "complete": - return "Complete"; - case "error": - return "Error"; - case "awaiting": - return "Awaiting input"; - default: - return "Pending"; - } -} - -function formatRoundsLabel(count: number): string { - return count === 1 ? "1 round" : `${count} rounds`; -} - -function formatRoundLabel(index: number): string { - return `Round ${index + 1}`; -} - -export function MrtrConversation({ - requestState, - rounds, - pinnedIds, - isListExpanded, - embedded = false, - onReplay, - onTogglePin, -}: MrtrConversationProps) { - const [isExpanded, setIsExpanded] = useState(isListExpanded); - - // The list-level Expand/Collapse toggle is authoritative: any per-entry - // override is discarded whenever the parent changes `isListExpanded`. - useValueChange(isListExpanded, setIsExpanded); - - // Always read the conversation chronologically (original → retries → final), - // regardless of the list's newest-first/oldest-first sort. - const ordered = [...rounds].sort( - (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), - ); - const method = extractMethod(ordered[0]); - const status = conversationStatus(ordered[ordered.length - 1]); - - return ( - - - - - - MRTR - - {formatRoundsLabel(ordered.length)} - - - - - {statusLabel(status)} - - setIsExpanded((v) => !v)} - /> - - - - - - - {ordered.map((round, index) => ( - - {formatRoundLabel(index)} - onReplay(round.id)} - onTogglePin={() => onTogglePin(round.id)} - /> - - ))} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx deleted file mode 100644 index 7bd2be2af..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Button, Group, Stack, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; -import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton"; - -const NETWORK_CATEGORIES: FetchRequestCategory[] = ["auth", "transport"]; - -const CATEGORY_COLORS: Record = { - auth: "violet", - transport: "blue", -}; - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -export interface NetworkControlsProps { - filterText: string; - visibleCategories: Record; - onFilterChange: (text: string) => void; - onToggleCategory: (category: FetchRequestCategory, visible: boolean) => void; - onToggleAllCategories: () => void; -} - -export function NetworkControls({ - filterText, - visibleCategories, - onFilterChange, - onToggleCategory, - onToggleAllCategories, -}: NetworkControlsProps) { - const allSelected = NETWORK_CATEGORIES.every((c) => visibleCategories[c]); - return ( - - Network - - onFilterChange(e.currentTarget.value)} - rightSectionPointerEvents="auto" - rightSection={ - filterText ? onFilterChange("")} /> : null - } - /> - - - Filter by Category - - {allSelected ? "Deselect All" : "Select All"} - - - - {NETWORK_CATEGORIES.map((category) => ( - onToggleCategory(category, visible)} - /> - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx deleted file mode 100644 index 7ef790bc5..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ /dev/null @@ -1,628 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - Badge, - Button, - Card, - Collapse, - Divider, - Group, - ScrollArea, - Stack, - Table, - Text, - Tooltip, -} from "@mantine/core"; -import { RiErrorWarningLine } from "react-icons/ri"; -import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; -import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; -import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge"; -import { maskSecretsInBody } from "../../../utils/maskSecrets"; -import { useValueChange } from "../../../hooks/useValueChange"; -import { - oauthNetworkPhase, - oauthNetworkPhaseLabel, -} from "../../../utils/oauthNetworkPhase"; -import { - checkHeaderConsistency, - decodeMcpParamValue, - isCancellationAbort, - isMcpHeader, - type HeaderConsistency, -} from "../../../utils/mcpNetworkHeaders"; - -export interface NetworkEntryProps { - entry: FetchRequestEntry; - isListExpanded: boolean; - /** - * Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is - * time + method + category + duration + status; line 2 is the URL in a - * horizontal scroll area with the expand toggle on the right. - */ - embedded?: boolean; - /** - * When true, this entry was targeted by a "reveal in Network" jump (from a - * correlated Protocol error): it scrolls itself into view and force-expands - * once, then calls {@link onRevealComplete} so the one-shot signal clears. - */ - revealed?: boolean; - onRevealComplete?: () => void; -} - -const EntryContainer = Card.withProps({ - withBorder: true, - padding: "md", - variant: "inset", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -const TimestampText = Text.withProps({ - size: "sm", - c: "dimmed", - ff: "monospace", -}); - -const UrlText = Text.withProps({ - size: "sm", - fw: 500, - truncate: "end", -}); - -// Compact-header URL: never wraps, so a long URL scrolls horizontally inside its -// ScrollArea instead of wrapping to many lines. -const UrlScroll = Text.withProps({ - size: "sm", - fw: 500, - variant: "nowrap", -}); - -// Left / right clusters for a compact header line (mirrors ProtocolEntry). -const HeaderCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, -}); - -const ControlsCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -const DurationText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Muted note for empty/placeholder states (no headers, empty body, uncaptured -// stream) and the secrets-hidden status line. -const DimmedNote = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -// Heading above each expanded-detail section (Request/Response Headers/Body). -const SectionLabel = Text.withProps({ - size: "sm", - fw: 500, -}); - -// Cap is in JS string `.length` units (UTF-16 code units), not bytes — for -// multi-byte content the wire size is larger, but the limit's purpose is -// to keep the DOM from drowning in a single Code block so character count -// is the right unit. -const MAX_INLINE_BODY_CHARS = 100_000; - -function formatDuration(ms: number): string { - return `${ms}ms`; -} - -function formatTimestamp(date: Date): string { - return date.toISOString(); -} - -// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO -// string would eat most of the narrow line-1 width (#1616). -function formatTimestampCompact(date: Date): string { - return date.toISOString().slice(11, 19); -} - -function statusColor(entry: FetchRequestEntry): string { - // A cancelled request surfaces as a connection abort under the modern - // transport; render it neutrally rather than as a hard error (SEP-2575). - if (isCancellationAbort(entry)) return "gray"; - if (entry.error) return "red"; - const status = entry.responseStatus; - if (status === undefined) return "gray"; - if (status >= 500) return "red"; - if (status >= 400) return "orange"; - if (status >= 300) return "yellow"; - if (status >= 200) return "green"; - return "gray"; -} - -function statusLabel(entry: FetchRequestEntry): string { - if (isCancellationAbort(entry)) return "Cancelled"; - if (entry.error) return "Error"; - if (entry.responseStatus === undefined) return "Pending"; - return entry.responseStatusText - ? `${entry.responseStatus} ${entry.responseStatusText}` - : `${entry.responseStatus}`; -} - -function isLongLivedStream(entry: FetchRequestEntry): boolean { - return isLongLivedStreamResponse( - entry.method, - entry.responseHeaders?.["content-type"], - ); -} - -// Header-table cell text. A modern MCP-mirrored header name gets a violet accent -// so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version) -// stand out from ordinary ones; a value that disagrees with the request body is -// shown in the danger colour. -const HeaderNameText = Text.withProps({ - size: "xs", - ff: "monospace", - fw: 500, -}); - -const McpHeaderNameText = Text.withProps({ - size: "xs", - ff: "monospace", - fw: 600, - c: "var(--inspector-mcp-header-accent)", -}); - -const HeaderValueText = Text.withProps({ - size: "xs", - ff: "monospace", - variant: "monoBreak", -}); - -const MismatchValueText = Text.withProps({ - size: "xs", - ff: "monospace", - variant: "monoBreak", - c: "var(--inspector-danger-text)", -}); - -const MismatchMarker = Text.withProps({ - component: "span", - // role="img" makes the aria-label permitted on the span (it wraps a decorative - // icon) and announces the mismatch to assistive tech. - role: "img", - c: "var(--inspector-danger-text)", -}); - -// The decoded value plus its optional base64 / mismatch markers, on one line. -const ValueCellRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - align: "center", -}); - -// Tooltip for a base64 sentinel value or a header/body mismatch. -const SentinelTooltip = Tooltip.withProps({ - withArrow: true, - multiline: true, - w: 280, -}); - -const Base64Badge = Badge.withProps({ - size: "xs", - color: "gray", - variant: "light", -}); - -const HeadersGrid = Table.withProps({ - striped: true, - withColumnBorders: true, - fz: "xs", -}); - -// OAuth flow-phase chip for an `auth`-category request. -const PhaseBadge = Badge.withProps({ - color: "violet", - variant: "light", -}); - -// Compact-header URL row: copy button, horizontal URL scroll, expand toggle. -const CompactUrlRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - justify: "space-between", -}); - -const UrlScrollArea = ScrollArea.withProps({ - scrollbarSize: 6, - flex: 1, - miw: 0, - // The URL scrolls horizontally but has no focusable child, so make the - // viewport itself keyboard-scrollable (WCAG SC 2.1.1). Scrollbar auto-hides - // via the `type="scroll"` theme default. - viewportProps: { tabIndex: 0 }, -}); - -// Wide-header left cluster: timestamp + badges + URL, shrinking to truncate. -const WideHeaderCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, - flex: 1, -}); - -// Trailing expand-toggle row in the wide layout. -const ToggleRow = Group.withProps({ - gap: "xs", - justify: "flex-end", -}); - -const MonoSpan = Text.withProps({ - span: true, - ff: "monospace", -}); - -const ErrorText = Text.withProps({ - size: "xs", - ff: "monospace", - c: "red", -}); - -function HeaderValueCell({ - name, - value, - consistency, -}: { - name: string; - value: string; - consistency?: HeaderConsistency; -}) { - // Only modern MCP headers carry sentinel-encoded values; a plain header is - // shown verbatim (never re-interpreted as base64). - const decoded = isMcpHeader(name) - ? decodeMcpParamValue(value) - : { value, encoded: false, raw: value }; - const mismatch = consistency !== undefined && !consistency.ok; - - return ( - - {mismatch ? ( - {decoded.value} - ) : ( - {decoded.value} - )} - {decoded.encoded && ( - - base64 - - )} - {mismatch && ( - - - - - - )} - - ); -} - -function HeadersTable({ - headers, - consistency, -}: { - headers: Record; - /** Header/body cross-checks (request side only) to flag mismatches. */ - consistency?: HeaderConsistency[]; -}) { - const rows = Object.entries(headers); - if (rows.length === 0) { - return (none); - } - const byHeader = new Map((consistency ?? []).map((row) => [row.header, row])); - return ( - - - {rows.map(([name, value]) => ( - - - {isMcpHeader(name) ? ( - {name} - ) : ( - {name} - )} - - - - - - ))} - - - ); -} - -const CancellationAlert = Alert.withProps({ - variant: "light", - color: "gray", - title: "Request cancelled", - icon: , -}); - -const RevealButton = Button.withProps({ - variant: "subtle", - size: "compact-xs", -}); - -function BodyPreview({ - body, - contentType, -}: { - body: string; - contentType?: string; -}) { - // Reveal state for masked secrets. Hooks run before any early return so the - // order stays stable across the too-large / has-secrets branches. The reveal - // state resets when the body or its content-type changes because callers key - // `` by both (remounting on swap), so a previously-revealed view - // never persists across a content (or masking) change. - const [revealed, setRevealed] = useState(false); - - const tooLarge = body.length > MAX_INLINE_BODY_CHARS; - - // OAuth responses (token exchange, DCR) and the token request carry - // bearer-grade secrets. Mask them by default and gate the raw values behind - // an explicit reveal so they aren't exposed at a glance during a - // screen-share. The entry's content-type scopes which parser runs (so a - // plaintext/HTML error body is never guessed at). Bodies without secrets - // render as-is with no toggle. - // - // Memoized so a Reveal/Hide click (a re-render) doesn't re-parse and re-walk - // the body; the cost is paid once per mount, and the `key={…}` remount on - // body/content-type change re-runs it. Skipped for too-large bodies so we - // never parse something we won't display (the hook must run unconditionally, - // hence the in-memo guard rather than an early return above it). - const { masked, hasSecrets } = useMemo( - () => - tooLarge - ? { masked: body, hasSecrets: false } - : maskSecretsInBody(body, contentType), - [tooLarge, body, contentType], - ); - - if (tooLarge) { - return ( - - Body too large to preview ({body.length} characters) - - ); - } - - if (!hasSecrets) { - return ; - } - - const shown = revealed ? body : masked; - return ( - - - - {revealed ? "Secrets revealed" : "Secrets hidden"} - - setRevealed((v) => !v)} - aria-label={ - revealed ? "Hide secrets in body" : "Reveal secrets in body" - } - > - {revealed ? "Hide" : "Reveal"} - - - - - ); -} - -export function NetworkEntry({ - entry, - isListExpanded, - embedded = false, - revealed = false, - onRevealComplete, -}: NetworkEntryProps) { - // Seeded from both sources so an entry that mounts already targeted by - // "Reveal in Network" starts open — the render-time syncs below only fire on - // a *change*, so neither of them covers the first render. - const [isExpanded, setIsExpanded] = useState(isListExpanded || revealed); - const rootRef = useRef(null); - - // The list-level Expand/Collapse toggle is authoritative: each time the - // parent changes `isListExpanded`, every entry snaps to that state and - // any per-entry override is intentionally discarded. Mirrors - // ProtocolEntry; do not change without aligning both. - useValueChange(isListExpanded, setIsExpanded); - - // "Reveal in Network" one-shot, part 1: force the targeted entry open. This - // is deliberately ordered *after* the list sync above, so that if both change - // in the same render the reveal wins. - useValueChange(revealed, (nextRevealed) => { - if (nextRevealed) setIsExpanded(true); - }); - - // "Reveal in Network" one-shot, part 2: scroll the entry into view, then - // clear the signal. The scroll runs in a rAF so it lands after - // `useScrollMemory`'s layout-effect restore (which would otherwise fight it) - // and after the force-expand above has grown the row. `onRevealComplete` - // clears the parent's `revealId`, which flips `revealed` back to false and re- - // runs this effect's cleanup — so it must fire *inside* the rAF, after the - // scroll, otherwise the cleanup's `cancelAnimationFrame` would race and could - // cancel the very frame doing the scroll. - useEffect(() => { - if (!revealed) return; - const raf = requestAnimationFrame(() => { - rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); - onRevealComplete?.(); - }); - return () => cancelAnimationFrame(raf); - }, [revealed, onRevealComplete]); - - // OAuth flow phase for `auth`-category requests (discovery / registration / - // authorize / token), so the Network tab labels the auth conversation. - const oauthPhase = - entry.category === "auth" ? oauthNetworkPhase(entry.url) : undefined; - const phaseBadge = oauthPhase ? ( - {oauthNetworkPhaseLabel(oauthPhase)} - ) : null; - - // Request header/body cross-checks so a mirrored-header mismatch is visible - // before the server rejects it. (Protocol errors like -32020 are surfaced - // distinctly in the Protocol tab, not here — the Network tab stays focused on - // the HTTP transaction.) - const headerConsistency = useMemo( - () => checkHeaderConsistency(entry), - [entry], - ); - const aborted = isCancellationAbort(entry); - - const metaBadges = ( - <> - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {isLongLivedStream(entry) && SSE} - - {statusLabel(entry)} - - - ); - const expandToggle = ( - setIsExpanded((v) => !v)} - /> - ); - - return ( - - - {embedded ? ( - // Compact two-line header for the narrow column. - - - - - {formatTimestampCompact(entry.timestamp)} - - - - {phaseBadge} - - {metaBadges} - - - - - {entry.url} - - {expandToggle} - - - ) : ( - <> - - - - {formatTimestamp(entry.timestamp)} - - - - {phaseBadge} - - {entry.url} - - {metaBadges} - - - {expandToggle} - - )} - - - - - {aborted && ( - - - Cancellation appears as a connection abort — the modern - transport aborts the request stream instead of sending a{" "} - notifications/cancelled frame (SEP-2575). - - - )} - - Request Headers - - - {entry.requestBody && ( - - Request Body - - - )} - {entry.responseHeaders && ( - - Response Headers - - - )} - {entry.responseStatus !== undefined && ( - - Response Body - {entry.responseBody ? ( - - ) : ( - - {isLongLivedStream(entry) - ? "Long-lived stream — body not captured" - : "(empty)"} - - )} - - )} - {entry.error && ( - - Error - {entry.error} - - )} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx deleted file mode 100644 index 4cb9b9b8f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useMemo } from "react"; -import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; -import type { - FetchRequestCategory, - FetchRequestEntry, -} from "@inspector/core/mcp/types.js"; -import { NetworkEntry } from "../NetworkEntry/NetworkEntry"; -import { ListToggle } from "../../elements/ListToggle/ListToggle"; -import { - SortToggle, - type SortDirection, -} from "../../elements/SortToggle/SortToggle"; -import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface NetworkStreamPanelProps { - entries: FetchRequestEntry[]; - filterText: string; - visibleCategories: Record; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ - embedded?: boolean; - /** Fetch-entry id targeted by a "reveal in Network" jump — that entry scrolls - * into view and force-expands, then clears the signal via onRevealComplete. */ - revealId?: string; - onRevealComplete?: () => void; -} - -const PanelContainer = Paper.withProps({ - withBorder: true, - p: "lg", - flex: 1, - variant: "panel", -}); - -// Centered in the full-height panel so the empty message sits mid-panel rather -// than clinging to the top of an otherwise-empty box (matches LogStreamPanel). -const EmptyCenter = Stack.withProps({ - flex: 1, - align: "center", - justify: "center", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", -}); - -// Panel header: title on the left, action controls on the right. -const HeaderRow = Group.withProps({ - justify: "space-between", - mb: "sm", -}); - -function formatTitle(count: number): string { - return `Requests (${count})`; -} - -function headersToString(headers: Record | undefined): string { - if (!headers) return ""; - return Object.entries(headers) - .map(([k, v]) => `${k}: ${v}`) - .join("\n"); -} - -function matchesFilters( - entry: FetchRequestEntry, - filterText: string, - visibleCategories: Record, - // The embedded column exposes only the search box (no category toggles), so it - // applies the text filter but skips the category filter (#1616). - ignoreCategories: boolean, -): boolean { - if (!ignoreCategories && !visibleCategories[entry.category]) return false; - if (filterText) { - const term = filterText.toLowerCase(); - const status = - entry.responseStatus !== undefined ? String(entry.responseStatus) : ""; - // Per-field match (rather than join + includes) so the search term - // can't span field boundaries — a search for "foo bar" where one - // field ends "foo" and the next begins "bar" should not match. - const fields: string[] = [ - entry.method, - entry.url, - status, - entry.responseStatusText ?? "", - headersToString(entry.requestHeaders), - headersToString(entry.responseHeaders), - entry.requestBody ?? "", - entry.responseBody ?? "", - entry.error ?? "", - ]; - if (!fields.some((f) => f.toLowerCase().includes(term))) return false; - } - return true; -} - -export function NetworkStreamPanel({ - entries, - filterText, - visibleCategories, - onClear, - onExport, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - revealId, - onRevealComplete, -}: NetworkStreamPanelProps) { - const viewportRef = useScrollMemory("network-stream"); - const filteredEntries = useMemo(() => { - // Embedded column filters by text only (its category toggles live in the - // full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns a - // fresh array, so sorting in-place is safe. - const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleCategories, embedded)) - .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - if (sortDirection === "newest-first") sorted.reverse(); - return sorted; - }, [entries, filterText, visibleCategories, sortDirection, embedded]); - - const hasEntries = entries.length > 0; - const hasResults = filteredEntries.length > 0; - - return ( - - - {formatTitle(filteredEntries.length)} - - - - - {hasResults && ( - - )} - - - - {!hasResults ? ( - - No network requests - - ) : ( - - - {filteredEntries.map((entry) => ( - - ))} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx deleted file mode 100644 index 856eb4886..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Autocomplete, - Button, - Group, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import type { Prompt } from "@modelcontextprotocol/client"; - -export interface PromptArgumentsFormProps { - prompt: Prompt; - argumentValues: Record; - onArgumentChange: (name: string, value: string) => void; - onGetPrompt: () => void; - /** - * When provided, each keystroke in an argument input dispatches a - * (debounced) `completion/complete` request to the server and surfaces - * the returned values as a dropdown via Mantine `Autocomplete`. - * Wire to `InspectorClient.getCompletions` in the host App. - */ - onCompleteArgument?: ( - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; - /** - * Gates whether to render Autocomplete (with live completions) vs the - * plain TextInput. Typically derived from the server's - * `completions` capability. - */ - completionsSupported?: boolean; -} - -const COMPLETION_DEBOUNCE_MS = 300; - -const PromptTitle = Text.withProps({ - fw: 700, - size: "lg", - truncate: "end", -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -function formatPlaceholder(name: string): string { - return `Enter ${name}...`; -} - -export function PromptArgumentsForm({ - prompt, - argumentValues, - onArgumentChange, - onGetPrompt, - onCompleteArgument, - completionsSupported = false, -}: PromptArgumentsFormProps) { - const { name, title, description, arguments: promptArguments } = prompt; - - const [completions, setCompletions] = useState>({}); - - // Reset completion state whenever the active prompt changes — completions - // are keyed by argument name, and the same name could mean different - // things across prompts. - useValueChange(name, () => { - setCompletions({}); - }); - - // Per-arg in-flight controller (later keystroke aborts older request). - const requestsRef = useRef>(new Map()); - // Per-arg debounce timer so we don't spam the server on every key. - const timersRef = useRef>>( - new Map(), - ); - - useEffect(() => { - const timers = timersRef.current; - const requests = requestsRef.current; - return () => { - for (const t of timers.values()) clearTimeout(t); - timers.clear(); - for (const c of requests.values()) c.abort(); - requests.clear(); - }; - }, []); - - const useAutocomplete = completionsSupported && !!onCompleteArgument; - - const runCompletion = useCallback( - async (argName: string, value: string, context: Record) => { - if (!onCompleteArgument) return; - requestsRef.current.get(argName)?.abort(); - const controller = new AbortController(); - requestsRef.current.set(argName, controller); - try { - const values = await onCompleteArgument(argName, value, context); - if (controller.signal.aborted) return; - setCompletions((prev) => ({ ...prev, [argName]: values })); - } catch { - if (!controller.signal.aborted) { - setCompletions((prev) => ({ ...prev, [argName]: [] })); - } - } finally { - if (requestsRef.current.get(argName) === controller) { - requestsRef.current.delete(argName); - } - } - }, - [onCompleteArgument], - ); - - // Hold the latest argumentValues in a ref so debounced fires can read - // sibling values at *fire* time, not at schedule time. Without this, - // typing in arg A then arg B within the debounce window would ship - // A's request with B's value stuck at its pre-keystroke state. - const argumentValuesRef = useRef(argumentValues); - useEffect(() => { - argumentValuesRef.current = argumentValues; - }, [argumentValues]); - - // Build the `context.arguments` payload for a completion request. - // Includes every prompt argument the user could fill in (with `""` - // for ones they haven't typed yet) except the one being completed — - // the completing arg goes in `params.argument`. Servers that - // disambiguate based on co-arguments need all of them, not just - // whatever the user has already typed. - const buildContext = useCallback( - (currentArg: string): Record => { - const ctx: Record = {}; - for (const a of promptArguments ?? []) { - if (a.name === currentArg) continue; - ctx[a.name] = argumentValuesRef.current[a.name] ?? ""; - } - return ctx; - }, - [promptArguments], - ); - - function handleChange(argName: string, value: string) { - onArgumentChange(argName, value); - if (!useAutocomplete) return; - // Drop the previous prefix's completions so the dropdown doesn't - // show ghost suggestions from the old keystroke while the new - // request is in flight (300ms debounce + network latency). The - // fresh response repopulates the array when it arrives. - setCompletions((prev) => { - if (prev[argName] === undefined) return prev; - const next = { ...prev }; - delete next[argName]; - return next; - }); - const existing = timersRef.current.get(argName); - if (existing) clearTimeout(existing); - const timer = setTimeout(() => { - timersRef.current.delete(argName); - // Build context at fire time so sibling values that arrived - // between schedule and fire are picked up. - void runCompletion(argName, value, buildContext(argName)); - }, COMPLETION_DEBOUNCE_MS); - timersRef.current.set(argName, timer); - } - - function handleFocus(argName: string) { - if (!useAutocomplete) return; - // Fire immediately so the dropdown isn't empty when the user first - // clicks in. Cancel any pending debounce so a stale keystroke - // request doesn't overwrite this fresher one. - const existing = timersRef.current.get(argName); - if (existing) { - clearTimeout(existing); - timersRef.current.delete(argName); - } - const value = argumentValuesRef.current[argName] ?? ""; - void runCompletion(argName, value, buildContext(argName)); - } - - // Mirror ResourceTemplatePanel: every required argument must be - // filled before Get Prompt is enabled. Optional args are allowed to - // stay empty; the server will treat them as absent. - const canSubmit = (promptArguments ?? []) - .filter((a) => a.required === true) - .every((a) => (argumentValues[a.name] ?? "").length > 0); - - return ( - - {title ?? name} - {description && {description}} - {promptArguments && promptArguments.length > 0 && ( - <> - Arguments - - {promptArguments.map((arg) => - useAutocomplete ? ( - options} - onChange={(value) => handleChange(arg.name, value)} - onFocus={() => handleFocus(arg.name)} - /> - ) : ( - - handleChange(arg.name, event.currentTarget.value) - } - rightSectionPointerEvents="auto" - rightSection={ - argumentValues[arg.name] ? ( - handleChange(arg.name, "")} /> - ) : null - } - /> - ), - )} - - - )} - {/* Left-aligned so the action sits closest to the sidebar controls / the - form fields above — shortest pointer travel. */} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx deleted file mode 100644 index 2a2d5364c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { Prompt } from "@modelcontextprotocol/client"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; -import { - ListPaginationControls, - type ListPaginationControlsProps, -} from "../../elements/ListPaginationControls/ListPaginationControls"; -import { PromptListItem } from "../PromptListItem/PromptListItem"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -// Fill the full-height `sidebar` Card (a flex column) so the list runs to the -// bottom of the card before it scrolls, instead of being capped short by a -// fixed max-height. `mih: 0` lets the scroll child shrink and scroll. -const SidebarStack = Stack.withProps({ - gap: "sm", - flex: 1, - mih: 0, -}); - -const SearchInput = TextInput.withProps({ - placeholder: "Search prompts...", - rightSectionPointerEvents: "auto", -}); - -const ListScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, -}); - -export interface PromptControlsProps { - prompts: Prompt[]; - selectedName?: string; - // Search text is controlled by the parent (App, via PromptsScreen) so it - // persists across tab navigation within a live session — see #1417. - searchText?: string; - listChanged: boolean; - onRefreshList: () => void; - /** - * A failed list load, surfaced above the list instead of leaving the panel - * empty (which reads as "this server has none") (#1953). - */ - loadError?: Error | null; - /** Pagination controls (#1721). */ - pagination: ListPaginationControlsProps; - onSearchChange: (value: string) => void; - onSelectPrompt: (name: string) => void; -} - -export function PromptControls({ - prompts, - selectedName, - searchText = "", - listChanged, - onRefreshList, - loadError, - pagination, - onSearchChange, - onSelectPrompt, -}: PromptControlsProps) { - const viewportRef = useScrollMemory("prompts-sidebar"); - const query = searchText.toLowerCase(); - const filteredPrompts = prompts.filter( - (p) => - p.name.toLowerCase().includes(query) || - (p.title?.toLowerCase().includes(query) ?? false) || - (p.description?.toLowerCase().includes(query) ?? false), - ); - - return ( - - - Prompts - - - onSearchChange(e.currentTarget.value)} - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - - - - {filteredPrompts.map((prompt) => ( - { - if (prompt.name !== selectedName) onSelectPrompt(prompt.name); - }} - /> - ))} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx deleted file mode 100644 index f9d2af890..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Stack, Text, UnstyledButton } from "@mantine/core"; -import type { Prompt } from "@modelcontextprotocol/client"; - -export interface PromptListItemProps { - prompt: Prompt; - selected: boolean; - onClick: () => void; -} - -const NameText = Text.withProps({ - fw: 500, -}); - -const DescriptionText = Text.withProps({ - size: "xs", - c: "dimmed", - lineClamp: 1, -}); - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export function PromptListItem({ - prompt, - selected, - onClick, -}: PromptListItemProps) { - const { name, title, description } = prompt; - return ( - - - {title ?? name} - {description && {description}} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx deleted file mode 100644 index 3947d6d8c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - Button, - CloseButton, - Group, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; -import type { PromptMessage } from "@modelcontextprotocol/client"; -import { MessageBubble } from "../../elements/MessageBubble/MessageBubble"; - -export interface PromptMessagesDisplayProps { - messages: PromptMessage[]; - onCopyAll?: () => void; - /** - * When provided, a top-left X button dismisses the panel. The host - * (`PromptsScreen`) decides what to show in its place — typically - * the prompt's argument form (if it has arguments) or the empty state. - */ - onClose?: () => void; -} - -const CopyAllButton = Button.withProps({ - variant: "subtle", - size: "sm", -}); - -// Outer stack inside the PreviewCard: header stays pinned, the scroll -// region absorbs overflow. Mirrors ResourcePreviewPanel so prompts and -// resources share the same sized-to-content / cap-then-scroll behavior. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - flex: "0 0 auto", -}); - -const HeaderLeft = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -// `0 1 auto` lets the scroll region shrink (but not grow) when the card -// hits its mah. `mih: 0` is required for flex children to shrink below -// their content's intrinsic height. -const MessagesScroll = ScrollArea.withProps({ - flex: "0 1 auto", - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const MessagesStack = Stack.withProps({ - gap: "md", -}); - -export function PromptMessagesDisplay({ - messages, - onCopyAll, - onClose, -}: PromptMessagesDisplayProps) { - return ( - - - - {onClose && ( - - )} - Messages - - {onCopyAll && messages.length > 0 && ( - Copy All - )} - - - - {messages.length === 0 ? ( - No messages to display - ) : ( - messages.map((message, index) => ( - - )) - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx deleted file mode 100644 index e545ea54c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Select, Stack, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { - MessageMethod, - MessageOrigin, -} from "@inspector/core/mcp/types.js"; -import { MessageDirectionFilter } from "../MessageDirectionFilter/MessageDirectionFilter"; - -const SearchInput = TextInput.withProps({ - placeholder: "Search...", - rightSectionPointerEvents: "auto", -}); - -// h5 (not h6) so it sits one level below the screen's h4 heading — avoids an axe -// `heading-order` skip; `size="h6"` keeps the small visual size. -const MethodFilterTitle = Title.withProps({ - order: 5, - size: "h6", -}); - -const MethodSelect = Select.withProps({ - placeholder: "All methods", - clearable: true, -}); - -export interface ProtocolControlsProps { - searchText: string; - methodFilter?: MessageMethod; - availableMethods: MessageMethod[]; - visibleDirections: Record; - onSearchChange: (text: string) => void; - onMethodFilterChange: (method: MessageMethod | undefined) => void; - onToggleDirection: (direction: MessageOrigin, visible: boolean) => void; - onToggleAllDirections: () => void; -} - -export function ProtocolControls({ - searchText, - methodFilter, - availableMethods, - visibleDirections, - onSearchChange, - onMethodFilterChange, - onToggleDirection, - onToggleAllDirections, -}: ProtocolControlsProps) { - return ( - - Protocol - onSearchChange(event.currentTarget.value)} - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - Filter by Method - - onMethodFilterChange((value as MessageMethod | null) ?? undefined) - } - /> - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx deleted file mode 100644 index 73edda6cd..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { useState } from "react"; -import { - Alert, - Anchor, - Badge, - Card, - Collapse, - Divider, - Group, - ScrollArea, - Stack, - Text, -} from "@mantine/core"; -import { RiErrorWarningLine } from "react-icons/ri"; -import type { MessageEntry } from "@inspector/core/mcp/types.js"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; -import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; -import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; -import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { PinToggle } from "../../elements/PinToggle/PinToggle"; -import { ReplayButton } from "../../elements/ReplayButton/ReplayButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import { - classifyProtocolSpecError, - type McpSpecError, -} from "../../../utils/mcpNetworkHeaders"; -import { - extractMethod, - extractResultType, - extractSubscriptionId, - isReplayableProtocolMethod, -} from "../protocolUtils.js"; - -export interface ProtocolEntryProps { - entry: MessageEntry; - isPinned: boolean; - isListExpanded: boolean; - onReplay: () => void; - onTogglePin: () => void; - /** - * Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is - * time + direction + duration + status; line 2 is the method (and target) with - * the controls — Replay as an icon — on the right. - */ - embedded?: boolean; - /** - * When provided (a spec-error entry with a correlated Network request), the - * expanded alert shows a "view in Network" link that jumps to, and expands, - * the matching HTTP entry. - */ - onRevealInNetwork?: () => void; - /** - * HTTP status of this entry's correlated Network fetch, when known. Used to - * gate the generic `-32601` to a genuine modern 404 (an in-band `-32601` on a - * 200 is an ordinary error, not the modern taxonomy). Omitted when there is no - * correlated HTTP record. - */ - correlatedHttpStatus?: number; -} - -const EntryContainer = Card.withProps({ - withBorder: true, - padding: "md", - variant: "inset", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -// Left / right clusters within a compact header line. The left cluster shrinks -// (`miw: 0`) so a long target can truncate rather than push the row wider. -const HeaderCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, -}); - -const ControlsCluster = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const TimestampText = Text.withProps({ - size: "sm", - c: "dimmed", - ff: "monospace", -}); - -const TargetText = Text.withProps({ - size: "sm", - fw: 500, -}); - -// Compact-header target (e.g. a long resource URI): never wraps, so it scrolls -// horizontally inside its ScrollArea instead of truncating with an ellipsis -// (mirrors NetworkEntry's URL). -const TargetScroll = Text.withProps({ - size: "sm", - fw: 500, - variant: "nowrap", -}); - -const DurationText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// The `subscriptionId` tag on a modern push notification (spec §7.4). Shown with -// a copy button so the id can be correlated against the `subscriptions/listen` -// stream that opened it. -const SubscriptionLabel = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const SubscriptionId = Text.withProps({ - size: "sm", - ff: "monospace", -}); - -const SubscriptionCluster = Group.withProps({ - gap: 4, - wrap: "nowrap", - miw: 0, -}); - -// Friendly summary alert for a modern spec error (title is per-error, dynamic). -const SpecErrorAlert = Alert.withProps({ - variant: "light", - color: "red", - icon: , -}); - -// The client rejected an otherwise well-formed response (#1953). Distinct from -// SpecErrorAlert: nothing is wrong with the server's JSON-RPC frame — the -// Inspector's own decoding refused the result — so the title says who rejected it. -const ClientErrorAlert = Alert.withProps({ - variant: "light", - color: "red", - icon: , - title: "Rejected by the Inspector", -}); - -// Link (button-styled) that jumps to the correlated HTTP entry in the Network tab. -const RevealLink = Anchor.withProps({ - component: "button", - type: "button", - size: "xs", -}); - -const TargetScrollArea = ScrollArea.withProps({ - scrollbarSize: 6, - flex: 1, - miw: 0, - // The target scrolls horizontally but has no focusable child, so make the - // viewport itself keyboard-scrollable (WCAG SC 2.1.1). Scrollbar auto-hides - // via the `type="scroll"` theme default. - viewportProps: { tabIndex: 0 }, -}); - -// Trailing controls row (replay / pin / expand) in the wide layout. -const ToggleRow = Group.withProps({ - gap: "xs", - justify: "flex-end", -}); - -// `complete` is green — it's the success signal now that the redundant "OK" -// status badge is suppressed, so a modern success keeps the same at-a-glance -// green affordance a legacy success has. `input_required` is yellow (in -// progress: awaiting input before the retry). -function resultTypeColor(resultType: "complete" | "input_required"): string { - return resultType === "input_required" ? "yellow" : "green"; -} - -function resultTypeLabel(resultType: "complete" | "input_required"): string { - return resultType === "input_required" ? "input required" : "complete"; -} - -function formatDuration(ms: number): string { - return `${ms}ms`; -} - -function formatTimestamp(date: Date): string { - return date.toISOString(); -} - -// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO -// string would eat most of the narrow line-1 width (#1616). -function formatTimestampCompact(date: Date): string { - return date.toISOString().slice(11, 19); -} - -function extractTarget(entry: MessageEntry): string | undefined { - const msg = entry.message; - if (!("params" in msg) || !msg.params) return undefined; - const params = msg.params as Record; - if (typeof params.name === "string") return params.name; - if (typeof params.uri === "string") return params.uri; - return undefined; -} - -// The resource URI when the target is one (e.g. `resources/read`), so it can be -// copied. Tool/prompt targets are plain names, not URIs, and get no copy button. -function extractResourceUri(entry: MessageEntry): string | undefined { - const msg = entry.message; - if (!("params" in msg) || !msg.params) return undefined; - const params = msg.params as Record; - return typeof params.uri === "string" ? params.uri : undefined; -} - -// The pending → OK/Error lifecycle only applies to requests: messageLogState -// attaches a `response` to request entries by JSON-RPC id. A notification is -// fire-and-forget (no id, no response, ever) and an unmatched standalone -// response has none either — so those carry no request-style status ("none") -// and render no badge, rather than a misleading permanent "Pending". -function extractStatus( - entry: MessageEntry, -): "success" | "error" | "pending" | "none" { - // A response the CLIENT refused is an error whichever entry carries it, so - // this is checked BEFORE the request-only lifecycle below (#1953). - // messageLogState annotates the request entry when the response was folded - // into one, but falls back to the standalone response frame when there was - // no matching request (a trimmed log, or a reconnect boundary) — and that - // entry would otherwise fall straight through to "none" and render no badge. - if (entry.clientError) return "error"; - if (entry.direction !== "request") return "none"; - if (!entry.response) return "pending"; - if ("error" in entry.response) return "error"; - return "success"; -} - -function statusColor(status: "success" | "error" | "pending"): string { - if (status === "success") return "green"; - if (status === "error") return "red"; - return "gray"; -} - -function statusLabel(status: "success" | "error" | "pending"): string { - if (status === "success") return "OK"; - if (status === "error") return "Error"; - return "Pending"; -} - -function serializeMessage(value: unknown): string { - return JSON.stringify(value); -} - -// The JSON-RPC error carried by a message — either the folded error `response` -// on a request, or an error message frame itself — classified as a modern spec -// error (SEP-2243 / SEP-2575) or null. Protocol errors the SDK throws rather -// than delivers (e.g. -32601) are folded onto the pending request upstream (see -// `enrichProtocolEntries`), so they land here too. -function extractSpecError( - entry: MessageEntry, - httpStatus?: number, -): McpSpecError | null { - const error = - entry.response && "error" in entry.response - ? entry.response.error - : "error" in entry.message - ? entry.message.error - : undefined; - if (!error || typeof error.code !== "number") return null; - return classifyProtocolSpecError(error.code, error.data, httpStatus); -} - -// Friendly summary of a modern spec error, shown in the expanded detail. The -// HTTP-level facts (status, mirrored headers) live on the correlated Network -// entry, reachable via the "view in Network" link when one exists. -function McpSpecErrorAlert({ - error, - onReveal, -}: { - error: McpSpecError; - onReveal?: () => void; -}) { - return ( - - - {error.description} - {error.supported && ( - Server supports: {error.supported.join(", ")} - )} - {onReveal && ( - - View the HTTP request in the Network tab → - - )} - - - ); -} - -export function ProtocolEntry({ - entry, - isPinned, - isListExpanded, - onReplay, - onTogglePin, - embedded = false, - onRevealInNetwork, - correlatedHttpStatus, -}: ProtocolEntryProps) { - const [isExpanded, setIsExpanded] = useState(isListExpanded); - const method = extractMethod(entry); - const target = extractTarget(entry); - const resourceUri = extractResourceUri(entry); - const status = extractStatus(entry); - const canReplay = isReplayableProtocolMethod(method); - const resultType = extractResultType(entry); - const subscriptionId = extractSubscriptionId(entry); - - // The list-level Expand/Collapse toggle is authoritative: any per-entry - // override is discarded whenever the parent changes `isListExpanded`. - // Mirrors NetworkEntry; do not change without aligning both. - useValueChange(isListExpanded, setIsExpanded); - - const directionBadge = entry.origin && ( - - ); - // Distinct chip for a modern spec error (SEP-2243 / SEP-2575). Shown only in - // the wide layout (right after the method chip); the compact sidebar relies on - // its ERROR status badge to keep the two-line row uncluttered. - const specError = extractSpecError(entry, correlatedHttpStatus); - const specErrorBadge = specError && ( - - ); - // The modern `resultType` on the paired result (spec §7.3): `input_required` - // (the operation isn't done — it needs input and will be retried) vs the - // ordinary `complete`. Only present on modern results, so it doubles as a - // per-result modern signal without inferring the connection era. - const resultTypeBadge = resultType && ( - - {resultTypeLabel(resultType)} - - ); - // Suppress the redundant green "OK" when a `resultType` badge already conveys - // the outcome (a modern success is `complete`/`input required`); errors and - // pending have no `resultType`, so their status badge still shows. - const statusBadge = status !== "none" && - (!resultType || entry.clientError) && ( - - {statusLabel(status)} - - ); - const subscriptionBadge = subscriptionId && ( - - sub - - {subscriptionId} - - ); - const durationText = entry.duration != null && ( - {formatDuration(entry.duration)} - ); - - return ( - - - {embedded ? ( - // Compact two-line header for the narrow column. - - - - - {formatTimestampCompact(entry.timestamp)} - - {directionBadge} - - - {durationText} - {resultTypeBadge} - {statusBadge} - {/* The subscription-id tag rides the top line's trailing edge - (a notification row's duration/status slots are empty) so the - method badge on the line below gets the full column width and - doesn't truncate against the pin control (#1630). */} - {subscriptionBadge} - - - - - - {target && ( - <> - {resourceUri && } - - {target} - - - )} - - - {canReplay && } - - setIsExpanded((v) => !v)} - /> - - - - ) : ( - <> - - - - {formatTimestamp(entry.timestamp)} - - {directionBadge} - - {specErrorBadge} - {subscriptionBadge} - {target && ( - <> - {resourceUri && } - {target} - - )} - - - {durationText} - {resultTypeBadge} - {statusBadge} - - - - - {canReplay && } - - setIsExpanded((v) => !v)} - /> - - - )} - - - - - {entry.clientError && ( - - {entry.clientError} - - )} - {specError && ( - - )} - {"params" in entry.message && entry.message.params && ( - - Parameters: - - - )} - {entry.response && ( - - Response: - - - )} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx deleted file mode 100644 index 69214ef0e..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useMemo, useState, type ReactNode } from "react"; -import { - Button, - Collapse, - Group, - Paper, - Stack, - Text, - Title, - UnstyledButton, -} from "@mantine/core"; -import type { ProtocolEra } from "@modelcontextprotocol/client"; -import type { - MessageEntry, - MessageMethod, - MessageOrigin, -} from "@inspector/core/mcp/types.js"; -import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry"; -import { MrtrConversation } from "../MrtrConversation/MrtrConversation"; -import { ListToggle } from "../../elements/ListToggle/ListToggle"; -import { EraBadge } from "../../elements/EraBadge/EraBadge"; -import { - SortToggle, - type SortDirection, -} from "../../elements/SortToggle/SortToggle"; -import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; -import { extractMethod, groupProtocolEntries } from "../protocolUtils.js"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface ProtocolListPanelProps { - entries: MessageEntry[]; - pinnedIds: Set; - searchText: string; - methodFilter?: MessageMethod; - /** Which message directions to show, keyed by entry origin. */ - visibleDirections: Record; - /** - * The connection's negotiated protocol era (SEP §7.8), shown as a badge so - * captured traffic is labeled by era. Must come from connection state — never - * inferred from the frames (the modern probe carries a `_meta` envelope before - * the era is known; spec §8.3). Undefined hides the badge. - */ - protocolEra?: ProtocolEra; - onClearAll: () => void; - onExport: () => void; - /** Clear just one section's entries (pinned vs unpinned history). */ - onClearSection: (section: ProtocolSectionName) => void; - /** Export just one section's entries. */ - onExportSection: (section: ProtocolSectionName) => void; - onReplay: (id: string) => void; - onTogglePin: (id: string) => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ - embedded?: boolean; - /** Jump from a spec-error entry to its correlated Network HTTP entry. */ - onRevealInNetwork?: (id: string) => void; - /** Message-entry ids that have a correlated Network entry (link is shown). */ - revealableIds?: Set; - /** - * Message-entry id → correlated Network fetch HTTP status. Gates the generic - * `-32601` to a genuine modern 404 (see {@link ProtocolEntry}). - */ - correlatedStatusById?: Map; -} - -const PanelContainer = Paper.withProps({ - withBorder: true, - p: "lg", - flex: 1, - variant: "panel", -}); - -// Centered in the full-height panel so the empty message sits mid-panel rather -// than clinging to the top of an otherwise-empty box (matches LogStreamPanel). -const EmptyCenter = Stack.withProps({ - flex: 1, - align: "center", - justify: "center", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", -}); - -// Panel header: title (+ era badge) on the left, action controls on the right. -const HeaderRow = Group.withProps({ - justify: "space-between", - mb: "sm", -}); - -// The section header is a single "pleat" bar (rounded, with the filter-button -// outline-on-hover treatment and the active background passed per instance via -// `bg`). Inside it sit the -// clickable toggle area (the title, filling the left) and the optional -// Clear/Export actions on the right — so the actions live on the pleat itself, -// not beside it. The toggle is its own button (the actions can't nest inside a -// button), `flex: 1` so it spans the bar up to the actions. -const SectionHeaderBar = Group.withProps({ - variant: "sectionHeader", - gap: "sm", - wrap: "nowrap", - p: "sm", -}); - -const SectionToggleArea = UnstyledButton.withProps({ - flex: 1, -}); - -const SectionTitle = Text.withProps({ - fw: 600, -}); - -const SectionActionGroup = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -// Subtle link-style button, matching the Select/Deselect All control in -// ProtocolControls. -const SectionLinkButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -// The two in-panel sub-section labels. The un-pinned section deliberately keeps -// the "History" wording (and the `"history"` discriminator below) even though -// the tab/feature was renamed to "Protocol" — this is the settled boundary from -// #1623: only the tab/feature renames; the section discriminator and its -// Pinned / History labels stay. -function formatPinnedTitle(count: number): string { - return `Pinned Messages (${count})`; -} - -function formatHistoryTitle(count: number): string { - return `History (${count})`; -} - -type ProtocolSectionName = "pinned" | "history"; - -// Per-section Clear / Export links, shown to the right of a section header when -// both sections are present (so each can be cleared/exported on its own). -function SectionActions({ - onClear, - onExport, -}: { - onClear: () => void; - onExport: () => void; -}) { - return ( - - Clear - Export - - ); -} - -// A History section. When `collapsible` (both sections are on screen) the header -// is a `listItem` toggle — with an optional actions slot on the right — over a -// `Collapse` of the entries. When it's the only section, the accordion makes no -// sense: the header is a plain title and the entries always show (so a stale -// collapsed state from when both sections were present can't hide them) — -// unless `hideHeaderWhenAlone`, in which case the lone section drops its title -// entirely (the "Protocol" label is redundant when there's nothing to -// distinguish it from). -function CollapsibleSection({ - title, - collapsible, - hideHeaderWhenAlone = false, - open, - onToggle, - actions, - children, -}: { - title: string; - collapsible: boolean; - hideHeaderWhenAlone?: boolean; - open: boolean; - onToggle: () => void; - actions?: ReactNode; - children: ReactNode; -}) { - if (!collapsible) { - return ( - - {hideHeaderWhenAlone ? null : {title}} - {children} - - ); - } - return ( - - - - {title} - - {actions} - - - {children} - - - ); -} - -function matchesFilters( - entry: MessageEntry, - searchText: string, - visibleDirections: Record, - methodFilter: MessageMethod | undefined, - // The embedded column exposes only the search box (no direction/method - // controls), so it applies the text filter but skips those (#1616). - ignoreDirectionAndMethod: boolean, -): boolean { - const method = extractMethod(entry); - if (!ignoreDirectionAndMethod) { - // Hide a direction when its toggle is off. Entries with no recorded origin - // (legacy / pre-origin logs) are never filtered out by direction. - if (entry.origin && !visibleDirections[entry.origin]) return false; - if (methodFilter && method !== methodFilter) return false; - } - if (searchText) { - const term = searchText.toLowerCase(); - const responseText = entry.response ? JSON.stringify(entry.response) : ""; - const searchable = - `${method} ${entry.id} ${JSON.stringify(entry.message)} ${responseText}`.toLowerCase(); - if (!searchable.includes(term)) return false; - } - return true; -} - -export function ProtocolListPanel({ - entries, - pinnedIds, - searchText, - methodFilter, - visibleDirections, - protocolEra, - onClearAll, - onExport, - onClearSection, - onExportSection, - onReplay, - onTogglePin, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - onRevealInNetwork, - revealableIds, - correlatedStatusById, -}: ProtocolListPanelProps) { - const viewportRef = useScrollMemory("protocol-list"); - // Per-section expand/collapse, like the LogControls level toggles. Both start - // open; collapsing hides that section's entries without affecting the other. - const [pinnedOpen, setPinnedOpen] = useState(true); - const [historyOpen, setHistoryOpen] = useState(true); - const filteredEntries = useMemo(() => { - // Embedded column filters by text only (its direction/method controls live - // in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns - // a fresh array, so sorting in-place is safe. - const sorted = entries - .filter((e) => - matchesFilters( - e, - searchText, - visibleDirections, - methodFilter, - embedded, - ), - ) - .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - if (sortDirection === "newest-first") sorted.reverse(); - return sorted; - }, [ - entries, - searchText, - visibleDirections, - methodFilter, - sortDirection, - embedded, - ]); - - const pinnedEntries = useMemo( - () => filteredEntries.filter((e) => pinnedIds.has(e.id)), - [filteredEntries, pinnedIds], - ); - - const unpinnedEntries = useMemo( - () => filteredEntries.filter((e) => !pinnedIds.has(e.id)), - [filteredEntries, pinnedIds], - ); - - const hasResults = filteredEntries.length > 0; - // Per-section Clear/Export only make sense when both sections are on screen; - // with a single section the panel-level Clear/Export already covers it. - const bothSections = pinnedEntries.length > 0 && unpinnedEntries.length > 0; - - // Render a section's entries, folding contiguous MRTR rounds (spec §7.3) into - // one MrtrConversation so an operation spanning several JSON-RPC ids reads as - // a single unit; everything else stays a plain ProtocolEntry. `sectionPinned` - // is the section's pin state (used for a lone entry's pin label). - const renderRows = (sectionEntries: MessageEntry[], sectionPinned: boolean) => - groupProtocolEntries(sectionEntries).map((row) => - row.kind === "mrtr" ? ( - - ) : ( - onReplay(row.entry.id)} - onTogglePin={() => onTogglePin(row.entry.id)} - onRevealInNetwork={ - onRevealInNetwork && revealableIds?.has(row.entry.id) - ? () => onRevealInNetwork(row.entry.id) - : undefined - } - correlatedHttpStatus={correlatedStatusById?.get(row.entry.id)} - /> - ), - ); - - return ( - - - - Messages - {protocolEra && } - - - - - - {hasResults && ( - - )} - - - - {!hasResults ? ( - - No request history - - ) : ( - - - {pinnedEntries.length > 0 && ( - setPinnedOpen((v) => !v)} - actions={ - bothSections ? ( - onClearSection("pinned")} - onExport={() => onExportSection("pinned")} - /> - ) : undefined - } - > - {renderRows(pinnedEntries, true)} - - )} - - {unpinnedEntries.length > 0 && ( - setHistoryOpen((v) => !v)} - actions={ - bothSections ? ( - onClearSection("history")} - onExport={() => onExportSection("history")} - /> - ) : undefined - } - > - {renderRows(unpinnedEntries, false)} - - )} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx deleted file mode 100644 index adf5aa1a5..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { Accordion, Group, Stack, Text, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { RiArrowRightSLine } from "react-icons/ri"; -import type { - ProtocolEra, - Resource, - ResourceTemplateType as ResourceTemplate, -} from "@modelcontextprotocol/client"; -import type { - InspectorResourceSubscription, - ResourceSubscriptionStreamState, -} from "../../../../../../core/mcp/types.js"; -import { isModernEra } from "../../elements/EraBadge/eraUtils"; -import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; -import { - ListPaginationControls, - type ListPaginationControlsProps, -} from "../../elements/ListPaginationControls/ListPaginationControls"; -import { ListToggle } from "../../elements/ListToggle/ListToggle"; -import { ResourceListItem } from "../ResourceListItem/ResourceListItem"; -import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem"; - -// A tight, non-wrapping horizontal row (search field + toggle; count + stream -// badge). -const TightRow = Group.withProps({ gap: "xs", wrap: "nowrap" }); - -// Fills the full-height `sidebar` Card (flex column) so the scroll region below -// can claim the remaining space; `mih: 0` lets that child shrink and scroll -// instead of overflowing the card (#1462). -const SidebarStack = Stack.withProps({ gap: "sm", flex: 1, mih: 0 }); - -const SearchInput = TextInput.withProps({ - flex: 1, - placeholder: "Search...", - rightSectionPointerEvents: "auto", -}); - -export interface ResourceControlsProps { - resources: Resource[]; - templates: ResourceTemplate[]; - subscriptions: InspectorResourceSubscription[]; - /** - * Whether the connected server advertises the `resources.subscribe` - * capability. When false, the Subscriptions accordion section is hidden - * entirely. Defaults to true so the section renders unless a caller - * explicitly marks subscriptions unsupported. - */ - subscriptionsSupported?: boolean; - /** - * Modern-era `subscriptions/listen` stream state (#1630). When `active` - * (modern era with at least one subscription) the Subscriptions section shows - * a stream-status badge in its panel and a status dot in its header. Legacy - * connections pass `active: false` (or omit it) and see neither — and so does - * a stream open purely for list-change notifications, which this section has - * nothing to say about (#1920). - */ - subscriptionStreamState?: ResourceSubscriptionStreamState; - /** Negotiated protocol era; gates the modern subscription stream chrome. */ - protocolEra?: ProtocolEra; - selectedUri?: string; - selectedTemplateUri?: string; - // Search text + accordion open-sections are controlled by the parent (App, - // via ResourcesScreen) so they persist across tab navigation within a live - // session — see #1417. `openSections` is optional: when undefined the - // accordion falls back to the `compact`-derived default below. - searchText?: string; - openSections?: string[]; - listChanged: boolean; - onRefreshList: () => void; - /** - * A failed list load, surfaced above the list instead of leaving the panel - * empty (which reads as "this server has none") (#1953). - */ - loadError?: Error | null; - /** Pagination controls for the Resources list (#1721). */ - pagination: ListPaginationControlsProps; - onSearchChange: (value: string) => void; - onOpenSectionsChange: (value: string[]) => void; - onSelectUri: (uri: string) => void; - onSelectTemplate: (uriTemplate: string) => void; - onUnsubscribeResource: (uri: string) => void; - /** - * Persisted preference for the ListToggle. Seeds initial accordion state - * (when `openSections` is undefined); the user can still toggle individual - * sections during a session without affecting this value. Only an explicit - * ListToggle click updates it. - */ - compact: boolean; - onCompactChange: (next: boolean) => void; -} - -function formatSectionCount(label: string, count: number): string { - return `${label} (${count})`; -} - -// Per-section flex for the full-height accordion. Open sections share the -// remaining height; `flex-shrink` is weighted by item count (so a long section -// gives up space to shorter ones before they have to scroll) and `flex-grow` is -// 0 so nothing expands — or scrolls — until the combined content overflows the -// panel. Closed/empty sections stay at their header height (#1462). -function sectionFlex(open: boolean, count: number): string { - return open && count > 0 ? `0 ${count} auto` : "0 0 auto"; -} - -export function ResourceControls({ - resources, - templates, - subscriptions, - subscriptionsSupported = true, - subscriptionStreamState, - protocolEra, - selectedUri, - selectedTemplateUri, - searchText = "", - openSections: controlledOpenSections, - listChanged, - onRefreshList, - loadError, - pagination, - onSearchChange, - onOpenSectionsChange, - onSelectUri, - onSelectTemplate, - onUnsubscribeResource, - compact: initialCompact, - onCompactChange, -}: ResourceControlsProps) { - const query = searchText.toLowerCase(); - const filteredResources = resources.filter( - (r) => - r.name.toLowerCase().includes(query) || - (r.title?.toLowerCase().includes(query) ?? false) || - r.uri.toLowerCase().includes(query), - ); - const filteredTemplates = templates.filter( - (t) => - t.name.toLowerCase().includes(query) || - (t.title?.toLowerCase().includes(query) ?? false) || - t.uriTemplate.toLowerCase().includes(query), - ); - const filteredSubscriptions = subscriptions.filter( - (s) => - s.resource.name.toLowerCase().includes(query) || - (s.resource.title?.toLowerCase().includes(query) ?? false) || - s.resource.uri.toLowerCase().includes(query), - ); - - // Modern-era chrome for the single `subscriptions/listen` stream (#1630): - // a status badge in the section header (so it stays visible while the section - // is collapsed). Only shown on the modern era while the stream is active - // (≥1 subscription); the legacy per-URI `resources/subscribe` model has no - // persistent stream. Also gated on the *filtered* count so the badge hides - // alongside the section when a search matches none of the live subscriptions - // (rather than sitting next to a disabled "Subscriptions (0)" header). - const streamStatus = - isModernEra(protocolEra) && - subscriptionStreamState?.active === true && - filteredSubscriptions.length > 0 - ? subscriptionStreamState.status - : undefined; - - // Subscriptions are only meaningful when the server advertises the - // `resources.subscribe` capability; otherwise the section is omitted - // entirely (no header, no panel) — see #1478. - const allSections = subscriptionsSupported - ? ["resources", "templates", "subscriptions"] - : ["resources", "templates"]; - // Open-sections is parent-controlled (persists across navigation). When the - // parent hasn't set it yet (undefined), fall back to the persisted `compact` - // preference: empty when last left compact, all sections open when expanded. - // Per-section accordion clicks update the lifted value but don't change the - // persisted preference. - const openSections = - controlledOpenSections ?? (initialCompact ? [] : [...allSections]); - // Persisted open-sections may still carry "subscriptions" from a prior - // subscription-capable session, so compare only the sections we actually - // render when deciding whether everything is expanded. - const allExpanded = - openSections.filter((section) => allSections.includes(section)).length === - allSections.length; - - // Empty sections have a disabled control and nothing to show, so keep them - // out of the accordion's open set — they render collapsed (chevron points - // right) rather than as an open-but-empty panel (#1462). `openSections` still - // tracks the user's intent (and seeds the ListToggle), so a section re-opens - // on its own once it has items again. - const sectionItemCounts: Record = { - resources: filteredResources.length, - templates: filteredTemplates.length, - subscriptions: filteredSubscriptions.length, - }; - const visibleOpenSections = openSections.filter( - (section) => - allSections.includes(section) && (sectionItemCounts[section] ?? 0) > 0, - ); - // Open-in-intent but currently empty (so excluded from the accordion's - // `value`). Mantine derives the next open-array by toggling the clicked - // section against the `value` we hand it, which omits these — so without - // merging them back, toggling any populated section would silently drop an - // empty section's intent and it wouldn't reappear once it has items again. - // Restricted to `allSections` so a stale "subscriptions" entry persisted from - // a prior subscription-capable session isn't perpetually re-appended once the - // section is no longer rendered — it's dropped from persisted state instead. - const intendedButEmptySections = openSections.filter( - (section) => - allSections.includes(section) && !visibleOpenSections.includes(section), - ); - function handleOpenSectionsChange(next: string[]) { - // Safe to append unconditionally: empty-section controls are `disabled`, so - // the user can never toggle one and `next` never contains an empty section - // — no double-add, and a section the user just closed can't be resurrected. - onOpenSectionsChange([...next, ...intendedButEmptySections]); - } - - function handleToggleList() { - // Compute the next compact value from what the click will produce so a - // half-open accordion (user toggled a single section) still persists the - // right preference: clicking "expand all" should record `compact=false` - // even if the visible state was already partially expanded. - const nextCompact = allExpanded; - onOpenSectionsChange(nextCompact ? [] : [...allSections]); - onCompactChange(nextCompact); - } - - return ( - - - Resources - - - - onSearchChange(e.currentTarget.value)} - rightSection={ - searchText ? ( - onSearchChange("")} /> - ) : null - } - /> - - - - - {/* Stays inline: Accordion is a compound, `multiple`-discriminated generic, - so `.withProps({ multiple: true, ... })` loses its JSX call signature - (same tooling limit as Box). */} - } - flex={1} - mih={0} - // Disable Mantine's panel height animation so flex controls the height - // cleanly (the chevron still rotates smoothly via CSS in App.css). #1462 - transitionDuration={0} - value={visibleOpenSections} - onChange={handleOpenSectionsChange} - > - - - {formatSectionCount("URIs", filteredResources.length)} - - - - {filteredResources.map((resource) => ( - { - if (resource.uri !== selectedUri) onSelectUri(resource.uri); - }} - /> - ))} - - - - - - - {formatSectionCount("Templates", filteredTemplates.length)} - - - - {filteredTemplates.map((template) => ( - { - if (template.uriTemplate !== selectedTemplateUri) - onSelectTemplate(template.uriTemplate); - }} - /> - ))} - - - - - {subscriptionsSupported && ( - - - - - {formatSectionCount( - "Subscriptions", - filteredSubscriptions.length, - )} - - {streamStatus && ( - - )} - - - - - {filteredSubscriptions.map((sub) => ( - - onUnsubscribeResource(sub.resource.uri) - } - /> - ))} - - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx deleted file mode 100644 index f00de8b4b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useState } from "react"; -import { Alert, Card, Collapse, ScrollArea, Stack, Text } from "@mantine/core"; -import type { ReadResourceResult } from "@modelcontextprotocol/client"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo"; - -export interface ResourceLinkProps { - /** The linked resource's URI (always shown). */ - uri: string; - /** Optional human-friendly name shown above the URI. */ - name?: string; - /** Optional MIME type shown as a badge. */ - mimeType?: string; - /** - * Read-on-demand handler. When provided, the card becomes expandable: the - * first expand calls this with the link's `uri` and renders the returned - * read result inline. Omit to render a static, non-expandable card. - */ - onReadResource?: (uri: string) => Promise; -} - -// Recessed "inset" surface so each link card reads the same as a Protocol -// message card (ProtocolEntry), matching its colors in both light and dark -// modes; the inset variant also raises nested Code blocks (the expanded read -// result) onto a lighter surface via its cascade variable. -const LinkCard = Card.withProps({ - withBorder: true, - padding: "sm", - radius: "md", - variant: "inset", -}); - -const ExpandedSection = Stack.withProps({ - gap: "xs", - mt: "xs", -}); - -// Caps the inline read result so a large resource scrolls within the card -// instead of pushing the page down — mirrors V1's bounded resource view. -// `Autosize` sizes to the content up to `mah`, then scrolls; a plain -// ScrollArea would need a definite height to scroll, which this card (sized to -// its content) does not provide. -const ResultScroll = ScrollArea.Autosize.withProps({ - mah: 400, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const LoadingText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const ErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Failed to read resource", -}); - -/** - * Expandable card for a `resource_link` content block. Renders the link's - * metadata via {@link ResourceLinkInfo} and — when `onReadResource` is supplied - * — an expand affordance that reads the linked resource on demand and renders - * the full read result inline as formatted JSON (via {@link ContentViewer}). - * The fetched result is cached so collapsing and re-expanding does not re-read. - */ -export function ResourceLink({ - uri, - name, - mimeType, - onReadResource, -}: ResourceLinkProps) { - const [expanded, setExpanded] = useState(false); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - const expandable = Boolean(onReadResource); - - async function toggle() { - if (!onReadResource) return; - if (expanded) { - setExpanded(false); - return; - } - setExpanded(true); - // Only a successful result is cached; re-expanding after an error retries - // the read so a transient failure isn't permanent. - if (result !== null) return; - // Don't fire a second read if one is already in flight (rapid toggle). - if (loading) return; - setError(null); - setLoading(true); - try { - setResult(await onReadResource(uri)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - } - - // Same tooltip'd expand/collapse control as ProtocolEntry (ExpandToggle), - // placed in the header row's meta slot as a sibling of the URI's copy button. - // A per-resource `ariaLabel` keeps the toggles distinguishable to assistive - // tech when several links are listed (the visible tooltip stays "Expand"). - const action = expandable ? ( - void toggle()} - ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`} - /> - ) : undefined; - - return ( - - - {/* Same expand/collapse animation as ProtocolEntry: content stays mounted - (so the cached read result survives a collapse) and animates via - Mantine's Collapse. */} - {expandable && ( - - - {loading ? ( - Loading resource… - ) : error !== null ? ( - {error} - ) : result !== null ? ( - - - - ) : null} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx deleted file mode 100644 index ac659451d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Text, UnstyledButton } from "@mantine/core"; -import type { - Resource, - ResourceTemplateType as ResourceTemplate, -} from "@modelcontextprotocol/client"; - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export interface ResourceListItemProps { - resource: Resource | ResourceTemplate; - selected: boolean; - onClick: () => void; -} - -export function ResourceListItem({ - resource, - selected, - onClick, -}: ResourceListItemProps) { - return ( - - {resource.title ?? resource.name} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx deleted file mode 100644 index 39630e89d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { - Button, - CloseButton, - Flex, - Group, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; -import { useState } from "react"; -import type { - BlobResourceContents, - Resource, - TextResourceContents, -} from "@modelcontextprotocol/client"; -import { accessibleTextColor } from "../../elements/accessibleTextColor"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; -import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton"; - -export interface ResourcePreviewPanelProps { - resource: Resource; - contents: (TextResourceContents | BlobResourceContents)[]; - lastUpdated?: Date; - isSubscribed: boolean; - /** - * Whether the connected server advertises the `resources.subscribe` - * capability. When false, the Subscribe/Unsubscribe button is hidden. - * Defaults to true so the button renders unless explicitly unsupported. - */ - subscriptionsSupported?: boolean; - onRefresh: () => void; - onSubscribe: () => void; - onUnsubscribe: () => void; - /** - * When provided, a top-left X button dismisses the panel. The host - * (`ResourcesScreen`) decides what to show in its place — either the - * originating template form or the empty state. - */ - onClose?: () => void; -} - -function formatLastUpdated(date: Date): string { - return `Last updated: ${date.toLocaleString()}`; -} - -// MIME kinds whose rendered preview (react-markdown, the CSV table, the -// sandboxed HTML iframe) hides the underlying text. For these the panel offers -// a "View Source" toggle that swaps the renderer for the raw resource text. -const SOURCE_TOGGLEABLE_KINDS = new Set(["markdown", "csv", "html"]); - -// MIME forced on ContentViewer in source mode so it routes through the plain -// preformatted-text renderer regardless of the resource's real type. -const SOURCE_MIME = "text/plain"; - -function isSourceToggleable(mimeType: string): boolean { - return SOURCE_TOGGLEABLE_KINDS.has(getMimeKind(mimeType)); -} - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - flex: "0 0 auto", -}); - -const HeaderLeft = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const UriGroup = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const UriText = Text.withProps({ - size: "sm", - // Scheme-aware readable blue: `c="blue"` renders blue-4 in dark mode, which - // falls just under WCAG AA (4.38:1) on the card. `-light-color` clears it in - // both schemes (see `accessibleTextColor`). - c: accessibleTextColor("blue"), - truncate: "end", -}); - -const MetaRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - flex: "0 0 auto", -}); - -const TimestampText = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -const MimeText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Actions sit at the left (`flex-start`) so the pointer travels the shortest -// distance from the sidebar controls / the form fields above; annotation badges -// trail them. -const FooterRow = Group.withProps({ - justify: "flex-start", - flex: "0 0 auto", -}); - -const AnnotationGroup = Group.withProps({ - gap: "xs", -}); - -const ActionGroup = Group.withProps({ - gap: "xs", -}); - -// Subtle footer action button. Shared by Refresh and the View Source toggle so -// the two stay visually identical (the toggle is deliberately styled to match -// Refresh, which sits immediately to its right). -const FooterButton = Button.withProps({ - variant: "subtle", - size: "sm", -}); - -const Spacer = Flex.withProps({}); - -// The panel sizes to its content: when the resource body is short the -// Card hugs it; when the body would overflow the Card's `mah`, the -// browser shrinks shrinkable flex items (only ContentScroll, since the -// header / meta / footer rows opt out with `flex: 0 0 auto`) and the -// inner ScrollArea takes over scrolling — keeping the subscribe button -// pinned at the bottom edge of the cap. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, -}); - -// Middle scroll region: basis sized to its own content, can shrink to -// fit the available space when content overflows, never grows past its -// content (so a short resource body doesn't push the footer down). -const ContentScroll = ScrollArea.withProps({ - flex: "0 1 auto", - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const ContentStack = Stack.withProps({ - gap: "md", -}); - -// Map a file extension to the MIME type that drives ContentViewer's per-MIME -// renderer dispatch. MCP servers commonly omit `mimeType` (or return a generic -// `text/plain` / `application/octet-stream`), so the URI suffix is the most -// reliable signal for engaging the markdown / PDF / CSV / XML / HTML / CSS -// renderers. Order doesn't matter — suffixes are unique. -const URI_SUFFIX_MIME: ReadonlyArray = [ - [".md", "text/markdown"], - [".markdown", "text/markdown"], - [".csv", "text/csv"], - [".json", "application/json"], - [".xml", "application/xml"], - [".html", "text/html"], - [".htm", "text/html"], - [".css", "text/css"], - [".pdf", "application/pdf"], -]; - -// Infer a MIME type from the URI's file extension when the server didn't supply -// one. Returns undefined for unrecognized suffixes so callers fall through to -// the octet-stream default. -function inferMimeFromUri(uri: string): string | undefined { - const path = uri.split("?")[0].split("#")[0]; - const lower = path.toLowerCase(); - for (const [suffix, mime] of URI_SUFFIX_MIME) { - if (lower.endsWith(suffix)) return mime; - } - return undefined; -} - -function effectiveMime( - itemMime: string | undefined, - resource: Resource, -): string { - return ( - itemMime ?? - resource.mimeType ?? - inferMimeFromUri(resource.uri) ?? - "application/octet-stream" - ); -} - -export function ResourcePreviewPanel({ - resource, - contents, - lastUpdated, - isSubscribed, - subscriptionsSupported = true, - onRefresh, - onSubscribe, - onUnsubscribe, - onClose, -}: ResourcePreviewPanelProps) { - const { uri, annotations } = resource; - const mimeType = effectiveMime(contents[0]?.mimeType, resource); - - const [showSource, setShowSource] = useState(false); - // Reset to the rendered view when the previewed resource changes (the panel - // is reused, not remounted, across resources). React's documented - // "adjust state during render" pattern — no effect, so no cascading render. - const [prevUri, setPrevUri] = useState(uri); - if (uri !== prevUri) { - setPrevUri(uri); - setShowSource(false); - } - const sourceToggleable = isSourceToggleable(mimeType); - - return ( - - - - {onClose && ( - - )} - Resource - - - {uri} - - - - - - {contents.map((item, index) => { - const itemMime = effectiveMime(item.mimeType, resource); - // The toggle is gated on the first content item but applies per - // item: in source mode only the source-toggleable items (the ones - // whose rendered view hides their text) switch to plain text, so a - // mixed multi-part resource doesn't force an image/PDF blob through - // the text decoder. - const renderMime = - showSource && isSourceToggleable(itemMime) - ? SOURCE_MIME - : itemMime; - return ( - - ); - })} - - - - {lastUpdated ? ( - {formatLastUpdated(lastUpdated)} - ) : ( - - )} - {contents.length <= 1 && {mimeType}} - - - - {subscriptionsSupported && ( - - )} - Refresh - {sourceToggleable && ( - setShowSource((shown) => !shown)} - > - {showSource ? "View Rendered" : "View Source"} - - )} - - - {annotations?.audience && ( - - )} - {annotations?.priority !== undefined && ( - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx deleted file mode 100644 index 1b96579fb..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Button, Group, Stack, Text, Tooltip } from "@mantine/core"; -import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; - -export interface ResourceSubscribedItemProps { - subscription: InspectorResourceSubscription; - onUnsubscribe: () => void; -} - -const NameText = Text.withProps({ - size: "sm", - fw: 500, - truncate: "end", -}); - -const TimestampText = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -const ItemRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "xs", -}); - -const NameStack = Stack.withProps({ - gap: 2, - flex: 1, - miw: 0, -}); - -function formatLastUpdated(date: Date): string { - return date.toLocaleString(); -} - -// Strip the URI down to its last non-empty path segment so the tile shows -// a compact label (e.g. `file:///foo/bar/config.json` → `config.json`). -// The full URI is restored via a tooltip on hover. -function lastUriSegment(uri: string): string { - const segments = uri.split("/").filter(Boolean); - return segments[segments.length - 1] ?? uri; -} - -export function ResourceSubscribedItem({ - subscription, - onUnsubscribe, -}: ResourceSubscribedItemProps) { - const { resource, lastUpdated } = subscription; - return ( - - - - {lastUriSegment(resource.uri)} - - {lastUpdated && ( - {formatLastUpdated(lastUpdated)} - )} - - Unsubscribe - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx deleted file mode 100644 index 6b506228b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Autocomplete, - Button, - Group, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { accessibleTextColor } from "../../elements/accessibleTextColor"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; - -export interface ResourceTemplatePanelProps { - template: ResourceTemplate; - onReadResource: (uri: string) => void; - /** - * When provided, each keystroke in a variable input dispatches a - * (debounced) `completion/complete` request to the server. The - * resolved values are surfaced as a dropdown via Mantine `Autocomplete`. - * Wire to `InspectorClient.getCompletions` in the host App. - */ - onCompleteArgument?: ( - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; - /** - * Gates whether to render Autocomplete (with live completions) vs the - * plain TextInput. Typically derived from the server's - * `completions` capability. - */ - completionsSupported?: boolean; -} - -const COMPLETION_DEBOUNCE_MS = 300; - -function parseVariableNames(uriTemplate: string): string[] { - const names: string[] = []; - const regex = /\{(\w+)\}/g; - let match: RegExpExecArray | null; - - while ((match = regex.exec(uriTemplate)) !== null) { - names.push(match[1]); - } - - return names; -} - -function resolveUri( - uriTemplate: string, - variables: Record, -): string { - return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]); -} - -function previewUri( - uriTemplate: string, - variables: Record, -): string { - return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) => - variables[key]?.length > 0 ? variables[key] : match, - ); -} - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -const UriGroup = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const UriText = Text.withProps({ - size: "sm", - // Scheme-aware readable blue (`c="blue"` is blue-4 in dark, 4.38:1 on the - // card — just under WCAG AA); see `accessibleTextColor`. - c: accessibleTextColor("blue"), - truncate: "end", -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Left-aligned so the action sits closest to the sidebar controls / the form -// fields above; annotation badges trail it. -const FooterRow = Group.withProps({ - justify: "flex-start", -}); - -const AnnotationGroup = Group.withProps({ - gap: "xs", -}); - -export function ResourceTemplatePanel({ - template, - onReadResource, - onCompleteArgument, - completionsSupported = false, -}: ResourceTemplatePanelProps) { - const { name, title, uriTemplate, description, annotations } = template; - - const variableNames = useMemo( - () => parseVariableNames(uriTemplate), - [uriTemplate], - ); - - const [variables, setVariables] = useState>(() => - Object.fromEntries(variableNames.map((n) => [n, ""])), - ); - const [completions, setCompletions] = useState>({}); - - // Reset state when the user switches to a different template. Keyed on - // `uriTemplate` alone because `variableNames` is memoized from it, so the two - // can never change independently. - useValueChange(uriTemplate, () => { - setVariables(Object.fromEntries(variableNames.map((n) => [n, ""]))); - setCompletions({}); - }); - - // Latest in-flight controller per argument, so a faster keystroke can - // abort an outstanding completion request and the late response can't - // overwrite the fresh one. - const requestsRef = useRef>(new Map()); - // Debounce timer per argument so we don't spam the server on every key. - const timersRef = useRef>>( - new Map(), - ); - - // Drop pending timers / abort in-flight requests on unmount. - useEffect(() => { - const timers = timersRef.current; - const requests = requestsRef.current; - return () => { - for (const t of timers.values()) clearTimeout(t); - timers.clear(); - for (const c of requests.values()) c.abort(); - requests.clear(); - }; - }, []); - - const useAutocomplete = completionsSupported && !!onCompleteArgument; - - const runCompletion = useCallback( - async (varName: string, value: string, context: Record) => { - /* v8 ignore next -- unreachable: runCompletion is only invoked when - useAutocomplete is true, which already requires onCompleteArgument. */ - if (!onCompleteArgument) return; - requestsRef.current.get(varName)?.abort(); - const controller = new AbortController(); - requestsRef.current.set(varName, controller); - try { - const values = await onCompleteArgument(varName, value, context); - if (controller.signal.aborted) return; - setCompletions((prev) => ({ ...prev, [varName]: values })); - } catch { - if (!controller.signal.aborted) { - setCompletions((prev) => ({ ...prev, [varName]: [] })); - } - } finally { - if (requestsRef.current.get(varName) === controller) { - requestsRef.current.delete(varName); - } - } - }, - [onCompleteArgument], - ); - - // Hold the latest `variables` in a ref so a debounced completion - // call reads sibling values at fire time, not at schedule time. - // Typing in A then B within the 300ms window would otherwise ship - // A's request with B's value still empty in context. - const variablesRef = useRef(variables); - useEffect(() => { - variablesRef.current = variables; - }, [variables]); - - function buildContext(varName: string): Record { - const ctx: Record = { ...variablesRef.current }; - delete ctx[varName]; - return ctx; - } - - function handleVariableChange(varName: string, value: string) { - setVariables((prev) => ({ ...prev, [varName]: value })); - if (!useAutocomplete) return; - // Drop the previous prefix's completions so the dropdown doesn't - // show ghost suggestions from the old keystroke while the new - // request is in flight (300ms debounce + network latency). - setCompletions((prev) => { - if (prev[varName] === undefined) return prev; - const next = { ...prev }; - delete next[varName]; - return next; - }); - const existing = timersRef.current.get(varName); - if (existing) clearTimeout(existing); - const timer = setTimeout(() => { - timersRef.current.delete(varName); - // Build context at fire time so sibling updates that arrived - // between schedule and fire are picked up. - void runCompletion(varName, value, buildContext(varName)); - }, COMPLETION_DEBOUNCE_MS); - timersRef.current.set(varName, timer); - } - - function handleVariableFocus(varName: string) { - /* v8 ignore next -- unreachable: the plain (non-autocomplete) TextInput - has no onFocus, so this handler only runs when useAutocomplete is true. */ - if (!useAutocomplete) return; - // Fire immediately so the dropdown isn't empty when the user first - // clicks in. Cancel any pending debounce for this variable so a - // stale keystroke request doesn't overwrite the fresher focus - // response. `variables` already carries every declared template - // variable (seeded with "") so the context is complete by default. - const existing = timersRef.current.get(varName); - if (existing) { - clearTimeout(existing); - timersRef.current.delete(varName); - } - /* v8 ignore next -- the `?? ""` fallback is unreachable: `variables` (and - its ref) is seeded with every declared variable, so the key is present. */ - const value = variablesRef.current[varName] ?? ""; - void runCompletion(varName, value, buildContext(varName)); - } - - const canSubmit = variableNames.every((n) => variables[n]?.length > 0); - - function handleSubmit() { - onReadResource(resolveUri(uriTemplate, variables)); - } - - const preview = previewUri(uriTemplate, variables); - - return ( - - - {title ?? name} Template - - {preview} - - - - {description && {description}} - - {variableNames.map((varName) => { - /* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */ - const fieldValue = variables[varName] ?? ""; - return useAutocomplete ? ( - options} - onChange={(value) => handleVariableChange(varName, value)} - onFocus={() => handleVariableFocus(varName)} - /> - ) : ( - - handleVariableChange(varName, e.currentTarget.value) - } - rightSectionPointerEvents="auto" - rightSection={ - variables[varName] ? ( - handleVariableChange(varName, "")} - /> - ) : null - } - /> - ); - })} - - - - - {annotations?.audience && ( - - )} - {annotations?.priority !== undefined && ( - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx deleted file mode 100644 index 232b5327a..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { - Checkbox, - JsonInput, - MultiSelect, - NumberInput, - Select, - Stack, - Text, - TextInput, -} from "@mantine/core"; -import { useState } from "react"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import type { InspectorFormSchema } from "../../../utils/jsonUtils"; - -const FieldLabel = Text.withProps({ - fw: 500, - size: "sm", -}); - -const FieldDescription = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -// Indented column for a nested object's sub-fields. -const IndentedStack = Stack.withProps({ gap: "sm", pl: "md" }); - -const SchemaJsonInput = JsonInput.withProps({ - formatOnBlur: true, - autosize: true, -}); - -function serializeJson(value: unknown): string { - return JSON.stringify(value, null, 2); -} - -/** - * Pair enum values with their non-standard `enumNames` titles into Mantine - * `{ value, label }[]` option data. Falls back to bare enum values when - * `enumNames` is absent or its length does not match `enum`, since a wrong-length - * zip would mislabel options — worse than showing the raw values. - */ -function toEnumData( - values: string[], - names: string[] | undefined, -): string[] | { value: string; label: string }[] { - if (names && names.length === values.length) { - return values.map((value, index) => ({ value, label: names[index] })); - } - return values; -} - -/** - * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the - * field. Anything that is not a finite number becomes `undefined`, which is how - * an absent optional argument is represented everywhere else in this form. - * - * `NumberInput` emits a `number` only when the text both parses *and* is exactly - * representable; otherwise it hands back the **raw string** (see its - * `isValidNumber` guard). Two quite different situations produce a string, and - * they are treated differently here: - * - * 1. **Mid-entry text** — `""` when cleared, plus `"1."`, `"1.50"`, and a lone - * `"-"`. These are parsed: `"1."` really does mean `1`. (Note that an - * exponent is *not* in this set — `NumberInput` masks input through - * `NumericFormat`, which rejects `e` outright, so `"1e"` can never be typed.) - * 2. **Values JS cannot hold exactly** — anything at or beyond - * `Number.MAX_SAFE_INTEGER`. `Number("90071992547409910")` silently yields - * `90071992547409904`, so parsing here would send the server a number the - * user never entered. An inspector must not misreport what it transmits, so - * these report no value instead — which is also what this field did with such - * input before #1888, making it no regression. Preserving them properly needs - * an exact-serialization path down the whole `tools/call` chain, which is a - * separate concern from being able to type a decimal. - */ -function toNumericValue(raw: string | number): number | undefined { - if (typeof raw === "number") { - return Number.isFinite(raw) ? raw : undefined; - } - if (raw.trim() === "") { - return undefined; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed)) { - return undefined; - } - // Case 2 above. The integer part is what overflows exact representation; the - // fractional digits are bounded by the same guard and stay lossless. - return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined; -} - -interface SchemaNumberInputProps { - label: string; - description?: string; - withAsterisk: boolean; - disabled: boolean; - value: number | undefined; - min?: number; - max?: number; - allowDecimal: boolean; - onChange: (value: number | undefined) => void; -} - -/** - * A `NumberInput` that keeps the text the user is typing, not just the number it - * currently parses to. - * - * Driving `NumberInput` directly off the parent's numeric value makes a decimal - * impossible to enter (#1888): typing `.` after `1` produces the unparseable - * string `"1."`, the numeric value stays `1`, and the controlled `value` prop - * immediately rewrites the box back to `"1"` — so the `.` vanishes and `1.5` can - * never be reached. Trailing zeros (`"1.50"`) and a lone leading `"-"` fail the - * same way. - * - * So the raw text is held here as the source of truth for what is *displayed*, - * while the parent still only ever sees a `number | undefined`. The two are - * re-synced only when the parent's value genuinely diverges from what the draft - * parses to, which leaves an external reset (a cleared form, a loaded example) - * working while an in-progress `"1."` — whose parse is `1`, matching the value we - * just emitted — is left alone. - * - * That value comparison cannot see a reset to an *equal* value, so the caller is - * additionally expected to vary this component's React key via `SchemaForm`'s - * `resetKey` when it switches which entity the form edits. See the note on that - * prop for the case it covers. - */ -function SchemaNumberInput({ - value, - onChange, - ...inputProps -}: SchemaNumberInputProps) { - const [draft, setDraft] = useState(value ?? ""); - - useValueChange(value, (next) => { - if (!Object.is(toNumericValue(draft), next)) { - setDraft(next ?? ""); - } - }); - - return ( - { - setDraft(next); - onChange(toNumericValue(next)); - }} - /> - ); -} - -export interface SchemaFormProps { - schema: InspectorFormSchema; - values: Record; - onChange: (values: Record) => void; - disabled?: boolean; - /** - * Stable identity of whatever this form is editing — a tool name, a request - * id. Pass it whenever the same mounted form is reused for a *different* - * entity, which is the case for the Tools tab: `ToolDetailPanel` is not keyed - * by tool, so selecting another tool re-renders the same field components. - * - * It exists because the number field's draft/value re-sync compares parsed - * numbers, and so cannot detect a reset to an equal value. Type `-` (draft - * `"-"`, value `undefined`), then switch to a tool with a same-named number - * field and no default: the value is `undefined` on both sides, no divergence - * is seen, and the stale `-` would otherwise be left in the box for the new - * tool to continue from. Varying `resetKey` remounts the field instead, so no - * in-progress text can outlive the entity it was typed into. - * - * Omit it when the form is mounted fresh per entity (the elicitation panels), - * where unmounting already discards the draft. The schema object itself is no - * substitute — callers rebuild it every render, so its identity is unstable. - */ - resetKey?: string; -} - -function getDefaultValue(fieldSchema: InspectorFormSchema): unknown { - if (fieldSchema.default !== undefined) { - return fieldSchema.default; - } - return undefined; -} - -function resolveValue( - value: unknown, - fieldSchema: InspectorFormSchema, -): unknown { - if (value !== undefined) { - return value; - } - return getDefaultValue(fieldSchema); -} - -export function SchemaForm({ - schema, - values, - onChange, - disabled = false, - resetKey, -}: SchemaFormProps) { - const properties = schema.properties ?? {}; - const requiredFields = schema.required ?? []; - - function handleFieldChange(fieldName: string, fieldValue: unknown) { - onChange({ ...values, [fieldName]: fieldValue }); - } - - function renderField(fieldName: string, fieldSchema: InspectorFormSchema) { - const isRequired = requiredFields.includes(fieldName); - const label = fieldSchema.title ?? fieldName; - const description = fieldSchema.description; - const rawValue = resolveValue(values[fieldName], fieldSchema); - - // string with enum - if (fieldSchema.type === "string" && fieldSchema.enum) { - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // plain string - if (fieldSchema.type === "string") { - return ( - - handleFieldChange(fieldName, event.currentTarget.value) - } - rightSectionPointerEvents="auto" - rightSection={ - rawValue ? ( - handleFieldChange(fieldName, "")} /> - ) : null - } - /> - ); - } - - // number or integer - if (fieldSchema.type === "number" || fieldSchema.type === "integer") { - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // boolean - if (fieldSchema.type === "boolean") { - return ( - - handleFieldChange(fieldName, event.currentTarget.checked) - } - /> - ); - } - - // array of enum values (multi-select) - if (fieldSchema.type === "array" && fieldSchema.items?.enum) { - const data = toEnumData( - fieldSchema.items.enum, - fieldSchema.items.enumNames, - ); - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // array with items having anyOf - if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) { - const data = fieldSchema.items.anyOf.map((item) => ({ - value: String(item.const ?? ""), - label: item.title ?? String(item.const ?? ""), - })); - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // nested object - if (fieldSchema.type === "object" && fieldSchema.properties) { - return ( - - {label} - {description && {description}} - - ) ?? {}} - onChange={(nestedValues) => - handleFieldChange(fieldName, nestedValues) - } - disabled={disabled} - // Sub-fields belong to the same entity, so they reset with it. - resetKey={resetKey} - /> - - - ); - } - - // fallback: JsonInput for complex schemas - return ( - { - try { - handleFieldChange(fieldName, JSON.parse(val)); - } catch { - handleFieldChange(fieldName, val); - } - }} - /> - ); - } - - return ( - - {Object.entries(properties).map(([fieldName, fieldSchema]) => - renderField(fieldName, fieldSchema), - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx deleted file mode 100644 index 0f088e974..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState } from "react"; -import { - Collapse, - Group, - Paper, - ScrollArea, - Stack, - Title, -} from "@mantine/core"; -import type { CallToolResult } from "@modelcontextprotocol/client"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; - -export interface StructuredOutputPanelProps { - /** The result's `structuredContent` — the tool's schema-validated payload. */ - structuredContent: NonNullable; - /** Whether the section starts expanded. Defaults to `true`. */ - defaultExpanded?: boolean; -} - -// Bordered box matching the "Resource Links" group in the result panel, so the -// two supplementary sections of a tool result read as siblings. -const StructuredBox = Paper.withProps({ - withBorder: true, - radius: "md", - p: "md", - variant: "panel", -}); - -const StructuredInner = Stack.withProps({ - gap: "sm", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -// h4 (size h5) for the same reason as the "Resource Links" heading: the panel's -// "Results" title is h3, so a sub-box heading is h4 and the heading order never -// skips a level (axe `heading-order`). -const StructuredHeader = Title.withProps({ - order: 4, - size: "h5", -}); - -// Caps the payload so a large structured result scrolls within the box instead -// of pushing the content blocks out of view. `Autosize` sizes to the content up -// to `mah`, so a small object still takes only what it needs. -const StructuredScroll = ScrollArea.Autosize.withProps({ - mah: 400, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -/** - * Collapsible "Structured Output" section for a tool result's - * `structuredContent` (#1908). A tool declaring an `outputSchema` returns its - * real payload here — the `content[]` blocks usually only summarize it — so v1 - * rendered it as its own inspectable JSON section. Without this, the payload is - * dropped from the Tools screen entirely, with no hint it was ever returned. - * - * The JSON is pretty-printed and syntax-highlighted through {@link ContentViewer} - * (an `application/json` text block), so it is copyable and scannable field by - * field. - */ -export function StructuredOutputPanel({ - structuredContent, - defaultExpanded = true, -}: StructuredOutputPanelProps) { - const [expanded, setExpanded] = useState(defaultExpanded); - - return ( - - - - Structured Output - setExpanded((value) => !value)} - ariaLabel={`${expanded ? "Collapse" : "Expand"} structured output`} - /> - - {/* Content stays mounted across a collapse (Mantine `Collapse`), so the - highlighted JSON isn't re-rendered from scratch on every toggle. */} - - - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx deleted file mode 100644 index 4ad70d854..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { - Divider, - Group, - ScrollArea, - Stack, - Text, - TextInput, - ThemeIcon, - Title, - Tooltip, -} from "@mantine/core"; -import { RiErrorWarningLine } from "react-icons/ri"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { Tool } from "@modelcontextprotocol/client"; -import type { ExcludedTool } from "@inspector/core/mcp/types.js"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; -import { - ListPaginationControls, - type ListPaginationControlsProps, -} from "../../elements/ListPaginationControls/ListPaginationControls"; -import { ToolListItem } from "../ToolListItem/ToolListItem"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface ToolControlsProps { - tools: Tool[]; - /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` - * annotations (SEP-2243), shown below the list with the reason (#1632). */ - excludedTools?: ExcludedTool[]; - selectedName?: string; - // Search text is controlled by the parent (App, via ToolsScreen) so it - // persists across tab navigation within a live session — see #1417. - searchText?: string; - listChanged: boolean; - onRefreshList: () => void; - /** - * A failed list load, surfaced above the list instead of leaving the panel - * empty (which reads as "this server has none") (#1953). - */ - loadError?: Error | null; - /** Pagination controls (#1721). */ - pagination: ListPaginationControlsProps; - onSearchChange: (value: string) => void; - onSelectTool: (name: string) => void; -} - -// One excluded tool: a warning icon, the tool name (struck through, since it is -// not callable), and its reason on hover. `wrap: nowrap` keeps the icon pinned. -const ExcludedRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - align: "center", -}); - -const ExcludedWarningIcon = ThemeIcon.withProps({ - size: "sm", - variant: "transparent", - c: "var(--inspector-log-warning)", - "aria-hidden": true, -}); - -const ExcludedName = Text.withProps({ - size: "sm", - td: "line-through", - c: "var(--inspector-text-secondary)", - truncate: "end", -}); - -// Fill the full-height `sidebar` Card (a flex column) so the scroll region -// below claims all the remaining space under the fixed title/search — the -// list runs to the bottom of the card before it scrolls, instead of being -// capped short by a fixed max-height. `mih: 0` lets the scroll child shrink -// and scroll rather than overflow the card. -const SidebarStack = Stack.withProps({ - gap: "sm", - flex: 1, - mih: 0, -}); - -// h3 (not h4), size h4: the sampling/elicitation request modals open over this -// screen with an `h2` `Modal.Title`, so an `h4` section would skip a level -// (axe `heading-order`); `size="h4"` keeps the look. -const ToolsTitle = Title.withProps({ - order: 3, - size: "h4", -}); - -const SearchInput = TextInput.withProps({ - placeholder: "Search tools...", - rightSectionPointerEvents: "auto", -}); - -const SidebarScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, -}); - -const ExcludedDivider = Divider.withProps({ - label: "Excluded (SEP-2243)", - labelPosition: "left", - mt: "sm", -}); - -const ExcludedTooltip = Tooltip.withProps({ - multiline: true, - w: 280, - withArrow: true, - position: "right", -}); - -// A server may return the same tool name more than once, so the name alone is -// not a unique React key — colliding keys let a filtered-out row survive -// reconciliation instead of unmounting (#1957). The tool's position in the -// unfiltered list disambiguates duplicates and stays stable while the search -// narrows, since it is captured before filtering. -const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`; - -/** Matches a tool against the (already lower-cased) search query by name or title. */ -const matchesQuery = (tool: Tool, query: string) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false); - -export function ToolControls({ - tools, - excludedTools = [], - selectedName, - searchText = "", - listChanged, - onRefreshList, - loadError, - pagination, - onSearchChange, - onSelectTool, -}: ToolControlsProps) { - const viewportRef = useScrollMemory("tools-sidebar"); - const query = searchText.toLowerCase(); - // Stamp each row's source position before filtering, so the key survives the - // list narrowing (#1957). - const filteredTools = tools - .map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) })) - .filter(({ tool }) => !searchText || matchesQuery(tool, query)); - // Excluded tools are searchable too, matching name AND title like the main - // list above, so a filtered view stays consistent. - const filteredExcluded = excludedTools - .map((excluded, sourceIndex) => ({ - ...excluded, - key: rowKey(excluded.tool.name, sourceIndex), - })) - .filter(({ tool }) => !searchText || matchesQuery(tool, query)); - - return ( - - - Tools - - - onSearchChange(e.currentTarget.value)} - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - - - - {filteredTools.map(({ tool, key }) => ( - { - if (tool.name !== selectedName) onSelectTool(tool.name); - }} - /> - ))} - {filteredExcluded.length > 0 && ( - <> - - {filteredExcluded.map(({ tool, reason, key }) => ( - - - - - - {tool.name} - - - ))} - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx deleted file mode 100644 index fbb6d26d6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { - ActionIcon, - Button, - Code, - Collapse, - Divider, - Group, - Image, - ScrollArea, - Stack, - Switch, - Text, -} from "@mantine/core"; -import { useId, useState } from "react"; -import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri"; -import type { - ProgressNotification, - Tool, - ToolAnnotations, -} from "@modelcontextprotocol/client"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; -import { toFormSchema } from "../../../utils/jsonUtils"; -import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; -import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; -import { SchemaForm } from "../SchemaForm/SchemaForm"; - -export type ToolProgress = Pick< - ProgressNotification["params"], - "progress" | "total" | "message" ->; - -export interface ToolDetailPanelProps { - tool: Tool; - formValues: Record; - isExecuting: boolean; - progress?: ToolProgress; - /** Whether the connected server advertises task-augmented tool calls. */ - serverSupportsTaskToolCalls: boolean; - /** - * Modern (2026-07-28) connection with the `io.modelcontextprotocol/tasks` - * extension negotiated (SEP-2663). Task creation is server-directed there, so - * "Run as task" is offered for ANY tool (not just ones declaring per-tool - * `taskSupport`, which is the legacy mechanism). Defaults to false (legacy). - */ - modernTasks?: boolean; - /** User's "Run as task" preference (meaningful for `optional` tools and, on - * modern connections, any tool). */ - runAsTask: boolean; - onRunAsTaskChange: (value: boolean) => void; - onFormChange: (values: Record) => void; - /** Receives the effective run-as-task decision for this execution. */ - onExecute: (runAsTask: boolean) => void; - onCancel: () => void; -} - -// Outer column: title/annotations pin at top, the Execute footer pins at the -// bottom, and the middle (description + form + progress) scrolls when the -// enclosing card hits its `mah`. `mih: 0` lets the flex children shrink. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, -}); - -const PinnedHeader = Stack.withProps({ - gap: "md", - flex: "0 0 auto", -}); - -// `0 1 auto` + `mih: 0`: shrinks to the available space and scrolls; a short -// form doesn't reserve extra height, keeping Execute snug below it. -const BodyScroll = ScrollArea.withProps({ - flex: "0 1 auto", - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const BodyStack = Stack.withProps({ - gap: "md", -}); - -// Left-aligned (Execute first, Cancel after) so the primary action sits closest -// to the sidebar controls / the form fields above — shortest pointer travel. -const FooterRow = Group.withProps({ - justify: "flex-start", - flex: "0 0 auto", -}); - -const TitleRow = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "center", - miw: 0, -}); - -const ToolIcon = Image.withProps({ - w: 24, - h: 24, - fit: "contain", -}); - -// `flex: 1` lets the title absorb the row's slack so the chevron toggle pins -// to the right edge of the (nowrap) TitleRow. -const ToolTitle = Text.withProps({ - fw: 700, - size: "lg", - truncate: "end", - flex: 1, -}); - -// Chevron toggle for the collapsible description, pinned to the right of the -// title row. `aria-label` is set per-render since it reflects the open state. -const DescriptionToggle = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "sm", -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const CancelButton = Button.withProps({ - variant: "subtle", - color: "red", -}); - -// Left-aligned row hosting the "Run as task" toggle, above the execute footer. -const TaskToggleRow = Group.withProps({ - justify: "flex-start", - flex: "0 0 auto", -}); - -const RunAsTaskSwitch = Switch.withProps({ - size: "sm", - label: "Run as task", -}); - -// Header-mirroring section (SEP-2243): lists which args mirror their value into -// an `Mcp-Param-{Name}` header on `tools/call`. `Stack` (not `Box`) so the -// constant can carry props; the heading + note pin above the mapping rows. -const HeaderParamsSection = Stack.withProps({ - gap: "xs", -}); - -const HeaderParamsTitle = Text.withProps({ - size: "sm", - fw: 600, -}); - -const HeaderParamsNote = Text.withProps({ - size: "xs", - c: "var(--inspector-text-secondary)", -}); - -// One `arg → Mcp-Param-{Name}` mapping row. -const HeaderParamRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const HeaderParamArrow = Text.withProps({ - size: "sm", - c: "var(--inspector-text-secondary)", -}); - -// A tool's per-tool task support, defaulting to "forbidden" (the SDK default -// when `execution` is absent) so tools that say nothing can't be run as tasks. -type TaskSupport = "forbidden" | "optional" | "required"; -function getTaskSupport(tool: Tool): TaskSupport { - return tool.execution?.taskSupport ?? "forbidden"; -} - -function hasAnyAnnotation(annotations?: ToolAnnotations): boolean { - return !!( - annotations && - (annotations.readOnlyHint || - annotations.destructiveHint || - annotations.idempotentHint || - annotations.openWorldHint) - ); -} - -export function ToolDetailPanel({ - tool, - formValues, - isExecuting, - progress, - serverSupportsTaskToolCalls, - modernTasks = false, - runAsTask, - onRunAsTaskChange, - onFormChange, - onExecute, - onCancel, -}: ToolDetailPanelProps) { - const { name, title, description, icons, annotations, inputSchema } = tool; - // Narrow the SDK protocol schema to the form renderer's schema type. - const formSchema = toFormSchema(inputSchema) ?? {}; - const iconSrc = icons?.[0]?.src; - // SEP-2243: args this tool declares as `x-mcp-header` — their values mirror - // into `Mcp-Param-{Name}` headers on a `tools/call` (#1632). - const mirroredParams = getMirroredHeaderParams(tool); - - // Descriptions are shown by default (most are short); the chevron lets the - // user hide a long one to keep the form and Execute footer in view. Reset to - // shown when switching tools (React's adjust-state-during-render pattern) so - // a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen - // clears formValues on change. - const [descriptionOpen, setDescriptionOpen] = useState(true); - const [prevToolName, setPrevToolName] = useState(name); - if (name !== prevToolName) { - setPrevToolName(name); - setDescriptionOpen(true); - } - // Ties the toggle to the Collapse region so assistive tech announces it as a - // single expandable control (aria-expanded + aria-controls). - const descriptionRegionId = useId(); - - // Show the toggle when the server supports task tool calls and either the - // connection is modern (task creation is server-directed there, so any tool - // may become a task) or the tool doesn't forbid per-tool task support - // (legacy). `required` tools are forced on (checked + disabled); `optional` - // and (on modern) any tool follow the user's `runAsTask` choice. - // - // NOTE: on modern, a per-tool `taskSupport: "forbidden"` is DELIBERATELY - // ignored. Under SEP-2663 task creation is decided by the server per request, - // not declared per tool, so `taskSupport` (a legacy 2025-11-25 concept) does - // not gate the affordance — the server may return a task for any call. The - // toggle just declares intent to poll a returned handle. - const taskSupport = getTaskSupport(tool); - const showRunAsTask = - serverSupportsTaskToolCalls && (modernTasks || taskSupport !== "forbidden"); - // Gate the effective decision on `showRunAsTask`: a stale `runAsTask`/`required` - // value must not route through callToolStream when the toggle is hidden. On - // legacy, a tool's taskSupport is only considered when the server advertises - // `tasks.requests.tools.call`; on modern, the user's choice governs any tool. - const effectiveRunAsTask = - showRunAsTask && - (taskSupport === "required" || - ((taskSupport === "optional" || modernTasks) && runAsTask)); - - return ( - - - - {iconSrc && } - {resolveDisplayLabel(name, title)} - {description && ( - setDescriptionOpen((open) => !open)} - > - {descriptionOpen ? : } - - )} - - {hasAnyAnnotation(annotations) && annotations && ( - - {annotations.readOnlyHint && ( - - )} - {annotations.destructiveHint && ( - - )} - {annotations.idempotentHint && ( - - )} - {annotations.openWorldHint && ( - - )} - - )} - - - - - {description && ( - - {description} - - )} - - - - {mirroredParams.length > 0 && ( - - - Mirrored request headers (SEP-2243) - - {mirroredParams.map((param) => ( - - {param.path} - - {param.header} - - ))} - - These argument values are mirrored into HTTP headers on the - call. In the web client the Mcp-Param-* headers are - applied by the Node backend that issues the upstream request. - - - )} - - - - {progress && } - - - - {showRunAsTask && ( - - onRunAsTaskChange(event.currentTarget.checked)} - /> - - )} - - - - {isExecuting && Cancel} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx deleted file mode 100644 index d109d0961..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core"; -import type { Tool } from "@modelcontextprotocol/client"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; - -export interface ToolListItemProps { - tool: Tool; - selected: boolean; - onClick: () => void; -} - -const ItemLabel = Text.withProps({ - fw: 500, - truncate: true, -}); - -const ItemSubLabel = Text.withProps({ - size: "xs", - c: "dimmed", - truncate: true, -}); - -const ItemBody = Stack.withProps({ - gap: 2, - flex: 1, - miw: 0, -}); - -const Row = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "flex-start", -}); - -const ToolIcon = Image.withProps({ - w: 20, - h: 20, - fit: "contain", -}); - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) { - const { name, title, icons } = tool; - const iconSrc = icons?.[0]?.src; - - return ( - - - {iconSrc && } - - {resolveDisplayLabel(name, title)} - {title && {name}} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx deleted file mode 100644 index 8c6ca8b0f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - Alert, - CloseButton, - Code, - Group, - Stack, - Text, - Title, -} from "@mantine/core"; -import { classifyToolCallError } from "./toolResultUtils"; - -export interface ToolCallErrorPanelProps { - /** The thrown error's message (already stringified in App). */ - error: string; - /** - * The JSON-RPC error code, when the throw was a `ProtocolError`. Under SDK v2 - * an unknown-tool `tools/call` REJECTS with `-32602 Invalid params` instead of - * resolving an `isError` result, so it arrives here as a thrown error rather - * than a `CallToolResult` (which the ToolResultPanel would render). The same - * `-32602` is also thrown for a known tool called with invalid arguments, so - * the heading/hint are chosen from the message, not the code alone. - */ - errorCode?: number; - /** Dismiss the error and return to the input form (mirrors ToolResultPanel). */ - onClear: () => void; -} - -// Mirrors ToolResultPanel's column so an error dismisses the same way a result -// does: header with the close X pins, the alert fills and scrolls below it. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, - flex: 1, -}); - -const HeaderRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - flex: "0 0 auto", -}); - -const HintText = Text.withProps({ - size: "sm", - c: "var(--inspector-text-secondary)", -}); - -// h3 (not h4), size h4: request modals open over the Tools screen with an `h2` -// `Modal.Title`, so an `h4` here would skip a level (axe `heading-order`); -// `size="h4"` keeps the visual size. -const PanelTitle = Title.withProps({ order: 3, size: "h4" }); - -const ErrorAlert = Alert.withProps({ color: "red", variant: "light" }); - -const ERROR_TITLES: Record = { - "unknown-tool": "Unknown Tool", - "invalid-params": "Invalid Parameters", - generic: "Tool Error", -}; - -/** - * Renders a thrown tool-call error (a protocol/SDK-level rejection) as a - * distinct error panel. This is separate from ToolResultPanel, which renders a - * `CallToolResult` (including a tool-level `isError` result). An `-32602` - * rejection carries no result, so it would otherwise be invisible. - */ -export function ToolCallErrorPanel({ - error, - errorCode, - onClear, -}: ToolCallErrorPanelProps) { - const kind = classifyToolCallError(errorCode, error); - return ( - - - - Tool Call Failed - - - - {error} - {kind === "unknown-tool" && ( - - The server rejected this call with -32602 (Invalid - params) — it does not recognize this tool. It may have been - excluded for an invalid x-mcp-header annotation or - removed since the list was last fetched. Try refreshing the tools - list. - - )} - {kind === "invalid-params" && ( - - The server rejected this call with -32602 (Invalid - params). Check the argument values against the tool's schema. - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx deleted file mode 100644 index 33ca77617..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { - Alert, - CloseButton, - Group, - Paper, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; -import type { - CallToolResult, - ReadResourceResult, -} from "@modelcontextprotocol/client"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { ResourceLink } from "../ResourceLink/ResourceLink"; -import { StructuredOutputPanel } from "../StructuredOutputPanel/StructuredOutputPanel"; -import { resultHasResourceLinks } from "./toolResultUtils"; - -export interface ToolResultPanelProps { - result: CallToolResult; - /** - * Dismiss the result and return to the input form (#1661). Mirrors the - * Prompts screen: the result replaces the form while present, and the - * top-left X flips back to the form so the tool can be re-run. - */ - onClear: () => void; - /** - * Read-on-demand handler so `resource_link` blocks in the result can fetch - * and inline their contents. - */ - onReadResource?: (uri: string) => Promise; -} - -type ContentBlock = CallToolResult["content"][number]; -type ResourceLinkBlock = Extract; - -// A result's content is rendered as a run of segments in original order: each -// non-link block on its own, and every maximal run of consecutive -// `resource_link` blocks collapsed into one grouped "Resource Links" box. -type ResultSegment = - | { kind: "links"; links: { block: ResourceLinkBlock; index: number }[] } - | { kind: "block"; block: ContentBlock; index: number }; - -// Walk the content array once, coalescing adjacent `resource_link` blocks into a -// single `links` segment so a run of links renders inside one scrollable box -// while preserving the overall block order. -function segmentContent(content: ContentBlock[]): ResultSegment[] { - const segments: ResultSegment[] = []; - content.forEach((block, index) => { - if (block.type === "resource_link") { - const last = segments[segments.length - 1]; - if (last && last.kind === "links") { - last.links.push({ block, index }); - } else { - segments.push({ kind: "links", links: [{ block, index }] }); - } - } else { - segments.push({ kind: "block", block, index }); - } - }); - return segments; -} - -// Outer column fills the (full-height) result card: the header pins -// (`flex: 0 0 auto`) and the body below fills the rest. `mih: 0` lets the flex -// children shrink below their content's intrinsic height. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, - flex: 1, -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - flex: "0 0 auto", -}); - -// Close button + title, mirroring PromptMessagesDisplay so the two result -// panels dismiss the same way. -const HeaderLeft = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -// Body scroll region for non-resource-link results: fills the card and scrolls -// within it when the content is taller than the available space. -const ResultScroll = ScrollArea.withProps({ - flex: 1, - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const ResultStack = Stack.withProps({ - gap: "md", -}); - -// A non-link block that shares the card with a "Resource Links" box: capped at -// half the available height and scrollable within, so a long text block can't -// crowd the links box out of view (it keeps the remaining space). `Autosize` -// sizes to content up to the cap, so a short block still takes only what it -// needs. Without links, non-link blocks flow in the main scroll body instead. -const NonLinkCap = ScrollArea.Autosize.withProps({ - mah: "50%", - flex: "0 1 auto", - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -// Body column for results that contain a "Resource Links" box: it fills the -// card so the box (which is `flex: 1` within it) can grow to the available -// height and scroll internally, rather than capping at its content height. -const FillStack = Stack.withProps({ - gap: "md", - flex: 1, - mih: 0, -}); - -// Grouped container for a run of `resource_link` blocks — a bordered box with a -// pinned "Resource Links" heading and its own scroll region, mirroring the -// "Messages" box in the Protocol monitoring sidebar (ProtocolListPanel). The -// `panel` variant makes it a flex column (overflow hidden, min-height 0) and -// `flex: 1` lets it grow to fill the result card's available height. -const ResourceLinksBox = Paper.withProps({ - withBorder: true, - radius: "md", - p: "md", - variant: "panel", - flex: 1, - mih: 0, -}); - -const ResourceLinksInner = Stack.withProps({ - gap: "sm", - flex: 1, - mih: 0, -}); - -const ResourceLinksHeader = Title.withProps({ - // The panel "Results" title is h3 (size h4); this sub-box heading is h4 so the - // heading order doesn't skip a level (axe `heading-order`). - order: 4, - size: "h5", -}); - -// Fills the box below the pinned heading and scrolls the link list within it. -const ResourceLinksScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const ResourceLinksStack = Stack.withProps({ - gap: "sm", -}); - -// h3 (not h4), size h4: request modals open over the Tools screen with an `h2` -// `Modal.Title`, so an `h4` here would skip a level (axe `heading-order`); -// `size="h4"` preserves the visual size. -const ResultsTitle = Title.withProps({ - order: 3, - size: "h4", -}); - -const ErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Tool Error", -}); - -function ResourceLinksGroup({ - links, - onReadResource, -}: { - links: { block: ResourceLinkBlock; index: number }[]; - onReadResource?: (uri: string) => Promise; -}) { - return ( - - - Resource Links - - - {links.map(({ block, index }) => ( - - ))} - - - - - ); -} - -export function ToolResultPanel({ - result, - onClear, - onReadResource, -}: ToolResultPanelProps) { - const segments = - result.isError || result.content.length === 0 - ? [] - : segmentContent(result.content); - // Results with a Resource Links box fill the card so the box grows to the - // available height (and scrolls inside). Plain text/image results keep the - // scroll-within-card body so a short result doesn't reserve empty height. - const hasLinks = resultHasResourceLinks(result); - // A tool with an `outputSchema` returns its real payload in - // `structuredContent`, which the `content[]` blocks typically only summarize - // (#1908). Render it as its own section — including alongside an error or an - // empty `content` array, so it is never silently dropped. - const structuredNode = result.structuredContent ? ( - - ) : null; - - const segmentNodes = segments.map((segment) => { - if (segment.kind === "links") { - return ( - - ); - } - const viewer = ( - - ); - // Alongside a Resource Links box, cap the block at half the height (and let - // it scroll); on its own it flows in the outer scroll body uncapped. - return hasLinks ? ( - {viewer} - ) : ( - viewer - ); - }); - - return ( - - - - - Results - - - {result.isError ? ( - - - - {result.content - .filter((b) => b.type === "text") - .map((b) => b.text) - .join("\n")} - - {structuredNode} - - - ) : result.content.length === 0 && !structuredNode ? ( - - No results yet - - ) : hasLinks ? ( - - {segmentNodes} - {structuredNode} - - ) : ( - - - {segmentNodes} - {structuredNode} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts deleted file mode 100644 index b5f78c79f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CallToolResult } from "@modelcontextprotocol/client"; -import { ProtocolErrorCode } from "@modelcontextprotocol/client"; - -/** How a thrown `tools/call` error should be presented in the error panel. */ -export type ToolCallErrorKind = "unknown-tool" | "invalid-params" | "generic"; - -/** - * Whether a `-32602` message names the *tool itself* as unrecognized, as - * opposed to reporting bad arguments for a known tool. Both reject with the same - * `-32602 Invalid params` code under SDK v2, so the code alone can't tell them - * apart — matching the message lets us pick the right heading instead of - * labelling every `-32602` "Unknown Tool" (which would mislabel a known tool - * called with invalid arguments). - * - * The match is deliberately tool-scoped so it doesn't INVERSELY mislabel: an - * argument-validation message like `"property 'region' does not exist"` must - * NOT read as "Unknown Tool". So the "not found / does not exist / unknown / - * unrecognized" family only counts when the word "tool" is in the same clause - * (the SDK's own message is `Tool not found`); `unknown tool` / `no such - * tool` are unambiguous on their own. Case-insensitive; best-effort — the fully - * unambiguous signal would be a tool name in `error.data`, which the SDK does - * not currently surface here. - */ -const UNKNOWN_TOOL_MESSAGE = - /\b(unknown tool|no such tool)\b|\btool\b[^.!?]*\b(not found|not recognized|does not exist|is unknown|unrecognized)\b/i; - -/** - * Classify a thrown tool-call error for display (#1632). Under SDK v2 an - * unknown-tool `tools/call` REJECTS with `-32602 Invalid params` instead of - * resolving an `isError` result — but so does a *known* tool called with - * invalid arguments (server-side schema validation). We narrow the ambiguous - * `-32602` to `"unknown-tool"` only when the message says so; any other - * `-32602` is `"invalid-params"`, and every other code is `"generic"`. - */ -export function classifyToolCallError( - errorCode?: number, - message?: string, -): ToolCallErrorKind { - if (errorCode !== ProtocolErrorCode.InvalidParams) return "generic"; - if (message && UNKNOWN_TOOL_MESSAGE.test(message)) return "unknown-tool"; - return "invalid-params"; -} - -/** - * Whether a result renders a "Resource Links" box — i.e. a non-error result - * with at least one `resource_link` block. Hosts use this to decide whether the - * result surface should fill the available height (so the box can grow and - * scroll internally); plain text/image results keep their content-sized card. - */ -export function resultHasResourceLinks(result: CallToolResult): boolean { - return ( - !result.isError && - result.content.some((block) => block.type === "resource_link") - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts deleted file mode 100644 index 845f39214..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js"; -import { - isInputRequiredResult, - SUBSCRIPTION_ID_META_KEY, -} from "@modelcontextprotocol/client"; - -export function extractMethod(entry: MessageEntry): MessageMethod { - if ("method" in entry.message) { - // Cast: SDK types message.method as `string`, but every entry in this - // app's MessageEntry log originates from MCP SDK schemas. - return entry.message.method as MessageMethod; - } - return "response"; -} - -/** - * Request methods the Protocol Replay action can re-issue (client→server reads - * and calls). Server→client requests (roots/list, sampling, elicitation) and - * side-effectful methods (logging/setLevel, subscribe) are intentionally - * excluded. Single source of truth: `ProtocolEntry` hides the Replay button for - * anything not listed here, and App's `replayProtocolRequest` gates dispatch on - * the same set. - */ -export const REPLAYABLE_PROTOCOL_METHODS: ReadonlySet = new Set([ - "tools/call", - "prompts/get", - "resources/read", - "tools/list", - "prompts/list", - "resources/list", - "resources/templates/list", - "tasks/list", - "ping", -]); - -export function isReplayableProtocolMethod(method: string): boolean { - return REPLAYABLE_PROTOCOL_METHODS.has(method); -} - -// --- Modern-era (2026-07-28) message vocabulary ----------------------------- -// -// The modern era changes the over-the-wire conversation (spec §7.2–7.4): every -// result carries a `resultType`, server→client interactions become MRTR (the -// server *returns* `input_required` and the client *retries* with a new id), -// and push notifications move to a `subscriptions/listen` stream. These helpers -// read that vocabulary off the transport-level `MessageEntry` log so the -// Protocol view can render and correlate it. IMPORTANT: they classify individual -// *frames* — they must NOT be used to decide a connection's era. The modern -// probe carries the `_meta` envelope before the era is negotiated, so "saw an -// envelope/resultType" ≠ "modern negotiated" (spec §8.3). Era labeling comes -// from the negotiated `protocolEra` connection state, threaded in as a prop. - -// The successful `result` object of a request entry's paired response, or -// undefined when there is no response or it was an error. (messageLogState folds -// a request's response onto the request entry by JSON-RPC id.) -function getResponseResult( - entry: MessageEntry, -): Record | undefined { - const response = entry.response; - if (!response || "error" in response) return undefined; - const result = response.result; - return result && typeof result === "object" - ? (result as Record) - : undefined; -} - -function getMessageParams( - entry: MessageEntry, -): Record | undefined { - const msg = entry.message; - if (!("params" in msg) || !msg.params) return undefined; - return msg.params as Record; -} - -/** - * The modern `resultType` discriminator on a request's paired result: - * `"input_required"` (the server needs input before it can complete) or - * `"complete"`. Undefined for legacy results (no `resultType` on the wire), - * errors, notifications, and pending requests — so a `resultType` badge only - * shows where the modern era actually put one. - */ -export function extractResultType( - entry: MessageEntry, -): "complete" | "input_required" | undefined { - const result = getResponseResult(entry); - if (!result) return undefined; - if (isInputRequiredResult(result)) return "input_required"; - return result.resultType === "complete" ? "complete" : undefined; -} - -/** - * The opaque MRTR `requestState` token that links the rounds of one logical - * operation across multiple JSON-RPC ids. It appears on the `input_required` - * *result* (original call) and is echoed back in the *params* of the retried - * request (spec §7.3). Returns undefined for non-MRTR traffic. - */ -export function extractRequestState(entry: MessageEntry): string | undefined { - const result = getResponseResult(entry); - const fromResult = result?.requestState; - if (typeof fromResult === "string" && fromResult.length > 0) - return fromResult; - const params = getMessageParams(entry); - const fromParams = params?.requestState; - if (typeof fromParams === "string" && fromParams.length > 0) - return fromParams; - return undefined; -} - -/** - * The `subscriptionId` a modern push notification is tagged with, carried in - * `params._meta` under `io.modelcontextprotocol/subscriptionId` (spec §7.4). - * Undefined for untagged frames. - */ -export function extractSubscriptionId(entry: MessageEntry): string | undefined { - const params = getMessageParams(entry); - const meta = params?._meta as Record | undefined; - const id = meta?.[SUBSCRIPTION_ID_META_KEY]; - return typeof id === "string" ? id : undefined; -} - -/** - * A rendered row in the Protocol list: either a single message entry, or an - * MRTR conversation — the contiguous run of entries sharing one `requestState` - * (original call → `input_required` → retried call → final result), grouped so - * one logical operation renders as one expandable unit. - */ -export type ProtocolRow = - | { kind: "single"; entry: MessageEntry } - | { kind: "mrtr"; requestState: string; rounds: MessageEntry[] }; - -/** - * Fold a (already filtered/sorted) entry list into rows, clustering contiguous - * entries that share a non-empty `requestState` into one MRTR row. Contiguity is - * safe because the SDK auto-fulfils MRTR input in-process (no intervening wire - * frames) so an operation's rounds are adjacent in the log. Order is preserved; - * everything without a `requestState` stays a `single` row. - */ -export function groupProtocolEntries(entries: MessageEntry[]): ProtocolRow[] { - const rows: ProtocolRow[] = []; - for (const entry of entries) { - const requestState = extractRequestState(entry); - if (requestState) { - const last = rows[rows.length - 1]; - if (last?.kind === "mrtr" && last.requestState === requestState) { - last.rounds.push(entry); - continue; - } - rows.push({ kind: "mrtr", requestState, rounds: [entry] }); - continue; - } - rows.push({ kind: "single", entry }); - } - return rows; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx deleted file mode 100644 index deaecd374..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ /dev/null @@ -1,795 +0,0 @@ -import { useCallback, useEffect, useRef, useState, type Ref } from "react"; -import { - ActionIcon, - Button, - Card, - Code, - Collapse, - Flex, - Group, - Image, - Paper, - ScrollArea, - Stack, - Text, - Title, - Tooltip, -} from "@mantine/core"; -import { - MdArrowBack, - MdClose, - MdFullscreen, - MdFullscreenExit, -} from "react-icons/md"; -import type { - ContentBlock, - LoggingMessageNotification, - Tool, -} from "@modelcontextprotocol/client"; -import type { - AppBridgeEventMap, - McpUiDisplayMode, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import { - AppRenderer, - type AppRendererHandle, - type AppRendererStatus, - type BridgeFactory, -} from "../../elements/AppRenderer/AppRenderer"; -import { HOST_AVAILABLE_DISPLAY_MODES } from "../../elements/AppRenderer/createAppBridgeFactory"; -import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel"; -import { AppControls } from "../../groups/AppControls/AppControls"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { LogLevelBadge } from "../../elements/LogLevelBadge/LogLevelBadge"; -import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils"; -import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; - -export interface AppsScreenProps { - tools: Tool[]; - listChanged: boolean; - /** - * URL of the inspector's sandbox proxy page (the trusted outer iframe). When - * undefined, MCP Apps cannot run (legacy backend, or a build without the - * sandbox controller) and the screen renders an unavailable state instead of - * a silently blank iframe. - */ - sandboxPath?: string; - bridgeFactory: BridgeFactory; - rendererRef: Ref; - ui: AppsUiState; - onUiChange: (next: AppsUiState) => void; - onRefreshList: () => void; - onSelectApp: (name: string) => void; - onOpenApp: (name: string, args: Record) => void; - onCloseApp: () => void; - /** Surfaces bridge/runtime failures from the renderer (e.g. no client). */ - onError?: (err: Error) => void; - /** - * Deep-link auto-open (#1577): when true and an app is already pre-selected - * (the parent seeds `ui.selectedAppName` + `ui.formValues` from the URL), - * the screen fires "Open App" automatically — no explicit click. Token-gated - * upstream in `parseDeepLink` (the URL value must equal the session token), - * so a third-party link cannot auto-invoke a tool. Fires exactly once. - */ - autoOpen?: boolean; -} - -// Selected app, its form values, and the sidebar search — controlled by the -// parent (App) as one object so they persist across tab navigation within a -// live session (#1417). `running`/`maximized` stay local to the screen: they're -// tied to the live iframe and bridge, which are torn down on unmount, so -// persisting them would restore a flag without its runtime. On return the -// selected app's input form (with its values) is shown, ready to re-open. -export interface AppsUiState { - selectedAppName?: string; - formValues: Record; - search: string; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", - align: "flex-start", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -// `variant="preview"` (overflow: hidden) keeps the full-height card from -// bleeding past the viewport: the running app's iframe fills it, and the -// app-input form scrolls internally (see AppDetailPanel's PanelScroll). -// `flex: 1` + `h: "100%"` make it fill the screen column (both call sites do). -const ContentCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", - flex: 1, - h: "100%", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "sm", -}); - -const HeaderLabel = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "center", - flex: 1, - miw: 0, -}); - -const HeaderIcon = Image.withProps({ - w: 24, - h: 24, - fit: "contain", -}); - -const HeaderTitle = Text.withProps({ - fw: 600, - size: "lg", - truncate: true, - flex: 1, - miw: 0, -}); - -const HeaderActions = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const BackToInputButton = Button.withProps({ - variant: "subtle", - size: "sm", - leftSection: , -}); - -const CloseIconButton = ActionIcon.withProps({ - variant: "subtle", - "aria-label": "Close", -}); - -// The host-controlled box the running app sits within. Its size is driven by -// the host's layout (window resize, sidebar toggle, maximize) and NOT by the -// view's reported content height — that drives the inner RendererFrame — so the -// renderer's containerDimensions observer can measure this element without -// coupling host→view container size to view→host size-changed. -const RendererContainer = Stack.withProps({ - flex: 1, - miw: 0, - mih: 0, - gap: 0, -}); - -// The inner box that actually holds the iframe. Sized by the view-reported -// content height (see `contentHeight`) and capped at the outer container. -// Distinct from RendererContainer above so the two roles read clearly in JSX. -const RendererFrame = Stack.withProps({ - miw: 0, - mih: 0, - gap: 0, -}); - -const ContentStack = Stack.withProps({ - gap: "md", - h: "100%", -}); - -// Pinned panel below the running app (used by both the message log and the -// app-log panel). `0 0 auto` keeps it at its content height (capped by the -// inner scroll's `mah`) so it never squeezes out the iframe above it. -const PinnedPanel = Stack.withProps({ - gap: "xs", - flex: "0 0 auto", - mih: 0, -}); - -const LogScroll = ScrollArea.withProps({ - mah: 200, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const MessageLogStack = Stack.withProps({ - gap: "sm", -}); - -const MessageItem = Paper.withProps({ - p: "md", - radius: "md", - withBorder: true, -}); - -const MessageItemStack = Stack.withProps({ - gap: "xs", -}); - -const MonoCaption = Text.withProps({ - size: "xs", - c: "dimmed", - ff: "monospace", -}); - -const AppLogList = Stack.withProps({ - gap: "xs", -}); - -const AppLogRow = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "flex-start", -}); - -const AppLogData = Code.withProps({ - block: true, - fz: "xs", -}); - -const CompactSubtleButton = Button.withProps({ - variant: "subtle", - size: "compact-xs", -}); - -const PanelHeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "sm", -}); - -const PartialStageControls = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "center", - flex: "0 0 auto", -}); - -const StagePartialButton = Button.withProps({ - variant: "default", - size: "compact-xs", -}); - -const PartialStageCount = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -const AppErrorPanel = Paper.withProps({ - p: "md", - radius: "md", - withBorder: true, - c: "var(--inspector-log-error)", -}); - -const AppErrorTitle = Text.withProps({ - fw: 600, - size: "sm", -}); - -const AppErrorMessage = Text.withProps({ - size: "sm", - ff: "monospace", -}); - -/** Render a log payload as a string for display. */ -function formatLogData(data: unknown): string { - if (typeof data === "string") return data; - try { - // JSON.stringify(undefined) returns the value `undefined`, not a string, so - // coalesce to "" to keep the `: string` return type honest for a data-less - // log (spec-required, so this is only defensive against a malformed view). - return JSON.stringify(data) ?? ""; - } catch { - /* v8 ignore next -- JSON.stringify only throws on a BigInt or a circular - structure; a log payload delivered over postMessage is already - structured-clone-safe, so this fallback is unreachable in practice. */ - return String(data); - } -} - -/** - * Soft cap on retained message / log entries per run. Chatty widgets can emit - * logs in a loop; keep only the most recent so the panels (and their DOM rows) - * don't grow without bound between Clear/close. Oldest entries are dropped. - */ -const MAX_APP_CHANNEL_ENTRIES = 500; - -/** Append to a capped list, dropping the oldest entries past the cap. */ -function appendCapped(prev: T[], next: T): T[] { - const grown = [...prev, next]; - return grown.length > MAX_APP_CHANNEL_ENTRIES - ? grown.slice(grown.length - MAX_APP_CHANNEL_ENTRIES) - : grown; -} - -// A user-role message submitted by the running view through ui/message. The -// inspector has no conversation to append to, so it just records the content -// blocks for display. `role`/`content` mirror McpUiMessageRequest["params"]; -// `id` is a stable React key (like AppLogEntry) so the appendCapped front-drop -// can't renumber keys the way an array index would. -interface AppMessage { - id: number; - role: "user"; - content: ContentBlock[]; -} - -/** - * One MCP `notifications/message` log entry from the running app, with the - * payload stringified once at capture time so the render path can use it - * directly. `id` is a stable React key. - */ -interface AppLogEntry { - id: number; - level: LoggingMessageNotification["params"]["level"]; - logger?: string; - text: string; -} - -export function AppsScreen({ - tools, - listChanged, - sandboxPath, - bridgeFactory, - rendererRef, - ui, - onUiChange, - onRefreshList, - onSelectApp, - onOpenApp, - onCloseApp, - onError, - autoOpen = false, -}: AppsScreenProps) { - const { selectedAppName, formValues, search } = ui; - const [running, setRunning] = useState(false); - const [maximized, setMaximized] = useState(false); - const rendererContainerRef = useRef(null); - const nextLogIdRef = useRef(0); - const nextMessageIdRef = useRef(0); - // Height (px) the running view last reported via ui/notifications/size-changed. - // Undefined until the view reports (or after it's torn down), in which case - // the iframe fills the available card space as before. Local to the screen - // like `running`/`maximized`: it's tied to the live iframe, not persisted. - const [appHeight, setAppHeight] = useState(undefined); - // Messages the running view has pushed via ui/message. The inspector has no - // chat loop, so they're collected here and shown in a log below the app - // rather than continuing a conversation. Local to the screen like `running`: - // tied to the live bridge, cleared when the open ends or the app changes. - const [messages, setMessages] = useState([]); - // Standard MCP log notifications (notifications/message) the running app - // emits. The host advertises the `logging` capability; without surfacing - // these here they'd be silently dropped by the bridge. Same lifecycle as - // `messages`: tied to the live bridge, cleared on open/close/switch. - const [appLogs, setAppLogs] = useState([]); - // Expanded by default so a widget developer sees the entries without an extra - // click. The user can still collapse it for the rest of the run. - const [appLogsExpanded, setAppLogsExpanded] = useState(true); - // Snapshots of the input form captured via "Stage partial input". On Open - // App they're passed to AppRenderer as `partialInputs` and replayed via - // ui/notifications/tool-input-partial before the complete tool-input, so a - // widget's progressive-render path can be exercised. Cleared on switch/close. - const [partialStages, setPartialStages] = useState[]>( - [], - ); - // High-level renderer lifecycle, surfaced as `data-app-status` on the - // `apps-form` card so an automated driver can poll - // `[data-app-status="ready"]` instead of racing the iframe selector. - const [appStatus, setAppStatus] = useState( - "idle", - ); - // The error that put the renderer into status="error" (factory throw, no - // connected client). Shown in place of the blank iframe and surfaced as - // `data-app-error` on the `apps-form` card so an automated driver can read - // *why* the open failed without screenshotting a toast. - const [appError, setAppError] = useState(undefined); - - const selectedTool = selectedAppName - ? tools.find((t) => t.name === selectedAppName) - : undefined; - const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false; - - // The running view reports its rendered content height via - // ui/notifications/size-changed; honor it so the iframe is neither clipped - // nor surrounded by dead space. Width is left at the host-controlled - // container width. The value is clamped to the available space by the - // renderer frame's `mah` below, and ignored while maximized (the app fills - // the screen instead). A non-positive height is ignored — a view's - // ResizeObserver can transiently fire 0 before layout settles or during - // teardown, which would otherwise collapse the frame (mirrors AppRenderer's - // own 0×0 skip on the container side). - function handleSizeChange(size: AppBridgeEventMap["sizechange"]) { - if (size.height != null && size.height > 0) setAppHeight(size.height); - } - - function handleMessage(params: Omit) { - setMessages((prev) => - appendCapped(prev, { id: nextMessageIdRef.current++, ...params }), - ); - } - - function handleLog(params: LoggingMessageNotification["params"]) { - setAppLogs((prev) => - appendCapped(prev, { - id: nextLogIdRef.current++, - level: params.level, - logger: params.logger, - text: formatLogData(params.data), - }), - ); - } - - // Clear the message + log panels (and the reported height). Called when a run - // ends or the selected app changes so a new run starts clean. `keepPartials` - // is set for `handleOpen`, where the staged fragments are about to be consumed - // by the renderer and must not be cleared first. Memoized (stable setState - // calls only) so the deep-link auto-open effect's deps don't churn. - const resetAppChannels = useCallback((opts?: { keepPartials?: boolean }) => { - setAppHeight(undefined); - setMessages([]); - setAppLogs([]); - setAppLogsExpanded(true); - setAppStatus("idle"); - setAppError(undefined); - if (!opts?.keepPartials) setPartialStages([]); - }, []); - - // Capture the error locally (so it can be shown in the card and surfaced as - // `data-app-error`) and forward it to the parent's onError. The renderer - // already drives `data-app-status="error"` via onAppStatusChange; this adds - // the *reason* alongside it. - function handleAppError(err: Error) { - setAppError(err); - onError?.(err); - } - - function handleStagePartialInput() { - setPartialStages((prev) => [...prev, { ...formValues }]); - } - - // The app's display mode is derived from the existing maximized toggle. - // Passed to AppRenderer so the running view receives it via - // host-context-changed; the Maximize/Restore button below keeps toggling - // `maximized`, which now flows out as a protocol event. - const displayMode: McpUiDisplayMode = maximized ? "fullscreen" : "inline"; - - // Handle a view-originated ui/request-display-mode. Only modes the inspector - // advertises in `availableDisplayModes` are honored — an unsupported request - // (e.g. "pip") is declined by returning the current mode, per spec. - function handleRequestDisplayMode( - requested: McpUiDisplayMode, - ): McpUiDisplayMode { - if (!HOST_AVAILABLE_DISPLAY_MODES.includes(requested)) return displayMode; - setMaximized(requested === "fullscreen"); - return requested; - } - - function handleSelect(name: string) { - if (name === selectedAppName) return; - const next = tools.find((t) => t.name === name); - if (!next) return; - // Seed schema defaults so default-only fields are sent on Open App (parity - // with the form's resolveValue display, which onChange doesn't capture). - onUiChange({ - ...ui, - selectedAppName: name, - formValues: collectSchemaDefaults(toFormSchema(next.inputSchema) ?? {}), - }); - setMaximized(false); - resetAppChannels(); - onSelectApp(name); - // No-input apps auto-launch on selection so the user lands directly in - // the running view; apps with fields wait for the explicit Open App click. - if (!hasInputFields(next)) { - setRunning(true); - onOpenApp(name, {}); - } else { - setRunning(false); - } - } - - function handleOpen() { - if (!selectedTool) return; - // `keepPartials` preserves the staged fragments: AppRenderer snapshots them - // into its own pendingPartialsRef at bridge-build time and replays them. - // The `partialStages` state is intentionally NOT cleared here — the staging - // UI only renders while not running, so the surviving state is invisible - // until the next select/close/back reset drains it. - resetAppChannels({ keepPartials: true }); - setRunning(true); - onOpenApp(selectedTool.name, formValues); - } - - function handleClose() { - setRunning(false); - onUiChange({ ...ui, selectedAppName: undefined, formValues: {} }); - setMaximized(false); - resetAppChannels(); - onCloseApp(); - } - - // Deep-link auto-open (#1577): the parent seeds `ui.selectedAppName` + - // `ui.formValues` from the URL and sets `autoOpen`; fire "Open App" here so - // the driver lands on a rendered widget with zero clicks. Ref-guarded to fire - // exactly once — a later manual close leaves `running` false without - // re-triggering. The open (running + channel resets + `onOpenApp`) is - // deferred one microtask past the synchronous effect body: it calls setState, - // which the set-state-in-effect lint rightly flags in general, but this is a - // ref-guarded run-once effect so the cascading-render concern doesn't apply - // (same pattern as App.tsx's deep-link connect effect). `resetAppChannels` - // changes identity each render, so the effect re-runs, but the ref guard - // makes every run after the first a no-op. - const autoOpenFiredRef = useRef(false); - useEffect(() => { - if (!autoOpen || autoOpenFiredRef.current) return; - if (!selectedTool || running) return; - autoOpenFiredRef.current = true; - void Promise.resolve().then(() => { - resetAppChannels({ keepPartials: true }); - setRunning(true); - onOpenApp(selectedTool.name, formValues); - }); - }, [ - autoOpen, - selectedTool, - running, - formValues, - onOpenApp, - resetAppChannels, - ]); - - function handleBackToInput() { - setRunning(false); - setMaximized(false); - resetAppChannels(); - } - - // No sandbox proxy URL means the host can't embed the trusted outer iframe - // the double-iframe sandbox depends on — surface that plainly instead of - // mounting an iframe that would render blank. - if (!sandboxPath) { - return ( - - - - MCP Apps are unavailable — the sandbox could not be reached. - - - - ); - } - - // While maximized the app fills the screen, so the view-reported height is - // ignored; otherwise we honor it (clamped to the card by the frame's `mah`). - // `appHeight` is intentionally NOT cleared when toggling maximize: carrying - // the last inline height across a maximize→restore means the frame restores - // at its prior size immediately, rather than flashing to full-card height - // (flex:1) for the frame or two until the view sends a fresh size-changed - // after the `inline` host-context-changed. - const contentHeight = maximized ? undefined : appHeight; - - return ( - - {!maximized && ( - - - onUiChange({ ...ui, search: value })} - onSelectApp={handleSelect} - /> - - - )} - - - {selectedTool ? ( - - - - {selectedTool.icons?.[0]?.src && ( - - )} - - {resolveDisplayLabel(selectedTool.name, selectedTool.title)} - - - - {running && selectedHasFields && ( - - Back to Input - - )} - {running && ( - - setMaximized((m) => !m)} - aria-label={maximized ? "Restore" : "Maximize"} - > - {maximized ? ( - - ) : ( - - )} - - - )} - - - - - - - - {running ? ( - // RendererContainer is the host-controlled box (its size only - // changes with host layout); the inner RendererFrame is sized by - // the view's reported content height, capped at the container. - - - {/* Keying by name forces the renderer to remount when the - selected app changes, ensuring a fresh bridge and iframe - rather than reusing the previous app's transport. */} - - - {/* Shown BELOW the frame on a factory throw/reject so the reason - is visible alongside the (blank) iframe rather than leaving a - silent blank frame. The renderer stays mounted so an in-place - retry path remains possible. */} - {appError && ( - - App failed to load - {appError.message} - - )} - - ) : ( - // `isOpening` is always false here because `handleOpen` - // synchronously flips `running` to true, swapping in the - // AppRenderer before the panel could render its loading - // state. The prop stays in `AppDetailPanel`'s API for - // standalone use (the `Opening` story) and for Phase 3 - // wiring, where a managed-state hook can hold the panel - // in a pending state across an awaited `tools/call`. - <> - {selectedHasFields && ( - - - Stage partial input - - {partialStages.length > 0 && ( - <> - - {partialStages.length} staged - - setPartialStages([])} - > - Clear staged - - - )} - - )} - - onUiChange({ ...ui, formValues: values }) - } - onOpenApp={handleOpen} - /> - - )} - {running && messages.length > 0 && ( - - Messages from app ({messages.length}) - - - {messages.map((message, index) => ( - - - - [{index}] role: {message.role} - - {message.content.map((block, blockIndex) => ( - - ))} - - - ))} - - - - )} - {running && appLogs.length > 0 && ( - - - setAppLogsExpanded((e) => !e)} - aria-expanded={appLogsExpanded} - aria-controls="apps-logs-region" - > - App logs ({appLogs.length}) - - setAppLogs([])}> - Clear - - - - - - {appLogs.map((entry) => ( - - - {entry.logger && ( - {entry.logger} - )} - {entry.text} - - ))} - - - - - )} - - ) : ( - Select an app to view details - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx deleted file mode 100644 index a02c9b1b2..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Card, Flex, Stack } from "@mantine/core"; -import type { LoggingLevel, ProtocolEra } from "@modelcontextprotocol/client"; -import { LogControls } from "../../groups/LogControls/LogControls"; -import { LogStreamPanel } from "../../groups/LogStreamPanel/LogStreamPanel"; -import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import type { SortDirection } from "../../elements/SortToggle/SortToggle"; -import { ALL_LEVELS_VISIBLE, NO_LEVELS_VISIBLE } from "./logLevels"; - -export interface LoggingScreenProps { - entries: LogEntryData[]; - currentLevel: LoggingLevel; - ui: LogsUiState; - onUiChange: (next: LogsUiState) => void; - onSetLevel: (level: LoggingLevel) => void; - /** - * Negotiated protocol era (#1629). On the modern era the level selector is - * replaced by the per-request opt-in control; legacy keeps `logging/setLevel`. - */ - protocolEra?: ProtocolEra; - /** Modern per-request log level currently stamped, or `null` when opted out. */ - modernLogLevel?: LoggingLevel | null; - /** Set (or clear, with `null`) the modern per-request log level. */ - onSetModernLogLevel?: (level: LoggingLevel | null) => void; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - /** - * True when rendered inside the monitoring sidebar: the screen fills its - * parent's height (instead of the viewport calc) and drops the filter - * sidebar so the narrow column is stream-only. - */ - embedded?: boolean; -} - -// Filter text + visible-level set — controlled by the parent (App) as one -// object so they persist across tab navigation within a live session (#1417). -export interface LogsUiState { - filterText: string; - visibleLevels: Record; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -export function LoggingScreen({ - entries, - currentLevel, - ui, - onUiChange, - onSetLevel, - protocolEra, - modernLogLevel = null, - onSetModernLogLevel, - onClear, - onExport, - sortDirection, - onSortChange, - embedded = false, -}: LoggingScreenProps) { - const { filterText, visibleLevels } = ui; - - function handleToggleLevel(level: LoggingLevel, visible: boolean) { - onUiChange({ - ...ui, - visibleLevels: { ...visibleLevels, [level]: visible }, - }); - } - - function handleToggleAllLevels() { - const allSelected = Object.values(visibleLevels).every(Boolean); - onUiChange({ - ...ui, - visibleLevels: allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE, - }); - } - - return ( - // Embedded fills the monitoring sidebar column (100%); standalone keeps the - // ScreenLayout's default full-screen height. Passing `h={undefined}` here - // would clobber that default (withProps plain-spreads), collapsing an empty - // screen to its controls' height — so only override `h` when embedded. - // Embedded also halves the top padding (`pt: md` vs the `xl` default) so the - // panel sits closer to the sidebar's tab/search controls above it. - - {embedded ? null : ( - - - - onUiChange({ ...ui, filterText: value }) - } - onToggleLevel={handleToggleLevel} - onToggleAllLevels={handleToggleAllLevels} - /> - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts deleted file mode 100644 index 3dfc758dc..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { LoggingLevel } from "@modelcontextprotocol/client"; - -// Default visible-level filter: every level on. Shared by LoggingScreen (its -// fallback when the parent hasn't set `visibleLevels`) and App, which seeds and -// resets the lifted filter state from it (#1417). Lives in its own module so -// the screen file only exports a component (react-refresh constraint). -export const ALL_LEVELS_VISIBLE: Record = { - debug: true, - info: true, - notice: true, - warning: true, - error: true, - critical: true, - alert: true, - emergency: true, -}; - -export const NO_LEVELS_VISIBLE: Record = { - debug: false, - info: false, - notice: false, - warning: false, - error: false, - critical: false, - alert: false, - emergency: false, -}; diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx deleted file mode 100644 index 43c2affdf..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Card, Flex, Stack } from "@mantine/core"; -import type { - FetchRequestCategory, - FetchRequestEntry, -} from "@inspector/core/mcp/types.js"; -import { NetworkControls } from "../../groups/NetworkControls/NetworkControls"; -import { NetworkStreamPanel } from "../../groups/NetworkStreamPanel/NetworkStreamPanel"; -import type { SortDirection } from "../../elements/SortToggle/SortToggle"; -import { - ALL_CATEGORIES_VISIBLE, - NO_CATEGORIES_VISIBLE, -} from "./fetchCategories"; - -export interface NetworkScreenProps { - entries: FetchRequestEntry[]; - ui: NetworkUiState; - onUiChange: (next: NetworkUiState) => void; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ - embedded?: boolean; - /** "Reveal in Network" target (a fetch-entry id) + its one-shot clear. */ - revealId?: string; - onRevealComplete?: () => void; -} - -// Filter text + visible-category set — controlled by the parent (App) as one -// object so they persist across tab navigation within a live session (#1417). -export interface NetworkUiState { - filterText: string; - visibleCategories: Record; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -export function NetworkScreen({ - entries, - ui, - onUiChange, - onClear, - onExport, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - revealId, - onRevealComplete, -}: NetworkScreenProps) { - const { filterText, visibleCategories } = ui; - - function handleToggleCategory( - category: FetchRequestCategory, - visible: boolean, - ) { - onUiChange({ - ...ui, - visibleCategories: { ...visibleCategories, [category]: visible }, - }); - } - - function handleToggleAllCategories() { - const allSelected = Object.values(visibleCategories).every(Boolean); - onUiChange({ - ...ui, - visibleCategories: allSelected - ? NO_CATEGORIES_VISIBLE - : ALL_CATEGORIES_VISIBLE, - }); - } - - return ( - // See LoggingScreen: only override `h` when embedded, so the standalone - // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` - // would clobber it and collapse an empty screen to its controls' height). - - {embedded ? null : ( - - - - onUiChange({ ...ui, filterText: value }) - } - onToggleCategory={handleToggleCategory} - onToggleAllCategories={handleToggleAllCategories} - /> - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts deleted file mode 100644 index 86a80c7f8..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; - -// Default visible-category filter: every category on. Shared by NetworkScreen -// (its fallback when the parent hasn't set `visibleCategories`) and App, which -// seeds and resets the lifted filter state from it (#1417). Lives in its own -// module so the screen file only exports a component (react-refresh constraint). -export const ALL_CATEGORIES_VISIBLE: Record = { - auth: true, - transport: true, -}; - -export const NO_CATEGORIES_VISIBLE: Record = { - auth: false, - transport: false, -}; diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx deleted file mode 100644 index c599ed477..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { - Alert, - Card, - CloseButton, - Flex, - Group, - Loader, - Stack, - Text, -} from "@mantine/core"; -import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client"; -import { PromptControls } from "../../groups/PromptControls/PromptControls"; -import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; -import { PromptArgumentsForm } from "../../groups/PromptArgumentsForm/PromptArgumentsForm"; -import { PromptMessagesDisplay } from "../../groups/PromptMessagesDisplay/PromptMessagesDisplay"; - -export interface GetPromptState { - status: "idle" | "pending" | "ok" | "error"; - result?: GetPromptResult; - error?: string; - /** - * Name of the prompt the in-flight / latest result is for. Used to - * route the result panel only to the matching sidebar selection. - */ - promptName?: string; -} - -export interface PromptsScreenProps { - prompts: Prompt[]; - getPromptState?: GetPromptState; - ui: PromptsUiState; - listChanged: boolean; - completionsSupported?: boolean; - onUiChange: (next: PromptsUiState) => void; - onRefreshList: () => void; - /** A failed list load, rendered above the sidebar list (#1953). */ - loadError?: Error | null; - /** Pagination controls rendered in the sidebar (#1721). */ - pagination: ListPaginationControlsProps; - onGetPrompt: (name: string, args: Record) => void; - onCopyMessages?: () => void; - onCompleteArgument?: ( - ref: - | { type: "ref/resource"; uri: string } - | { type: "ref/prompt"; name: string }, - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; -} - -// Selection, argument values, the "submitted" marker, and the sidebar search — -// controlled by the parent (App) as one object so they persist across tab -// navigation within a live session (#1417). -export interface PromptsUiState { - selectedPromptName?: string; - argumentValues: Record; - submittedFor?: string; - search: string; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - // Widened from 340 to comfortably fit the pagination controls - // (Load-next-page button + status) without cramping list entries (#1721). - w: 360, - flex: "0 0 auto", -}); - -// `sidebar` variant makes the card a full-height flex column capped at the -// screen height, so PromptControls' list fills the card and scrolls internally -// once it overflows (matching the Resources sidebar). -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "sidebar", -}); - -const DetailCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -// Sized-to-content card with overflow handling. When the inner content -// fits, the card hugs it. When it doesn't, the inner ScrollArea inside -// PromptMessagesDisplay shrinks (flex 0 1 auto, mih 0) and scrolls. -const PreviewCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", -}); - -// Column wrapper that pins the card to the top of the available space -// and bounds its growth via the consumer-set `mah`. -const PreviewPane = Flex.withProps({ - flex: 1, - miw: 0, - direction: "column", - align: "stretch", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -// Centered loader/status column shown while a prompt is being fetched. -const CenteredStatus = Stack.withProps({ - align: "center", - py: "xl", -}); - -const PromptErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Prompt Error", -}); - -const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; - -function hasArguments(prompt: Prompt): boolean { - return !!prompt.arguments && prompt.arguments.length > 0; -} - -export function PromptsScreen({ - prompts, - getPromptState, - ui, - listChanged, - completionsSupported, - onUiChange, - onRefreshList, - loadError, - pagination, - onGetPrompt, - onCopyMessages, - onCompleteArgument, -}: PromptsScreenProps) { - const { selectedPromptName, argumentValues, submittedFor, search } = ui; - const selectedPrompt = selectedPromptName - ? prompts.find((p) => p.name === selectedPromptName) - : undefined; - - function handleSelectPrompt(name: string) { - // Re-clicking the active prompt in the sidebar shouldn't wipe the - // user's typed argument values or trigger a re-fetch — sidebar is - // for navigation, ✕ is for dismiss. Closing-then-reselecting is - // its own thing (the close handler clears submittedFor). PromptControls - // already swallows a re-click on the active item (it only fires - // onSelectPrompt when the name differs), so this is a redundant guard - // that never fires through the UI. - /* v8 ignore next -- unreachable: PromptControls never re-emits the active name */ - if (name === selectedPromptName) return; - // Auto-fetch no-argument prompts the moment they're selected — the - // form pane would otherwise just render a bare Get Prompt button - // with nothing to fill in. Prompts with arguments wait for submit. - const target = prompts.find((p) => p.name === name); - const autoFetch = !!target && !hasArguments(target); - onUiChange({ - ...ui, - argumentValues: {}, - selectedPromptName: name, - submittedFor: autoFetch ? name : undefined, - }); - if (autoFetch) onGetPrompt(name, {}); - } - - function handleSubmit() { - // Defensive guard: handleSubmit is only wired to the argument form's - // onGetPrompt, which renders solely when `selectedPrompt` is truthy, so - // this never fires through the UI. - /* v8 ignore next -- unreachable: form only renders with a selected prompt */ - if (!selectedPrompt) return; - onUiChange({ ...ui, submittedFor: selectedPrompt.name }); - onGetPrompt(selectedPrompt.name, argumentValues); - } - - function handleClosePreview() { - // For prompts with arguments, flip back to the form so the user can - // edit and re-submit (argumentValues are preserved). For no-arg - // prompts there's no form to return to, so drop the selection and - // fall back to the empty state. - if (selectedPrompt && hasArguments(selectedPrompt)) { - onUiChange({ ...ui, submittedFor: undefined }); - } else { - onUiChange({ - ...ui, - selectedPromptName: undefined, - submittedFor: undefined, - }); - } - } - - // The preview is "active" when we've submitted (or auto-fetched) the - // currently-selected prompt and the parent's state is tagged with - // the matching prompt name. The name match guards against a stale - // result from a previously-selected prompt leaking into the new - // prompt's pane. App.tsx tags every state transition with - // `promptName`, so we don't need a fallback for untagged states. - const previewActive = - !!selectedPrompt && - !!getPromptState && - submittedFor === selectedPrompt.name && - getPromptState.promptName === selectedPrompt.name; - - function renderPreview() { - // Defensive guard: renderPreview is only invoked from the `previewActive` - // branch below, and `previewActive` already requires a truthy - // `getPromptState`, so neither arm of this condition is reachable here. - /* v8 ignore next -- unreachable: only called when previewActive && getPromptState */ - if (!previewActive || !getPromptState) return null; - if (getPromptState.status === "pending") { - return ( - - - - - - - - Loading prompt... - - - - ); - } - if (getPromptState.status === "error") { - return ( - - - - - - - {getPromptState.error ?? "Failed to get prompt"} - - - - ); - } - if (getPromptState.result) { - return ( - - - - ); - } - return null; - } - - return ( - - - - onUiChange({ ...ui, search: value })} - onSelectPrompt={handleSelectPrompt} - /> - - - - {previewActive ? ( - // Result branch — sized to content, capped at viewport. Mirrors - // the resource preview layout (see ResourcesScreen). - {renderPreview()} - ) : selectedPrompt && hasArguments(selectedPrompt) ? ( - // Argument-form branch — fills the content pane's width (like the Tools - // input form), replaced by the result once the user clicks Get Prompt - // and previewActive flips on. - - - - onUiChange({ - ...ui, - argumentValues: { ...argumentValues, [argName]: value }, - }) - } - onGetPrompt={handleSubmit} - completionsSupported={completionsSupported} - onCompleteArgument={ - onCompleteArgument - ? (argName, value, context) => - onCompleteArgument( - { type: "ref/prompt", name: selectedPrompt.name }, - argName, - value, - context, - ) - : undefined - } - /> - - - ) : ( - - Select a prompt to view details - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx deleted file mode 100644 index cf6698d20..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Card, Flex, Stack } from "@mantine/core"; -import type { ProtocolEra } from "@modelcontextprotocol/client"; -import type { - MessageEntry, - MessageMethod, - MessageOrigin, -} from "@inspector/core/mcp/types.js"; -import { ProtocolControls } from "../../groups/ProtocolControls/ProtocolControls"; -import { ProtocolListPanel } from "../../groups/ProtocolListPanel/ProtocolListPanel.js"; -import { extractMethod } from "../../groups/protocolUtils.js"; -import type { SortDirection } from "../../elements/SortToggle/SortToggle"; - -export interface ProtocolScreenProps { - entries: MessageEntry[]; - pinnedIds: Set; - /** Negotiated protocol era (SEP §7.8), shown as a badge in the list header. */ - protocolEra?: ProtocolEra; - ui: ProtocolUiState; - onUiChange: (next: ProtocolUiState) => void; - onClearAll: () => void; - onExport: () => void; - onClearSection: (section: "pinned" | "history") => void; - onExportSection: (section: "pinned" | "history") => void; - onReplay: (id: string) => void; - onTogglePin: (id: string) => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ - embedded?: boolean; - /** Jump from a spec-error entry to its correlated Network HTTP entry. */ - onRevealInNetwork?: (id: string) => void; - /** Message-entry ids that have a correlated Network entry (link is shown). */ - revealableIds?: Set; - /** - * Message-entry id → correlated Network fetch HTTP status. Gates the generic - * `-32601` to a genuine modern 404 (see {@link ProtocolListPanel}). - */ - correlatedStatusById?: Map; -} - -// Search text, method filter, and per-direction visibility — controlled by the -// parent (App) as one object so they persist across tab navigation within a -// live session (#1417). -export interface ProtocolUiState { - search: string; - methodFilter?: MessageMethod; - /** Which message directions are shown, keyed by entry origin. */ - visibleDirections: Record; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -export function ProtocolScreen({ - entries, - pinnedIds, - protocolEra, - ui, - onUiChange, - onClearAll, - onExport, - onClearSection, - onExportSection, - onReplay, - onTogglePin, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - onRevealInNetwork, - revealableIds, - correlatedStatusById, -}: ProtocolScreenProps) { - const { search, methodFilter, visibleDirections } = ui; - - const availableMethods = useMemo( - () => Array.from(new Set(entries.map(extractMethod))).sort(), - [entries], - ); - - const handleClearAll = useCallback(() => { - onUiChange({ ...ui, methodFilter: undefined }); - onClearAll(); - }, [ui, onUiChange, onClearAll]); - - const handleToggleDirection = useCallback( - (direction: MessageOrigin, visible: boolean) => { - onUiChange({ - ...ui, - visibleDirections: { ...visibleDirections, [direction]: visible }, - }); - }, - [ui, visibleDirections, onUiChange], - ); - - const handleToggleAllDirections = useCallback(() => { - const next = !Object.values(visibleDirections).every(Boolean); - onUiChange({ - ...ui, - visibleDirections: { client: next, server: next }, - }); - }, [ui, visibleDirections, onUiChange]); - - return ( - // See LoggingScreen: only override `h` when embedded, so the standalone - // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` - // would clobber it and collapse an empty screen to its controls' height). - - {embedded ? null : ( - - - onUiChange({ ...ui, search: value })} - onMethodFilterChange={(value) => - onUiChange({ ...ui, methodFilter: value }) - } - onToggleDirection={handleToggleDirection} - onToggleAllDirections={handleToggleAllDirections} - /> - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx deleted file mode 100644 index a94f91699..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { - Alert, - Card, - CloseButton, - Flex, - Group, - Loader, - Stack, - Text, -} from "@mantine/core"; -import type { - ProtocolEra, - ReadResourceResult, - Resource, - ResourceTemplateType as ResourceTemplate, -} from "@modelcontextprotocol/client"; -import type { - InspectorResourceSubscription, - ResourceSubscriptionStreamState, -} from "../../../../../../core/mcp/types.js"; -import { ResourceControls } from "../../groups/ResourceControls/ResourceControls"; -import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; -import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel"; -import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel"; - -export interface ReadResourceState { - status: "idle" | "pending" | "ok" | "error"; - uri?: string; - result?: ReadResourceResult; - error?: string; - lastUpdated?: Date; - isSubscribed?: boolean; -} - -export interface ResourcesScreenProps { - resources: Resource[]; - templates: ResourceTemplate[]; - subscriptions: InspectorResourceSubscription[]; - /** - * Modern-era `subscriptions/listen` stream state (#1630). On the modern era - * the Subscriptions section shows a stream-status badge and a header dot; - * `active: false` (the legacy default) renders neither. - */ - subscriptionStreamState?: ResourceSubscriptionStreamState; - /** Negotiated protocol era; gates the modern subscription stream chrome. */ - protocolEra?: ProtocolEra; - readState?: ReadResourceState; - ui: ResourcesUiState; - listChanged: boolean; - completionsSupported?: boolean; - /** - * Whether the connected server advertises the `resources.subscribe` - * capability. When false, the Subscribe/Unsubscribe button and the - * Subscriptions accordion section are hidden. Defaults to true so the - * controls render unless a caller explicitly marks them unsupported. - */ - subscriptionsSupported?: boolean; - onUiChange: (next: ResourcesUiState) => void; - onRefreshList: () => void; - /** A failed list load, rendered above the sidebar list (#1953). */ - loadError?: Error | null; - /** Pagination controls rendered in the sidebar (#1721). */ - pagination: ListPaginationControlsProps; - onReadResource: (uri: string) => void; - onSubscribeResource: (uri: string) => void; - onUnsubscribeResource: (uri: string) => void; - onCompleteArgument?: ( - ref: - | { type: "ref/resource"; uri: string } - | { type: "ref/prompt"; name: string }, - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; - compact: boolean; - onCompactChange: (next: boolean) => void; -} - -// Selection (resource URI, template URI, the originating-template marker), the -// sidebar search, and accordion open-sections — controlled by the parent (App) -// as one object so they persist across tab navigation within a live session -// (#1417). `openSections` undefined → ResourceControls falls back to the -// compact-derived default. -export interface ResourcesUiState { - selectedResourceUri?: string; - selectedTemplateUri?: string; - originatingTemplateUri?: string; - search: string; - openSections?: string[]; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - // Widened from 340 to comfortably fit the pagination controls - // (Load-next-page button + status) without cramping list entries (#1721). - w: 360, - flex: "0 0 auto", -}); - -// Card that grows with its content but is capped at the screen height by the -// `sidebar` variant (`max-height: 100%`), like the Tools panel. The column -// layout lets ResourceControls' accordion take over per-section scrolling once -// the content would overflow that cap. -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "sidebar", -}); - -const DetailCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -// Card that sizes to its content but caps at the screen's available -// height. When content fits, the card stays compact (footer sits right -// under the body); when content would overflow, the inner ScrollArea -// inside ResourcePreviewPanel shrinks and scrolls. -const PreviewCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", -}); - -// Column that pins the preview card to the top of the available space -// and bounds its growth via the consumer-set `mah`. The card inside -// keeps its natural height up to that cap. -const PreviewPane = Flex.withProps({ - flex: 1, - miw: 0, - direction: "column", - align: "stretch", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -// Centered loader/status column shown while a resource is being read. -const CenteredStatus = Stack.withProps({ - align: "center", - py: "xl", -}); - -const ReadErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Read Error", -}); - -const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; - -export function ResourcesScreen({ - resources, - templates, - subscriptions, - subscriptionStreamState, - protocolEra, - readState, - ui, - listChanged, - completionsSupported, - subscriptionsSupported = true, - onUiChange, - onRefreshList, - loadError, - pagination, - onReadResource, - onSubscribeResource, - onUnsubscribeResource, - onCompleteArgument, - compact, - onCompactChange, -}: ResourcesScreenProps) { - const { - selectedResourceUri, - selectedTemplateUri, - originatingTemplateUri, - search, - openSections, - } = ui; - const selectedResource = selectedResourceUri - ? resources.find((r) => r.uri === selectedResourceUri) - : undefined; - const selectedTemplate = selectedTemplateUri - ? templates.find((t) => t.uriTemplate === selectedTemplateUri) - : undefined; - - // For template-expanded URIs that don't appear in the resources list, - // construct a synthetic Resource so the preview panel can render. - const readResource: Resource | undefined = - selectedResource ?? - (readState?.uri && readState.uri === selectedResourceUri - ? { name: readState.uri, uri: readState.uri } - : undefined); - - function handleSelectResource(uri: string) { - onUiChange({ - ...ui, - selectedTemplateUri: undefined, - selectedResourceUri: uri, - originatingTemplateUri: undefined, - }); - onReadResource(uri); - } - - function handleSelectTemplate(uriTemplate: string) { - onUiChange({ - ...ui, - selectedResourceUri: undefined, - selectedTemplateUri: uriTemplate, - originatingTemplateUri: undefined, - }); - } - - function handleReadResource(uri: string) { - // Once the user reads (either from the template form or a refresh - // inside the preview panel), hand the screen over to the preview: - // clearing the template selection hides the template form so only - // the rendered resource is shown. We remember the template URI so - // closing the preview can restore the form. - onUiChange({ - ...ui, - originatingTemplateUri: selectedTemplateUri ?? originatingTemplateUri, - selectedTemplateUri: undefined, - selectedResourceUri: uri, - }); - onReadResource(uri); - } - - function handleClosePreview() { - if (originatingTemplateUri) { - onUiChange({ - ...ui, - selectedResourceUri: undefined, - selectedTemplateUri: originatingTemplateUri, - originatingTemplateUri: undefined, - }); - } else { - onUiChange({ ...ui, selectedResourceUri: undefined }); - } - } - - function renderReadState() { - if (!readState) return null; - - if (readState.status === "pending") { - return ( - - - - - - - - Reading resource... - - - - ); - } - - if (readState.status === "error") { - return ( - - - - - - - {readState.error ?? "Failed to read resource"} - - - - ); - } - - if (readState.result && readResource) { - return ( - - handleReadResource(readResource.uri)} - onSubscribe={() => onSubscribeResource(readResource.uri)} - onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} - onClose={handleClosePreview} - /> - - ); - } - - return null; - } - - return ( - - - - onUiChange({ ...ui, search: value })} - onOpenSectionsChange={(value) => - onUiChange({ ...ui, openSections: value }) - } - onSelectUri={handleSelectResource} - onSelectTemplate={handleSelectTemplate} - onUnsubscribeResource={onUnsubscribeResource} - compact={compact} - onCompactChange={onCompactChange} - /> - - - - {selectedTemplate ? ( - // Template form only — once the user clicks Read Resource, - // handleReadResource clears the template selection so the - // resource branch takes over and the preview is shown alone. - // Fills the main area width (like the preview pane) rather than - // being capped, so the URI preview and variable inputs get the - // full width on wide displays. - - - - onCompleteArgument( - { - type: "ref/resource", - uri: selectedTemplate.uriTemplate, - }, - argName, - value, - context, - ) - : undefined - } - /> - - - ) : readResource ? ( - // Sized-to-content preview pane, capped at the screen's available - // height. When the resource body fits, the card hugs its content - // and the subscribe/refresh row sits right under it. When the body - // would overflow, the inner ScrollArea inside ResourcePreviewPanel - // shrinks and scrolls, keeping the footer pinned at the cap. - // miw=0 prevents wide content (long unbroken lines, tables) from - // pushing the pane past the viewport's right edge. - {renderReadState()} - ) : ( - - Select a resource to preview - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx deleted file mode 100644 index f6dd4ed31..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { Card, Flex, Stack, Text } from "@mantine/core"; -import type { - CallToolResult, - ReadResourceResult, - Tool, -} from "@modelcontextprotocol/client"; -import type { ExcludedTool } from "@inspector/core/mcp/types.js"; -import { ToolControls } from "../../groups/ToolControls/ToolControls"; -import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; -import { - ToolDetailPanel, - type ToolProgress, -} from "../../groups/ToolDetailPanel/ToolDetailPanel"; -import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; -import { ToolCallErrorPanel } from "../../groups/ToolResultPanel/ToolCallErrorPanel"; -import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils"; -import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; - -export interface ToolCallState { - status: "idle" | "pending" | "ok" | "error"; - result?: CallToolResult; - error?: string; - /** - * JSON-RPC error code when the call REJECTED (a thrown `ProtocolError`) rather - * than resolving a result. SDK v2 rejects an unknown-tool call with `-32602` - * instead of returning an `isError` result, so this drives the distinct - * "Unknown Tool" rendering in the error panel (#1632). - */ - errorCode?: number; - progress?: ToolProgress; -} - -// Selection, form values, and sidebar search — controlled by the parent (App) -// as one object so they persist across tab navigation within a live session; -// the screen unmounts on tab switch, so local state would be lost (#1414/#1417). -export interface ToolsUiState { - selectedToolName?: string; - formValues: Record; - search: string; - // Screen-level "Run as task" toggle, shared across tools (not per-tool): - // selecting a different tool keeps the current value. Persists across tab - // navigation like the rest of the UI state, and is only honored for the - // selected tool when its `execution.taskSupport` is "optional" (a "required" - // tool is always run as a task, "forbidden" never) — see ToolDetailPanel. - runAsTask: boolean; -} - -export interface ToolsScreenProps { - tools: Tool[]; - /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` - * annotations (SEP-2243), shown in the sidebar with the reason (#1632). */ - excludedTools?: ExcludedTool[]; - callState?: ToolCallState; - ui: ToolsUiState; - listChanged: boolean; - /** Whether the connected server advertises task-augmented tool calls. */ - serverSupportsTaskToolCalls: boolean; - /** Modern (SEP-2663) tasks extension negotiated — "Run as task" is offered - * for any tool (server-directed task creation). */ - modernTasks?: boolean; - onUiChange: (next: ToolsUiState) => void; - onRefreshList: () => void; - /** A failed list load, rendered above the sidebar list (#1953). */ - loadError?: Error | null; - /** Pagination controls rendered in the sidebar (#1721). */ - pagination: ListPaginationControlsProps; - onCallTool: ( - name: string, - args: Record, - runAsTask?: boolean, - ) => void; - onCancelCall?: () => void; - onClearResult?: () => void; - /** - * Read-on-demand handler for `resource_link` blocks in a tool result. - * Passed through to the result panel so links can inline their contents. - */ - onReadResource?: (uri: string) => Promise; -} - -// Caps the detail/result columns at the screen's available height: full -// viewport minus the app-shell header and the screen's top+bottom xl padding, -// leaving the bottom margin the overflow used to eat. -const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; - -// No `align` override: children stretch to the row's full height, giving each -// column pane a definite height. That definite height is what lets a column's -// inner ScrollArea know how much it can shrink into (a bare `mah` doesn't — -// see the Prompts/Resources preview panes this mirrors). -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - // Widened from 340 to comfortably fit the pagination controls - // (Load-next-page button + status) without cramping list entries (#1721). - w: 360, - flex: "0 0 auto", -}); - -// `sidebar` variant makes the card a full-height flex column capped at the -// screen height, so ToolControls' list fills the card and scrolls internally -// once it overflows (matching the Resources sidebar). (#1417) -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "sidebar", -}); - -// Column wrapper: stretches to the screen's available height (capped by the -// consumer-set `mah`) so the card inside has a definite height to shrink into. -const ContentPane = Flex.withProps({ - flex: 1, - miw: 0, - direction: "column", - align: "stretch", -}); - -// Detail/result column card: `variant="preview"` (overflow: hidden) lets the -// panel's inner ScrollArea take over scrolling instead of the card bleeding -// past the viewport. Sizes to content when short, caps at the pane when tall. -const ContentCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", -}); - -// Full-height card for the empty placeholder (used with `flex={1}`) so it fills -// the screen height like the Prompts/Resources placeholders, rather than -// shrinking to its text. The result/detail states keep the content-sized -// `ContentCard` (their inner ScrollArea handles overflow). -const DetailCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -export function ToolsScreen({ - tools, - excludedTools, - callState, - ui, - listChanged, - serverSupportsTaskToolCalls, - modernTasks = false, - onUiChange, - onRefreshList, - loadError, - pagination, - onCallTool, - onCancelCall, - onClearResult, - onReadResource, -}: ToolsScreenProps) { - const { selectedToolName, formValues, search } = ui; - const selectedTool = selectedToolName - ? tools.find((t) => t.name === selectedToolName) - : undefined; - const isExecuting = callState?.status === "pending"; - - const handleSelectTool = (name: string) => { - // Seed the form with the tool's schema defaults so default-only fields the - // user never edits are still sent on execute (the form shows defaults via - // resolveValue, but onChange only writes edited fields). - const tool = tools.find((t) => t.name === name); - // `name` always comes from the rendered tools list (ToolControls only emits - // names it was given), so the lookup never misses; the empty-object fallback - // is an unreachable defensive default. - let nextFormValues: Record = {}; - /* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */ - if (tool) - nextFormValues = collectSchemaDefaults( - toFormSchema(tool.inputSchema) ?? {}, - ); - onUiChange({ ...ui, selectedToolName: name, formValues: nextFormValues }); - }; - - return ( - - - - onUiChange({ ...ui, search: value })} - onSelectTool={handleSelectTool} - /> - - - - {callState?.result ? ( - // Results replace the input form while present, and the panel's top-left - // X dismisses them back to the form (#1661) — the Prompts screen pattern. - // `formValues` live in the lifted UI state, so the form is restored - // intact for a re-run. A call in flight sets a `pending` state with no - // `result` (App.tsx), so the executing form (progress + cancel) shows - // until the result lands. - - {/* Fill the pane's full height only when the result renders a - "Resource Links" box, so that box can expand into the available - space and scroll within. Plain text/image/error results keep the - content-sized card (matching the input-form state) instead of - reserving a tall empty card. */} - - onClearResult?.()} - onReadResource={onReadResource} - /> - - - ) : callState?.status === "error" && callState.error ? ( - // A thrown rejection (no result) — e.g. SDK v2's `-32602` unknown-tool - // reject, which no longer arrives as an `isError` CallToolResult. The X - // dismisses back to the form, like a result (#1632). - - - onClearResult?.()} - /> - - - ) : selectedTool ? ( - - - - onUiChange({ ...ui, runAsTask: value }) - } - onFormChange={(values) => - onUiChange({ ...ui, formValues: values }) - } - onExecute={(runAsTask) => - onCallTool(selectedTool.name, formValues, runAsTask) - } - onCancel={() => onCancelCall?.()} - /> - - - ) : ( - // Empty placeholder fills the full screen height (like Prompts/Resources) - // rather than shrinking to its text. - - Select a tool to view details - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts b/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts deleted file mode 100644 index fea5f3480..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useLayoutEffect, useRef } from "react"; - -// Scroll positions survive a screen unmount by living in this module-scope map -// rather than React state: the screens unmount on tab switch (#1417), so a -// component-local ref would be lost, and threading every scroll position up to -// App would balloon the prop surface for what is purely ephemeral DOM state. -// Keyed by a caller-supplied stable region id (e.g. "logs-stream"). -const scrollPositions = new Map(); - -/** - * Forget all remembered scroll positions. App calls this on disconnect so a new - * session's screens start at the top, matching the clear-on-disconnect rule the - * lifted selection/filter state follows (#1417). - */ -export function clearScrollMemory(): void { - scrollPositions.clear(); -} - -/** - * Remember and restore a scroll container's position across unmount/remount. - * Returns a ref to attach to a Mantine `ScrollArea`/`ScrollArea.Autosize` via - * its `viewportRef` prop. On mount the saved offset (if any) is restored before - * paint; on unmount the current offset is captured. The captured viewport node - * is closed over so the offset is still readable during the cleanup phase. - */ -export function useScrollMemory(key: string) { - const viewportRef = useRef(null); - useLayoutEffect(() => { - const viewport = viewportRef.current; - if (!viewport) return; - const saved = scrollPositions.get(key); - if (saved) { - viewport.scrollTo({ left: saved.x, top: saved.y }); - } - return () => { - scrollPositions.set(key, { - x: viewport.scrollLeft, - y: viewport.scrollTop, - }); - }; - }, [key]); - return viewportRef; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts b/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts deleted file mode 100644 index 6346dfee0..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useState } from "react"; - -/** - * Call `onChange(next)` during render whenever `value` differs from the value - * seen on the previous render of this component. Nothing is called on the first - * render — seed the dependent state with `useState` instead. - * - * This is React's documented "adjusting state during render" pattern - * (https://react.dev/reference/react/useState#storing-information-from-previous-renders), - * and it is the supported way to reset or re-sync local state from a prop. - * - * ⚠️ **`onChange` runs during render, so it must be pure** — `setState` calls - * and nothing else. No fetches, no DOM writes, no logging, no ref mutation, no - * parent callbacks. A render can be replayed or thrown away (StrictMode - * double-renders in development; concurrent React can abandon an in-progress - * render at any time), so anything external would run an unpredictable number - * of times. Real external work belongs in a `useEffect`, which is exactly the - * split `NetworkEntry` uses: the reveal's force-open is a state update and - * lives here, while its `requestAnimationFrame` scroll stays an effect. - * - * The obvious-looking alternative — `useEffect(() => setX(prop), [prop])` — is - * worse and is reported by `react-hooks/set-state-in-effect`: the effect only - * runs *after* the component has already painted with the stale value, so the - * user sees one frame of the old state and React has to render twice. Adjusting - * during render lets React discard the in-progress output and re-run the - * component body before anything reaches the DOM. - * - * ⚠️ **`value` must be referentially stable across renders that mean "no - * change".** The comparison is `Object.is`, so an object or array literal built - * fresh in the component body looks different on every render — `onChange` - * would fire every render, and because it is what updates state, that is an - * infinite render loop rather than a merely wasteful one. Pass a **primitive - * key** derived from the data (an id, a name, a URI) wherever one exists, and - * otherwise a value that is already memoized or owned by the parent. This is - * the same stability requirement a `useEffect` dependency array carries; the - * difference is only that here the failure is loud and immediate. - */ -export function useValueChange(value: T, onChange: (next: T) => void): void { - const [previous, setPrevious] = useState(value); - if (!Object.is(previous, value)) { - setPrevious(value); - onChange(value); - } -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts b/packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts deleted file mode 100644 index 5c5d3f709..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Browser-side helpers for triggering file downloads from in-memory content. - * - * Centralized so the temp-anchor incantation (`appendChild` for Firefox, - * deferred `revokeObjectURL` so the scheduled download can read the blob) - * lives in one place — and so the wiring is unit-testable under happy-dom - * without dragging React along. - */ - -/** - * Download an in-memory {@link Blob} as `filename`. Uses a temporary anchor - * element to trigger the browser's save dialog. The append-to-body step is - * for older Firefox versions that wouldn't fire `click()` on a detached - * anchor; modern browsers don't require it but it stays as the safe path. - * - * The object-URL revoke is deferred to a task: `link.click()` only schedules - * the download, and revoking the URL synchronously can abort it before the - * browser reads the blob (Firefox/Safari, intermittently Chrome for larger - * blobs). - */ -export function downloadBlob(filename: string, blob: Blob): void { - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - try { - anchor.click(); - } finally { - document.body.removeChild(anchor); - setTimeout(() => URL.revokeObjectURL(url), 0); - } -} - -/** Download an in-memory JSON string as `filename`. */ -export function downloadJsonFile(filename: string, json: string): void { - downloadBlob(filename, new Blob([json], { type: "application/json" })); -} - -/** - * Derive a safe suggested filename from a resource URI: the last path segment, - * stripped of control/format characters, path separators, and characters - * disallowed in filenames on common platforms. Falls back to `"download"` when - * nothing usable remains. - */ -export function fileNameFromUri(uri: string): string { - /* v8 ignore next -- String.prototype.split always returns a non-empty array, so .pop() is never undefined; the `?? ""` fallback is unreachable. */ - const tail = uri.split(/[\\/]/).pop() ?? ""; - const safe = tail - .replace(/[\p{Cc}\p{Cf}]+/gu, "") - .replace(/[\\/:*?"<>|]+/g, "_") - .trim(); - return safe.length > 0 ? safe.slice(0, 255) : "download"; -} - -/** - * Parse `url` and return it only if its scheme is `http:` or `https:`; - * otherwise null. Shared http(s)-only allowlist for opening or downloading - * server-supplied URLs. - */ -export function isHttpUrl(url: string): URL | null { - try { - const parsed = new URL(url); - return parsed.protocol === "https:" || parsed.protocol === "http:" - ? parsed - : null; - } catch { - return null; - } -} - -/** - * The categories of in-memory data the Inspector can export. Tightening - * `kind` to this union catches typos at call sites and documents the - * stable on-disk filename prefix. - */ -export type ExportKind = - | "protocol" - | "protocol-pinned" - | "protocol-unpinned" - | "logs" - | "network" - | "console"; - -/** - * Build a sortable export filename in the shape - * `inspector---.json`. The timestamp uses - * the standard ISO-8601 form with `:` swapped for `-` so the result is - * safe on Windows (which disallows `:` in filenames). Server id is - * passed through `encodeURIComponent` for the same reason — config ids - * are user-supplied and may contain slashes / spaces / colons. - * - * When `serverId` is falsy (undefined or empty) the segment is omitted; - * the rest of the filename still uniquely identifies the export by kind - * + time. - */ -export function buildExportFilename( - kind: ExportKind, - serverId: string | undefined, - now: Date = new Date(), -): string { - const iso = now.toISOString().replace(/:/g, "-"); - const id = serverId ? encodeURIComponent(serverId) : undefined; - const segments = ["inspector", kind, ...(id ? [id] : []), iso]; - return `${segments.join("-")}.json`; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts deleted file mode 100644 index 65bfa4d2d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "@rstest/core"; -import { - INSPECTOR_SERVERS_TAB, - INSPECTOR_TAB_IDS, - isInspectorTabId, -} from "./inspectorTabs"; - -describe("inspectorTabs", () => { - it("names the Servers tab, which is not a liftable inspector tab", () => { - expect(INSPECTOR_SERVERS_TAB).toBe("Servers"); - expect(INSPECTOR_TAB_IDS).not.toContain(INSPECTOR_SERVERS_TAB); - }); - - it("enumerates the liftable inspector tabs", () => { - expect(INSPECTOR_TAB_IDS).toEqual([ - "Apps", - "Tools", - "Prompts", - "Resources", - "Tasks", - "Logs", - "Protocol", - "Network", - ]); - }); - - it("isInspectorTabId returns true for every enumerated tab", () => { - for (const tab of INSPECTOR_TAB_IDS) { - expect(isInspectorTabId(tab)).toBe(true); - } - }); - - it("isInspectorTabId returns false for non-inspector tab values", () => { - expect(isInspectorTabId(INSPECTOR_SERVERS_TAB)).toBe(false); - expect(isInspectorTabId("")).toBe(false); - expect(isInspectorTabId("Bogus")).toBe(false); - }); -}); diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts deleted file mode 100644 index be69af737..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Inspector main-view tab identifiers. Match labels used in ViewHeader / - * InspectorView (`"Tools"`, `"Resources"`, …). - */ - -export const INSPECTOR_SERVERS_TAB = "Servers"; - -/** Tabs with liftable `*UiState` in App.tsx (Servers has no ui snapshot). */ -export const INSPECTOR_TAB_IDS = [ - "Apps", - "Tools", - "Prompts", - "Resources", - "Tasks", - "Logs", - "Protocol", - "Network", -] as const; - -export type InspectorTabId = (typeof INSPECTOR_TAB_IDS)[number]; - -export function isInspectorTabId(value: string): value is InspectorTabId { - return (INSPECTOR_TAB_IDS as readonly string[]).includes(value); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts deleted file mode 100644 index 98dd905a2..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts +++ /dev/null @@ -1,316 +0,0 @@ -export type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -export type JsonSchemaConst = { - const: JsonValue; - title?: string; - description?: string; -}; - -export type InspectorFormSchema = { - type?: - | "string" - | "number" - | "integer" - | "boolean" - | "array" - | "object" - | "null" - | ( - | "string" - | "number" - | "integer" - | "boolean" - | "array" - | "object" - | "null" - )[]; - title?: string; - description?: string; - required?: string[]; - default?: JsonValue; - properties?: Record; - items?: InspectorFormSchema; - // Array validation constraints - minItems?: number; - maxItems?: number; - minimum?: number; - maximum?: number; - minLength?: number; - maxLength?: number; - nullable?: boolean; - pattern?: string; - format?: string; - enum?: string[]; - // Non-standard legacy support: titles for enum values - enumNames?: string[]; - const?: JsonValue; - oneOf?: (InspectorFormSchema | JsonSchemaConst)[]; - anyOf?: (InspectorFormSchema | JsonSchemaConst)[]; - $ref?: string; -}; - -export type JsonObject = { [key: string]: JsonValue }; - -/** - * Narrow an MCP protocol schema (SDK `JsonSchemaType` — e.g. `Tool["inputSchema"]` - * / `outputSchema`, an elicitation `requestedSchema`) to the {@link - * InspectorFormSchema} subset the {@link SchemaForm} renderer understands. - * - * Under SDK v2 the protocol schema type (from `json-schema-typed`, exported as - * `JsonSchemaType` from `@modelcontextprotocol/client`) is structurally distinct - * from Inspector's form schema — same JSON on the wire, incompatible TS types. - * Rather than cast at every call site, callers pass the SDK schema through here. - * Returns `null` when there is no renderable object shape (missing schema, or a - * non-object schema the form can't build fields from); callers handle `null`. - */ -export function toFormSchema(schema: unknown): InspectorFormSchema | null { - if (schema == null || typeof schema !== "object" || Array.isArray(schema)) { - return null; - } - // Structural narrow: the SDK schema's fields are a superset of what the form - // reads (`type`, `properties`, `required`, `items`, …); the values the form - // never dereferences don't affect rendering. - return schema as InspectorFormSchema; -} - -export type DataType = - | "string" - | "number" - | "bigint" - | "boolean" - | "symbol" - | "undefined" - | "object" - | "function" - | "array" - | "null"; - -/** - * Determines the specific data type of a JSON value - * @param value The JSON value to analyze - * @returns The specific data type including "array" and "null" as distinct types - */ -export function getDataType(value: JsonValue): DataType { - if (Array.isArray(value)) return "array"; - if (value === null) return "null"; - return typeof value; -} - -/** - * Collect a schema's default field values into a values object. A schema form - * displays defaults but only writes a field into its `values` once the user - * edits it, so an untouched default would otherwise be absent from a - * submission. Seeding form state with this keeps default-only fields in the - * submitted result (parity with v1). Recurses into nested object schemas and - * omits fields that have no default. - */ -export function collectSchemaDefaults( - schema: InspectorFormSchema, -): Record { - const properties = schema.properties ?? {}; - const result: Record = {}; - for (const [fieldName, fieldSchema] of Object.entries(properties)) { - if (fieldSchema.default !== undefined) { - result[fieldName] = fieldSchema.default; - } else if (fieldSchema.type === "object" && fieldSchema.properties) { - const nested = collectSchemaDefaults(fieldSchema); - if (Object.keys(nested).length > 0) { - result[fieldName] = nested; - } - } - } - return result; -} - -/** - * Whether any of the schema's required top-level fields is missing a value in - * `values` (absent, null, or empty string). Used to gate a form's submit - * action until required fields are supplied. - */ -export function hasMissingRequiredFields( - schema: InspectorFormSchema, - values: Record, -): boolean { - const required = schema.required ?? []; - return required.some((field) => { - const value = values[field]; - return value === undefined || value === null || value === ""; - }); -} - -/** - * Attempts to parse a string as JSON, only for objects and arrays - * @param str The string to parse - * @returns Object with success boolean and either parsed data or original string - */ -export function tryParseJson(str: string): { - success: boolean; - data: JsonValue; -} { - const trimmed = str?.trim(); - if ( - trimmed && - !(trimmed.startsWith("{") && trimmed.endsWith("}")) && - !(trimmed.startsWith("[") && trimmed.endsWith("]")) - ) { - return { success: false, data: str }; - } - try { - return { success: true, data: JSON.parse(str) }; - } catch { - return { success: false, data: str }; - } -} - -/** - * Updates a value at a specific path in a nested JSON structure - * @param obj The original JSON value - * @param path Array of keys/indices representing the path to the value - * @param value The new value to set - * @returns A new JSON value with the updated path - */ -export function updateValueAtPath( - obj: JsonValue, - path: string[], - value: JsonValue, -): JsonValue { - if (path.length === 0) return value; - - if (obj === null || obj === undefined) { - obj = !isNaN(Number(path[0])) ? [] : {}; - } - - if (Array.isArray(obj)) { - return updateArray(obj, path, value); - } else if (typeof obj === "object" && obj !== null) { - return updateObject(obj as JsonObject, path, value); - } else { - console.error( - `Cannot update path ${path.join(".")} in non-object/array value:`, - obj, - ); - return obj; - } -} - -/** - * Updates an array at a specific path - */ -function updateArray( - array: JsonValue[], - path: string[], - value: JsonValue, -): JsonValue[] { - const [index, ...restPath] = path; - const arrayIndex = Number(index); - - if (isNaN(arrayIndex)) { - console.error(`Invalid array index: ${index}`); - return array; - } - - if (arrayIndex < 0) { - console.error(`Array index out of bounds: ${arrayIndex} < 0`); - return array; - } - - let newArray: JsonValue[] = []; - for (let i = 0; i < array.length; i++) { - newArray[i] = i in array ? array[i] : null; - } - - if (arrayIndex >= newArray.length) { - const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null); - // Copy over the existing elements (now guaranteed to be dense) - for (let i = 0; i < newArray.length; i++) { - extendedArray[i] = newArray[i]; - } - newArray = extendedArray; - } - - if (restPath.length === 0) { - newArray[arrayIndex] = value; - } else { - newArray[arrayIndex] = updateValueAtPath( - newArray[arrayIndex], - restPath, - value, - ); - } - return newArray; -} - -/** - * Updates an object at a specific path - */ -function updateObject( - obj: JsonObject, - path: string[], - value: JsonValue, -): JsonObject { - const [key, ...restPath] = path; - - // Validate object key - if (typeof key !== "string") { - console.error(`Invalid object key: ${key}`); - return obj; - } - - const newObj = { ...obj }; - - if (restPath.length === 0) { - newObj[key] = value; - } else { - // Ensure key exists - if (!(key in newObj)) { - newObj[key] = {}; - } - newObj[key] = updateValueAtPath(newObj[key], restPath, value); - } - return newObj; -} - -/** - * Gets a value at a specific path in a nested JSON structure - * @param obj The JSON value to traverse - * @param path Array of keys/indices representing the path to the value - * @param defaultValue Value to return if path doesn't exist - * @returns The value at the path, or defaultValue if not found - */ -export function getValueAtPath( - obj: JsonValue, - path: string[], - defaultValue: JsonValue = null, -): JsonValue { - if (path.length === 0) return obj; - - const [first, ...rest] = path; - - if (obj === null || obj === undefined) { - return defaultValue; - } - - if (Array.isArray(obj)) { - const index = Number(first); - if (isNaN(index) || index < 0 || index >= obj.length) { - return defaultValue; - } - return getValueAtPath(obj[index], rest, defaultValue); - } - - if (typeof obj === "object" && obj !== null) { - if (!(first in obj)) { - return defaultValue; - } - return getValueAtPath((obj as JsonObject)[first], rest, defaultValue); - } - - return defaultValue; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts deleted file mode 100644 index 3fd4d9a5d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Masks sensitive OAuth values inside a captured HTTP body for display in the - * Network tab. OAuth token-exchange / registration responses carry credentials - * (`access_token`, `refresh_token`, …) and the token *request* (a - * `application/x-www-form-urlencoded` body) carries `code` / `code_verifier` / - * `client_secret`. We show the body so it's inspectable, but mask those values - * by default so they aren't exposed at a glance during a screen-share. The raw - * body is preserved by the caller and shown only when the user reveals it. - * - * Content-type selects the parser: `*json*` → JSON masking, form-urlencoded → - * form masking, any other known type → no masking. When the content-type is - * absent/unknown the body is sniffed (parse as JSON first, else treat as - * form). See `maskSecretsInBody`. - */ - -// Keys masked in JSON bodies — bearer-grade secrets only. `code` is -// deliberately NOT here: a JSON body's `code` is usually something else (e.g. -// a JSON-RPC error `code`), and we don't want to mask those. -// `registration_access_token` is the DCR management credential (RFC 7592), -// same bearer class as `access_token`. -const JSON_SENSITIVE_KEYS = new Set([ - "access_token", - "refresh_token", - "id_token", - "client_secret", - "registration_access_token", -]); - -// Keys masked in form-encoded bodies — the JSON set plus the single-use OAuth -// request material that only appears as form params (authorization code, PKCE -// verifier, private-key-JWT client assertion). -const FORM_SENSITIVE_KEYS = new Set([ - ...JSON_SENSITIVE_KEYS, - "code", - "code_verifier", - "client_assertion", -]); - -// What a masked value is replaced with. A fixed-width dotted string keeps the -// shape recognizable as "a value was here" without hinting at its length. -export const MASK_PLACEHOLDER = "••••••••"; - -function isSensitiveKey(set: ReadonlySet, key: string): boolean { - return set.has(key.toLowerCase()); -} - -// Whether a value under a sensitive key should be masked. The contract is -// "any non-null, non-empty-string value": strings are masked when non-empty -// (an empty `access_token` carries nothing), and any non-string value -// (object/array/number/boolean wrapper — pathological for OAuth, but a safe -// default) is masked wholesale so it can't leak through the recursion. -function isMaskableValue(value: unknown): boolean { - if (value === null || value === undefined) return false; - if (typeof value === "string") return value.length > 0; - return true; -} - -interface MaskedNode { - node: unknown; - masked: boolean; -} - -// Recursively mask sensitive values in a parsed JSON node, tracking whether -// anything was masked (so the caller never has to infer it by comparing -// serializations — reformatting alone can't trip the flag, and it's robust if -// this function ever grows non-identity transforms). -function maskNode(node: unknown): MaskedNode { - if (Array.isArray(node)) { - let masked = false; - const out = node.map((item) => { - const r = maskNode(item); - masked = masked || r.masked; - return r.node; - }); - return { node: out, masked }; - } - if (node !== null && typeof node === "object") { - let masked = false; - const out: Record = {}; - for (const [key, value] of Object.entries( - node as Record, - )) { - if (isSensitiveKey(JSON_SENSITIVE_KEYS, key) && isMaskableValue(value)) { - out[key] = MASK_PLACEHOLDER; - masked = true; - } else { - const r = maskNode(value); - out[key] = r.node; - masked = masked || r.masked; - } - } - return { node: out, masked }; - } - return { node, masked: false }; -} - -export interface MaskResult { - /** The body with sensitive values replaced; pretty-printed for JSON, otherwise the original shape with values substituted. */ - masked: string; - /** True when at least one sensitive value was masked. */ - hasSecrets: boolean; -} - -function maskJsonBody(body: string): MaskResult { - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return { masked: body, hasSecrets: false }; - } - const { node, masked } = maskNode(parsed); - return { masked: JSON.stringify(node, null, 2), hasSecrets: masked }; -} - -// Mask sensitive params in a form-urlencoded body, preserving the original -// formatting (we only swap the value, so the placeholder isn't percent-encoded -// the way `URLSearchParams.toString()` would mangle it). A non-form string -// (no `key=value` pairs with a sensitive key) falls through untouched. -function maskFormBody(body: string): MaskResult { - let hasSecrets = false; - const masked = body - .split("&") - .map((pair) => { - const eq = pair.indexOf("="); - if (eq === -1) return pair; - const rawKey = pair.slice(0, eq); - const value = pair.slice(eq + 1); - let key: string; - try { - key = decodeURIComponent(rawKey); - } catch { - key = rawKey; - } - if (isSensitiveKey(FORM_SENSITIVE_KEYS, key) && value.length > 0) { - hasSecrets = true; - return `${rawKey}=${MASK_PLACEHOLDER}`; - } - return pair; - }) - .join("&"); - return { masked: hasSecrets ? masked : body, hasSecrets }; -} - -/** - * Mask sensitive fields in an HTTP body for display. - * - * `contentType` (the body's `content-type` header, if known) picks the parser: - * - `*json*` → JSON masking (re-serialized pretty) - * - `application/x-www-form-urlencoded` → form masking (shape preserved) - * - any other known type (HTML, plaintext, XML, …) → no masking - * - absent/unknown → sniff: parse as JSON, else treat as form - * - * Bodies with no sensitive keys return unchanged with `hasSecrets: false` so - * callers can skip the reveal affordance. The caller keeps the original string - * for the revealed view. - * - * `contentType` is matched by substring (`*json*`, `*x-www-form-urlencoded*`) - * and we trust the wire's own label — a body mislabeled by the server (e.g. - * JSON sent as `text/html`) takes the "no masking" branch and renders raw. - * That's acceptable: the threat model is a screen-share viewer, not an - * adversary who controls the response's content-type. - */ -export function maskSecretsInBody( - body: string, - contentType?: string, -): MaskResult { - const ct = (contentType ?? "").toLowerCase(); - if (ct) { - if (ct.includes("json")) return maskJsonBody(body); - if (ct.includes("x-www-form-urlencoded")) return maskFormBody(body); - // Known, non-JSON/non-form content type → don't guess; leave it alone. - return { masked: body, hasSecrets: false }; - } - // No content-type hint: sniff. Valid JSON → JSON masking; otherwise form. - try { - JSON.parse(body); - } catch { - return maskFormBody(body); - } - return maskJsonBody(body); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts deleted file mode 100644 index 93adf6bc3..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { ProtocolErrorCode } from "@modelcontextprotocol/client"; -import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; - -/** - * SEP-2243 / modern Streamable HTTP transport awareness for the monitoring tabs. - * - * The modern (≥2026-07-28) transport mirrors key JSON-RPC body fields into HTTP - * headers so intermediaries can route/police MCP traffic without parsing bodies - * (`Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`, `MCP-Protocol-Version`), and defines - * a small family of spec error codes returned as JSON-RPC bodies over specific - * HTTP statuses. This module is the pure logic those tabs use to recognise, - * decode, and validate them — no rendering, fully unit-testable. The header - * recognition/decoding/consistency helpers back the Network tab; the spec-error - * classification (`classifyProtocolSpecError`) backs the Protocol tab. Shared - * here because both are the same modern HTTP/spec vocabulary. - * - * Recorded header keys are lowercased (the fetch tracker reads them through the - * `Headers` API, which normalises names), so every comparison here is - * case-insensitive on the name. - */ - -/** - * `-32020 HeaderMismatch` (SEP-2243). The client SDK keeps this in an internal - * chunk that isn't re-exported from its public barrel, so the spec-reserved - * value is pinned locally. The other three codes come from the SDK's - * {@link ProtocolErrorCode} enum. - */ -export const HEADER_MISMATCH_ERROR_CODE = -32020; - -const BASE64_SENTINEL_PREFIX = "=?base64?"; -const BASE64_SENTINEL_SUFFIX = "?="; - -const MCP_STANDARD_HEADER_NAMES: ReadonlySet = new Set([ - "mcp-method", - "mcp-name", - "mcp-protocol-version", -]); - -const MCP_PARAM_HEADER_PREFIX = "mcp-param-"; - -/** JSON-RPC `_meta` key carrying the negotiated protocol version. */ -export const PROTOCOL_VERSION_META_KEY = - "io.modelcontextprotocol/protocolVersion"; - -/** Whether `name` is one of the standard mirrored headers (case-insensitive). */ -export function isMcpStandardHeader(name: string): boolean { - return MCP_STANDARD_HEADER_NAMES.has(name.toLowerCase()); -} - -/** Whether `name` is an opt-in `Mcp-Param-*` custom header (case-insensitive). */ -export function isMcpParamHeader(name: string): boolean { - return name.toLowerCase().startsWith(MCP_PARAM_HEADER_PREFIX); -} - -/** Whether `name` is any modern MCP mirrored header (standard or `Mcp-Param-*`). */ -export function isMcpHeader(name: string): boolean { - return isMcpStandardHeader(name) || isMcpParamHeader(name); -} - -export interface DecodedMcpParamValue { - /** The value shown to the user: decoded when sentinel-encoded, else the raw. */ - value: string; - /** True when the raw header used the `=?base64?{b64}?=` sentinel form. */ - encoded: boolean; - /** The original, undecoded header value. */ - raw: string; -} - -/** - * Decode a mirrored header value per SEP-2243's value-encoding rules. A value - * wrapped as `=?base64?{base64-of-utf8}?=` is decoded to its UTF-8 string; - * anything else is passed through unchanged. A sentinel wrapper whose inner - * payload is not valid Base64 is reported as `encoded: true` but left as the raw - * string (best-effort — never throws). - */ -export function decodeMcpParamValue(raw: string): DecodedMcpParamValue { - const isSentinel = - raw.length >= - BASE64_SENTINEL_PREFIX.length + BASE64_SENTINEL_SUFFIX.length && - raw.startsWith(BASE64_SENTINEL_PREFIX) && - raw.endsWith(BASE64_SENTINEL_SUFFIX); - if (!isSentinel) return { value: raw, encoded: false, raw }; - - const b64 = raw.slice( - BASE64_SENTINEL_PREFIX.length, - raw.length - BASE64_SENTINEL_SUFFIX.length, - ); - try { - const bin = atob(b64); - const bytes = Uint8Array.from(bin, (ch) => ch.charCodeAt(0)); - return { value: new TextDecoder().decode(bytes), encoded: true, raw }; - } catch { - return { value: raw, encoded: true, raw }; - } -} - -export interface JsonRpcError { - code: number; - message: string; - data?: unknown; -} - -/** - * Extract the first JSON-RPC `error` object from a (possibly batched) response - * body. Returns `null` for an empty, non-JSON, or error-free body. Best-effort - * and never throws. - */ -export function parseJsonRpcError( - body: string | undefined, -): JsonRpcError | null { - if (!body) return null; - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return null; - } - const candidates = Array.isArray(parsed) ? parsed : [parsed]; - for (const candidate of candidates) { - if (candidate === null || typeof candidate !== "object") continue; - if (!("error" in candidate)) continue; - const err = (candidate as { error: unknown }).error; - if (err === null || typeof err !== "object") continue; - const { code, message, data } = err as { - code?: unknown; - message?: unknown; - data?: unknown; - }; - if (typeof code !== "number") continue; - return { - code, - message: typeof message === "string" ? message : "", - data, - }; - } - return null; -} - -export interface McpSpecError { - code: number; - /** Spec name, e.g. `HeaderMismatch`. */ - name: string; - /** One-line explanation for the Network UI. */ - description: string; - /** HTTP status the spec pairs this code with. */ - expectedHttpStatus: number; - /** The actual HTTP status recorded on the entry, when present. */ - actualHttpStatus?: number; - /** - * For `-32022 UnsupportedProtocolVersion`: the versions the server advertises - * as supported (from `error.data.supported`), when present. - */ - supported?: string[]; - /** - * Whether a "view in Network" link is worth offering on the Protocol alert. - * True when the error is EITHER thrown by the SDK (its real HTTP response - * lives only in the Network log — the Protocol entry is a synthetic fold from - * the correlated fetch) OR tied to the HTTP request/response headers. A - * delivered, protocol-only error (a missing capability, an unsupported version - * whose `supported` list is already in the alert) sets this false: the raw - * HTTP entry adds nothing. - */ - httpRelevant: boolean; -} - -const SPEC_ERROR_META: Record< - number, - { - name: string; - description: string; - expectedHttpStatus: number; - httpRelevant: boolean; - } -> = { - [HEADER_MISMATCH_ERROR_CODE]: { - name: "HeaderMismatch", - description: - "An Mcp-* header did not match the JSON-RPC body (SEP-2243). The server rejected the request pre-dispatch.", - expectedHttpStatus: 400, - // The mirrored headers are the whole story — the Network entry shows them. - httpRelevant: true, - }, - [ProtocolErrorCode.MissingRequiredClientCapability]: { - name: "MissingRequiredClientCapability", - description: - "The server requires a client capability that was not declared (SEP-2575).", - expectedHttpStatus: 400, - // Protocol-only: the capability requirement is in the error, not the HTTP. - httpRelevant: false, - }, - [ProtocolErrorCode.UnsupportedProtocolVersion]: { - name: "UnsupportedProtocolVersion", - description: - "The requested protocol version is not supported (SEP-2575). The error body lists the supported versions.", - expectedHttpStatus: 400, - // Protocol-only: the supported-versions list is already shown in the alert. - httpRelevant: false, - }, - [ProtocolErrorCode.MethodNotFound]: { - name: "MethodNotFound", - description: - "Unknown method. A JSON-RPC error body on an HTTP 404 marks a modern server — a legacy HTTP+SSE server returns a bare 404 with no body.", - expectedHttpStatus: 404, - // Thrown by the SDK (HTTP 404, not delivered as a frame) — the real - // response lives only in the Network log, so the link is essential. - httpRelevant: true, - }, -}; - -function extractSupportedVersions(data: unknown): string[] | undefined { - if (data === null || typeof data !== "object") return undefined; - const supported = (data as { supported?: unknown }).supported; - if (!Array.isArray(supported)) return undefined; - const strings = supported.filter((v): v is string => typeof v === "string"); - return strings.length > 0 ? strings : undefined; -} - -/** - * Classify a Network entry's response as one of the modern spec errors, or - * `null` if it isn't one. `-32601 MethodNotFound` is only treated as the modern - * marker when it arrives on an HTTP 404 (an in-band `-32601` on a 200 response - * is an ordinary result, not the transport-level taxonomy this surfaces). - */ -export function classifyMcpSpecError( - entry: Pick, -): McpSpecError | null { - const err = parseJsonRpcError(entry.responseBody); - if (!err) return null; - const meta = SPEC_ERROR_META[err.code]; - if (!meta) return null; - if ( - err.code === ProtocolErrorCode.MethodNotFound && - entry.responseStatus !== 404 - ) { - return null; - } - const result: McpSpecError = { - code: err.code, - ...meta, - actualHttpStatus: entry.responseStatus, - }; - if (err.code === ProtocolErrorCode.UnsupportedProtocolVersion) { - const supported = extractSupportedVersions(err.data); - if (supported) result.supported = supported; - } - return result; -} - -/** - * Classify a JSON-RPC error *code* (e.g. from a Protocol message's - * `response.error`) as one of the modern spec errors, or `null`. - * - * `-32020`/`-32021`/`-32022` are SEP-reserved and unambiguous, so they're - * recognised from the code alone. `-32601 MethodNotFound`, by contrast, is the - * most generic standard JSON-RPC error — any server can return it *in-band* for - * an unsupported method, which is not the modern transport taxonomy. So it's - * treated as the modern marker only when the correlated fetch was an actual 404 - * (`httpStatus === 404`), mirroring {@link classifyMcpSpecError}. The genuine - * modern case is thrown by the SDK on a 404 and folded in by - * `enrichProtocolEntries`, which always carries that 404 — so requiring 404 loses - * nothing intended. An unknown status (`undefined`) is *not* a 404: that path is - * only reached by an ordinary in-band `-32601` with no correlated 404 (most - * commonly a **stdio** connection, which has no HTTP at all), so it must not get - * the modern framing. - */ -export function classifyProtocolSpecError( - code: number, - data?: unknown, - httpStatus?: number, -): McpSpecError | null { - const meta = SPEC_ERROR_META[code]; - if (!meta) return null; - if (code === ProtocolErrorCode.MethodNotFound && httpStatus !== 404) { - return null; - } - const result: McpSpecError = { code, ...meta }; - if (code === ProtocolErrorCode.UnsupportedProtocolVersion) { - const supported = extractSupportedVersions(data); - if (supported) result.supported = supported; - } - return result; -} - -/** - * A bare HTTP 404 with no JSON-RPC body is how a legacy HTTP+SSE endpoint (or a - * non-MCP server) answers an unknown route — distinct from a modern server's - * `-32601` 404 (see {@link classifyMcpSpecError}). Surfacing it helps explain - * why a connection fell back to the legacy transport. - */ -export function isLegacyBare404( - entry: Pick, -): boolean { - return ( - entry.responseStatus === 404 && - parseJsonRpcError(entry.responseBody) === null - ); -} - -export interface HeaderConsistency { - /** Canonical lowercase header name. */ - header: string; - /** The value derived from the JSON-RPC body that the header should mirror. */ - expected: string; - /** The header's value (sentinel-decoded), as actually sent. */ - actual: string; - /** Whether the header and body agree. */ - ok: boolean; -} - -interface JsonRpcRequestBody { - method?: unknown; - params?: { - name?: unknown; - uri?: unknown; - _meta?: Record; - }; -} - -function parseRequestBody(body: string | undefined): JsonRpcRequestBody | null { - if (!body) return null; - try { - const parsed: unknown = JSON.parse(body); - if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) - ) { - return null; - } - return parsed as JsonRpcRequestBody; - } catch { - return null; - } -} - -function findHeaderValue( - headers: Record, - name: string, -): string | undefined { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === name) return value; - } - return undefined; -} - -/** - * Cross-check the mirrored standard headers against the request body they - * derive from, so a `HeaderMismatch` is visible at a glance before the server - * even rejects it. A row is produced only when BOTH the header is present AND - * the corresponding body field can be derived — an unverifiable pair (e.g. a - * connection-level `mcp-protocol-version` on a body with no version envelope) is - * skipped rather than falsely flagged. - * - * Checks: `mcp-method` ↔ body `method`; `mcp-name` (decoded) ↔ body - * `params.name` / `params.uri`; `mcp-protocol-version` ↔ body - * `params._meta["io.modelcontextprotocol/protocolVersion"]`. - */ -export function checkHeaderConsistency( - entry: Pick, -): HeaderConsistency[] { - const body = parseRequestBody(entry.requestBody); - if (!body) return []; - const rows: HeaderConsistency[] = []; - - const methodHeader = findHeaderValue(entry.requestHeaders, "mcp-method"); - if (methodHeader !== undefined && typeof body.method === "string") { - rows.push({ - header: "mcp-method", - expected: body.method, - actual: methodHeader, - ok: methodHeader === body.method, - }); - } - - const nameHeader = findHeaderValue(entry.requestHeaders, "mcp-name"); - const bodyName = - typeof body.params?.name === "string" - ? body.params.name - : typeof body.params?.uri === "string" - ? body.params.uri - : undefined; - if (nameHeader !== undefined && bodyName !== undefined) { - const decoded = decodeMcpParamValue(nameHeader).value; - rows.push({ - header: "mcp-name", - expected: bodyName, - actual: decoded, - ok: decoded === bodyName, - }); - } - - const versionHeader = findHeaderValue( - entry.requestHeaders, - "mcp-protocol-version", - ); - const bodyVersion = body.params?._meta?.[PROTOCOL_VERSION_META_KEY]; - if (versionHeader !== undefined && typeof bodyVersion === "string") { - rows.push({ - header: "mcp-protocol-version", - expected: bodyVersion, - actual: versionHeader, - ok: versionHeader === bodyVersion, - }); - } - - return rows; -} - -/** The `header` names from {@link checkHeaderConsistency} rows that mismatched. */ -export function mismatchedHeaders( - entry: Pick, -): Set { - return new Set( - checkHeaderConsistency(entry) - .filter((row) => !row.ok) - .map((row) => row.header), - ); -} - -/** - * Whether an entry's error is a cancellation surfaced as a connection abort. - * Under the modern transport, cancelling an in-flight request aborts the - * connection instead of sending a `notifications/cancelled` frame (SEP-2575), so - * a cancelled request lands here as an `AbortError` rather than a tracked frame. - */ -export function isCancellationAbort( - entry: Pick, -): boolean { - if (!entry.error) return false; - const message = entry.error.toLowerCase(); - return message.includes("abort") || message.includes("cancel"); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts deleted file mode 100644 index 5fd1a1210..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Classify a captured `auth`-category network request by its OAuth flow phase, - * so the Network tab can label discovery / registration / token / step-up - * traffic. Purely heuristic on the request URL — the SDK owns the flow, so this - * is presentation only and returns `undefined` when nothing matches. - * - * Phases follow the 2026-07-28 authorization flow: RFC 9728/8414 discovery, - * DCR / CIMD registration (SEP-991), the authorization redirect, and the token - * exchange (where a `403 insufficient_scope` step-up re-authorizes, SEP-2350). - */ -export type OAuthNetworkPhase = - | "discovery" - | "registration" - | "authorize" - | "token"; - -const PHASE_LABELS: Record = { - discovery: "Discovery", - registration: "Registration", - authorize: "Authorize", - token: "Token", -}; - -export function oauthNetworkPhase( - rawUrl: string, -): OAuthNetworkPhase | undefined { - // Match on the path only; query strings and fragments are irrelevant and can - // contain misleading substrings (e.g. a `redirect_uri` pointing at `/token`). - let path: string; - try { - path = new URL(rawUrl).pathname.toLowerCase(); - } catch { - // Not an absolute URL — fall back to the raw string, minus any query. - path = rawUrl.toLowerCase().split(/[?#]/)[0] ?? ""; - } - - if ( - path.includes("/.well-known/oauth-protected-resource") || - path.includes("/.well-known/oauth-authorization-server") || - path.includes("/.well-known/openid-configuration") - ) { - return "discovery"; - } - // Match the endpoint as the final path segment (trailing slash tolerated) so a - // nested path like `/token/refresh` or `/api/register/foo` is not misclassified. - const endpoint = path.replace(/\/+$/, ""); - if (endpoint.endsWith("/register")) { - return "registration"; - } - if (endpoint.endsWith("/token")) { - return "token"; - } - if (endpoint.endsWith("/authorize")) { - return "authorize"; - } - return undefined; -} - -/** Human-readable badge label for a phase. */ -export function oauthNetworkPhaseLabel(phase: OAuthNetworkPhase): string { - return PHASE_LABELS[phase]; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts deleted file mode 100644 index b3d440045..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge"; - -/** - * Allowed shapes for a CSP source-expression supplied by an app's - * `_meta.ui.csp`. Each entry is server-supplied and untrusted: it MUST NOT - * inject extra directives (`;`) or break out of the meta attribute - * (`"`, `<`, `>`). Only common source forms are accepted — - * `scheme://host[:port][/path]`, scheme-only (`data:`, `blob:`), `*`, and - * wildcard hosts (`*.example.com`, `https://*.example.com`); anything else is - * dropped by {@link approveCspSources}. - */ -export const SAFE_CSP_SOURCE = - /^(?:\*|[a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/; - -/** - * Identity helper that pins `CSP_KEYS` to an exhaustive, valid key list at - * compile time. `satisfies readonly (keyof McpUiResourceCsp)[]` alone only - * proves every *listed* key is valid; it does NOT prove the list is *complete*. - * The intersected conditional adds the missing half: when `CSP_KEYS` covers - * every key, `keyof McpUiResourceCsp extends T[number]` holds and the parameter - * type is just `T`; if the upstream ext-apps type ever gains a new domain key, - * the conditional collapses the parameter type to `never` and the call fails to - * compile — forcing the key to be added here rather than being silently dropped - * (a vanished restriction) by {@link approveCspSources}. - */ -function exhaustiveCspKeys( - keys: T & (keyof McpUiResourceCsp extends T[number] ? unknown : never), -): T { - return keys; -} - -const CSP_KEYS = exhaustiveCspKeys([ - "connectDomains", - "resourceDomains", - "frameDomains", - "baseUriDomains", -]); - -/** - * Filter an app-supplied {@link McpUiResourceCsp} down to the entries the host - * will actually enforce. Unsafe values are dropped (and warned), and the - * resulting object contains only keys with at least one accepted source. The - * return value is what the host echoes back to the view via - * `hostCapabilities.sandbox.csp` so the app sees what was granted, not what it - * asked for. - * - * NOTE: this screens each source for *injection safety* only — it does NOT - * bound the *breadth* of a grant. A bare `*` (and a scheme-wildcard host) is a - * syntactically safe source, so `resourceDomains: ["*"]` is "approved" and maps - * to `script-src 'unsafe-inline' *` (scripts from anywhere), just as - * `connectDomains: ["*"]` maps to `connect-src *`. That breadth is acceptable - * here because the enforced document runs in an opaque-origin sandbox with no - * ambient credentials and nothing to exfiltrate beyond what the app already - * received; "approved" therefore means "cannot break out of the meta - * attribute," not "restrictive." - */ -export function approveCspSources( - csp: McpUiResourceCsp | undefined, -): McpUiResourceCsp { - const approved: McpUiResourceCsp = {}; - if (!csp) return approved; - for (const key of CSP_KEYS) { - const requested = csp[key]; - if (!Array.isArray(requested)) continue; - const accepted: string[] = []; - for (const entry of requested) { - if (typeof entry === "string" && SAFE_CSP_SOURCE.test(entry)) { - accepted.push(entry); - } else { - console.warn("[mcp-app sandbox] dropping unsafe CSP source:", entry); - } - } - if (accepted.length > 0) approved[key] = accepted; - } - return approved; -} - -function joinSources(list: string[] | undefined, fallback: string): string { - return list && list.length > 0 ? list.join(" ") : fallback; -} - -/** - * Translate an approved {@link McpUiResourceCsp} into the Content-Security-Policy - * string enforced on the inner sandboxed document. `default-src 'none'` is the - * catch-all so any fetch type not explicitly mapped is denied. `script-src` / - * `style-src` carry `'unsafe-inline'` because the app's own inline code ships - * with the inline-delivered HTML and has no origin to allowlist; external loads - * stay restricted to `resourceDomains`. - * - * `resourceDomains` intentionally feeds `script-src` (and `style-src`) in - * addition to `img-src`/`font-src`/`media-src`: the `McpUiResourceCsp` contract - * defines it as a single "static resources" allowlist that "Maps to CSP - * `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives," so - * an app that lists a CDN there is granted script execution from that origin by - * design. There is no narrower per-directive key in the contract; if the spec - * ever splits scripts out, update the mapping here accordingly. - */ -export function buildSandboxCspPolicy(approved: McpUiResourceCsp): string { - const resourceSrc = joinSources(approved.resourceDomains, "'none'"); - const inlineResource = - resourceSrc === "'none'" - ? "'unsafe-inline'" - : `'unsafe-inline' ${resourceSrc}`; - return [ - "default-src 'none'", - `connect-src ${joinSources(approved.connectDomains, "'none'")}`, - `script-src ${inlineResource}`, - `style-src ${inlineResource}`, - `img-src ${resourceSrc}`, - `font-src ${resourceSrc}`, - `media-src ${resourceSrc}`, - `frame-src ${joinSources(approved.frameDomains, "'none'")}`, - `base-uri ${joinSources(approved.baseUriDomains, "'self'")}`, - "form-action 'none'", - "object-src 'none'", - "worker-src 'none'", - ].join("; "); -} - -/** HTML-attribute-encode a string (defense-in-depth for the CSP meta value). */ -export function escapeHtmlAttr(s: string): string { - return s - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(//g, ">"); -} - -/** - * Wrap an app's untrusted HTML in a host-authored document whose first - * `` child is the CSP ``. The wrapper bytes are fixed — the - * untrusted content lands inside `` and never precedes the policy, so a - * ``/`` token in the app's HTML cannot push the meta inert or - * load resources before the policy applies. If the app's HTML is itself a full - * document, the second ``/``/`` are parsed inside - * `` (the HTML parser ignores duplicate document-structure tags) while - * its scripts and styles still run — governed by the already-applied policy. - */ -export function wrapSandboxedHtml( - untrustedHtml: string, - policy: string, -): string { - const meta = ``; - return `${meta}${untrustedHtml}`; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts deleted file mode 100644 index 247e31b41..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Tool } from "@modelcontextprotocol/client"; - -/** - * Returns the display label for an MCP entity that follows the BaseMetadata - * shape (Tool, Prompt, Resource): the optional `title` if provided, else the - * machine `name`. Centralized so list items, detail panels, and screens stay - * consistent. - */ -export function resolveDisplayLabel(name: string, title?: string): string { - return title ?? name; -} - -/** - * True when the tool's input schema declares at least one property — used by - * App-flow callers to decide whether to render a form or auto-launch. Kept in - * one place so the definition of "has fields" stays consistent if it ever - * grows to consider `additionalProperties`, `anyOf`, etc. - */ -export function hasInputFields(tool: Tool): boolean { - return Object.keys(tool.inputSchema.properties ?? {}).length > 0; -} diff --git a/packages/workbench/src/inspector/vendor/core/auth/providers.ts b/packages/workbench/src/inspector/vendor/core/auth/providers.ts deleted file mode 100644 index 12808542c..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/providers.ts +++ /dev/null @@ -1,356 +0,0 @@ -import type { - OAuthClientProvider, - OAuthClientInformationContext, -} from "@modelcontextprotocol/client"; -import type { - OAuthClientInformation, - OAuthClientMetadata, - OAuthTokens, - OAuthMetadata, - OAuthDiscoveryState, -} from "@modelcontextprotocol/client"; -import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js"; -import { generateOAuthState } from "./utils.js"; - -/** - * Redirect URL provider. Returns the redirect URL for OAuth flows. - * Caller populates the URL before authenticate() (e.g. from callback server). - */ -export interface RedirectUrlProvider { - getRedirectUrl(): string; -} - -/** - * Mutable redirect URL provider for TUI/CLI. Caller sets redirectUrl - * before authenticate(). - */ -export class MutableRedirectUrlProvider implements RedirectUrlProvider { - redirectUrl = ""; - - getRedirectUrl(): string { - return this.redirectUrl; - } -} - -/** - * Navigation handler interface - * Handles navigation to authorization URLs - */ -export interface OAuthNavigation { - /** - * Navigate to the authorization URL - * @param authorizationUrl - The OAuth authorization URL - */ - navigateToAuthorization(authorizationUrl: URL): void; -} - -export type OAuthNavigationCallback = ( - authorizationUrl: URL, -) => void | Promise; - -/** - * Callback navigation handler - * Invokes the provided callback when navigation is requested. - * The caller always handles navigation. - */ -export class CallbackNavigation implements OAuthNavigation { - private authorizationUrl: URL | null = null; - private callback: OAuthNavigationCallback; - - constructor(callback: OAuthNavigationCallback) { - this.callback = callback; - } - - navigateToAuthorization(authorizationUrl: URL): void { - this.authorizationUrl = authorizationUrl; - const result = this.callback(authorizationUrl); - if (result instanceof Promise) { - void result; - } - } - - getAuthorizationUrl(): URL | null { - return this.authorizationUrl; - } -} - -/** - * Console navigation handler - * Prints the authorization URL to console, optionally invokes an extra callback. - */ -export class ConsoleNavigation extends CallbackNavigation { - constructor(callback?: OAuthNavigationCallback) { - super((url) => { - console.log(`Please navigate to: ${url.href}`); - return callback?.(url); - }); - } -} - -/** - * Config passed to BaseOAuthClientProvider. Provider assigns to members and - * accesses as needed. - */ -export type OAuthProviderConfig = { - storage: OAuthStorage; - redirectUrlProvider: RedirectUrlProvider; - navigation: OAuthNavigation; - clientMetadataUrl?: string; -}; - -/** - * Base OAuth client provider - * Implements common OAuth provider functionality. - * Use with injected storage, redirect URL provider, and navigation. - */ -export class BaseOAuthClientProvider implements OAuthClientProvider { - private capturedAuthUrl: URL | null = null; - private eventTarget: EventTarget | null = null; - private suppressAuthorizationNavigation = false; - /** Cached after {@link prepareForAuth} for sync SDK `clientMetadata.scope`. */ - private cachedScope: string | undefined; - - protected serverUrl: string; - protected storage: OAuthStorage; - protected redirectUrlProvider: RedirectUrlProvider; - protected navigation: OAuthNavigation; - public clientMetadataUrl?: string; - - constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) { - this.serverUrl = serverUrl; - this.storage = oauthConfig.storage; - this.redirectUrlProvider = oauthConfig.redirectUrlProvider; - this.navigation = oauthConfig.navigation; - this.clientMetadataUrl = oauthConfig.clientMetadataUrl; - } - - /** - * Load persisted scope into {@link cachedScope} before SDK `auth()` (which - * reads {@link clientMetadata.scope} synchronously). - */ - async prepareForAuth(): Promise { - this.cachedScope = await this.storage.getScope(this.serverUrl); - } - - /** - * Set the event target for dispatching oauthAuthorizationRequired events - */ - setEventTarget(eventTarget: EventTarget): void { - this.eventTarget = eventTarget; - } - - /** - * Get the captured authorization URL (for return value) - */ - getCapturedAuthUrl(): URL | null { - return this.capturedAuthUrl; - } - - /** - * Clear the captured authorization URL - */ - clearCapturedAuthUrl(): void { - this.capturedAuthUrl = null; - } - - /** Capture authorize URL without navigating (step-up confirmation modal). */ - setSuppressAuthorizationNavigation(suppress: boolean): void { - this.suppressAuthorizationNavigation = suppress; - } - - get scope(): string | undefined { - return this.cachedScope; - } - - get redirectUrl(): string { - return this.redirectUrlProvider.getRedirectUrl(); - } - - get redirect_uris(): string[] { - return [this.redirectUrl]; - } - - get clientMetadata(): OAuthClientMetadata { - const metadata: OAuthClientMetadata = { - redirect_uris: this.redirect_uris, - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "MCP Inspector", - client_uri: "https://github.com/modelcontextprotocol/inspector", - scope: this.scope ?? "", - // SEP-837: the Inspector is a locally-hosted app reached over localhost, so - // it registers as a native client. OIDC-flavored ASes default an omitted - // `application_type` to `"web"`, which forbids loopback redirect URIs and - // rejects DCR. (The SDK also infers `"native"` from loopback `redirect_uris`; - // declaring it explicitly keeps the value visible and correct even when the - // redirect host is not itself a loopback literal.) - application_type: "native", - }; - - // Note: clientMetadataUrl for CIMD mode is passed to registerClient() directly, - // not as part of clientMetadata. The SDK handles CIMD separately. - - return metadata; - } - - state(): string | Promise { - return generateOAuthState(); - } - - async clientInformation( - ctx?: OAuthClientInformationContext, - ): Promise { - // Try preregistered (static, issuer-independent) first, then the per-issuer - // dynamic registration (SEP-2352 — keyed by `ctx.issuer`). - const preregistered = await this.storage.getClientInformation( - this.serverUrl, - true, - ); - if (preregistered) { - return preregistered; - } - return await this.storage.getClientInformation( - this.serverUrl, - false, - ctx?.issuer, - ); - } - - async saveClientInformation( - clientInformation: OAuthClientInformation, - // SDK v2's `OAuthClientProvider.saveClientInformation` passes an - // `OAuthClientInformationContext` ({ issuer }); our own DCR/CIMD callers - // pass `SaveClientInformationOptions` ({ registrationKind }). Accept either - // and read whichever keys are present: the SDK supplies `issuer` (SEP-2352 - // per-AS keying) and defaults registration kind to DCR; our callers supply - // the registration kind and no issuer yet. - options?: SaveClientInformationOptions | OAuthClientInformationContext, - ): Promise { - const registrationKind = - options && "registrationKind" in options - ? options.registrationKind - : "dcr"; - const issuer = options && "issuer" in options ? options.issuer : undefined; - await this.storage.saveClientInformation( - this.serverUrl, - clientInformation, - { - registrationKind, - issuer, - }, - ); - } - - async saveScope(scope: string | undefined): Promise { - await this.storage.saveScope(this.serverUrl, scope); - this.cachedScope = scope; - } - - async savePreregisteredClientInformation( - clientInformation: OAuthClientInformation, - ): Promise { - await this.storage.savePreregisteredClientInformation( - this.serverUrl, - clientInformation, - ); - } - - async tokens( - ctx?: OAuthClientInformationContext, - ): Promise { - return await this.storage.getTokens(this.serverUrl, ctx?.issuer); - } - - async saveTokens( - tokens: OAuthTokens, - ctx?: OAuthClientInformationContext, - ): Promise { - await this.storage.saveTokens(this.serverUrl, tokens, { - issuer: ctx?.issuer, - }); - } - - redirectToAuthorization(authorizationUrl: URL): void { - // Capture URL for return value - this.capturedAuthUrl = authorizationUrl; - - if (!this.suppressAuthorizationNavigation) { - if (this.eventTarget) { - this.eventTarget.dispatchEvent( - new CustomEvent("oauthAuthorizationRequired", { - detail: { url: authorizationUrl }, - }), - ); - } - this.navigation.navigateToAuthorization(authorizationUrl); - } - } - - async saveCodeVerifier(codeVerifier: string): Promise { - await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier); - } - - async codeVerifier(): Promise { - const verifier = await this.storage.getCodeVerifier(this.serverUrl); - if (!verifier) { - throw new Error("No code verifier saved for session"); - } - return verifier; - } - - async clear(): Promise { - await this.storage.clear(this.serverUrl); - } - - async getServerMetadata(): Promise { - return this.storage.getServerMetadata(this.serverUrl); - } - - async saveServerMetadata(metadata: OAuthMetadata): Promise { - await this.storage.saveServerMetadata(this.serverUrl, metadata); - } - - /** - * SEP-2352 discovery-state round-trip. The SDK persists RFC 9728/8414 discovery - * here (alongside the code verifier) so that on the authorization-code callback - * leg it can compare the resolved AS `issuer` against the one recorded at - * redirect time and reject a mismatch (`AuthorizationServerMismatchError`). - * Without these two methods the SDK only `console.warn`s and the binding check - * is inactive. - */ - async saveDiscoveryState(state: OAuthDiscoveryState): Promise { - await this.storage.saveDiscoveryState(this.serverUrl, state); - } - - async discoveryState(): Promise { - return this.storage.getDiscoveryState(this.serverUrl); - } - - /** - * SEP-2352 credential invalidation. The SDK calls this to drop credentials the - * server has rejected; hosts also call `'discovery'` on repeated 401s so a - * changed `authorization_servers` list is re-fetched. - */ - async invalidateCredentials( - scope: "all" | "client" | "tokens" | "verifier" | "discovery", - ): Promise { - switch (scope) { - case "all": - await this.storage.clear(this.serverUrl); - return; - case "client": - await this.storage.clearClientInformation(this.serverUrl); - return; - case "tokens": - await this.storage.clearTokens(this.serverUrl); - return; - case "verifier": - await this.storage.clearCodeVerifier(this.serverUrl); - return; - case "discovery": - await this.storage.clearDiscoveryState(this.serverUrl); - return; - } - } -} diff --git a/packages/workbench/src/inspector/vendor/core/auth/storage.ts b/packages/workbench/src/inspector/vendor/core/auth/storage.ts deleted file mode 100644 index 90163c19d..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/storage.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - OAuthClientInformation, - OAuthTokens, - OAuthMetadata, - OAuthDiscoveryState, -} from "@modelcontextprotocol/client"; -import type { OAuthClientRegistrationKind } from "./types.js"; - -/** - * Abstract storage interface for OAuth state - * Supports browser (sessionStorage), Node.js (file), and remote HTTP backends. - */ -export interface SaveTokensOptions { - /** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */ - enterpriseManaged?: boolean; - /** - * Authorization-server `issuer` these tokens are bound to (SEP-2352). When set, - * tokens are keyed under `(server, issuer)`; when omitted (EMA / legacy callers) - * they write the per-server fallback slot. - */ - issuer?: string; -} - -export type { OAuthClientRegistrationKind }; - -export interface SaveClientInformationOptions { - registrationKind: "dcr" | "cimd"; - /** - * Authorization-server `issuer` this client registration is bound to (SEP-2352). - * Client identifiers are unique to the AS that issued them (RFC 6749 §2.2). - */ - issuer?: string; -} - -export interface OAuthStorage { - /** - * Optional preload of persisted state into memory. Getters and setters load - * automatically when needed; use this only for fail-fast at known boundaries - * (e.g. OAuth callback resume after a full-page navigation). - */ - load(): Promise; - - /** - * Get client information (preregistered or dynamically registered). - * - * @param issuer - When set, return the registration bound to this AS `issuer` - * (SEP-2352). When omitted, return the active-issuer slot, falling back to - * the legacy unkeyed entry. - */ - getClientInformation( - serverUrl: string, - isPreregistered?: boolean, - issuer?: string, - ): Promise; - - /** - * Get how the dynamic client registration slot was established. - */ - getClientRegistrationKind( - serverUrl: string, - issuer?: string, - ): Promise; - - /** - * Save client information (dynamically registered) - */ - saveClientInformation( - serverUrl: string, - clientInformation: OAuthClientInformation, - options: SaveClientInformationOptions, - ): Promise; - - /** - * Save preregistered client information (static client from config) - */ - savePreregisteredClientInformation( - serverUrl: string, - clientInformation: OAuthClientInformation, - ): Promise; - - /** - * Clear client information. When `issuer` is set, clear only that AS's - * registration; when omitted, clear every issuer's registration plus the - * legacy unkeyed entry. - */ - clearClientInformation( - serverUrl: string, - isPreregistered?: boolean, - issuer?: string, - ): Promise; - - /** - * Get OAuth tokens. When `issuer` is set, return that AS's tokens (SEP-2352); - * when omitted, return the active-issuer tokens, falling back to the legacy - * unkeyed entry (the transport's per-request bearer read). - */ - getTokens( - serverUrl: string, - issuer?: string, - ): Promise; - - /** - * Save OAuth tokens - */ - saveTokens( - serverUrl: string, - tokens: OAuthTokens, - options?: SaveTokensOptions, - ): Promise; - - /** - * Clear OAuth tokens. When `issuer` is set, clear only that AS's tokens; when - * omitted, clear every issuer's tokens plus the legacy unkeyed entry. - */ - clearTokens(serverUrl: string, issuer?: string): Promise; - - /** - * Get code verifier (for PKCE) - */ - getCodeVerifier(serverUrl: string): Promise; - - /** - * Save code verifier (for PKCE) - */ - saveCodeVerifier(serverUrl: string, codeVerifier: string): Promise; - - /** - * Clear code verifier - */ - clearCodeVerifier(serverUrl: string): Promise; - - /** - * Get scope - */ - getScope(serverUrl: string): Promise; - - /** - * Save scope - */ - saveScope(serverUrl: string, scope: string | undefined): Promise; - - /** - * Clear scope - */ - clearScope(serverUrl: string): Promise; - - /** - * Get server metadata discovered during OAuth - */ - getServerMetadata(serverUrl: string): Promise; - - /** - * Save server metadata discovered during OAuth - */ - saveServerMetadata(serverUrl: string, metadata: OAuthMetadata): Promise; - - /** - * Clear server metadata - */ - clearServerMetadata(serverUrl: string): Promise; - - /** - * Get the cached RFC 9728/8414 discovery state (SEP-2352). The SDK restores it - * to skip re-discovery and, on the authorization-code callback leg, to bind the - * exchange to the AS that minted the code (`AuthorizationServerMismatchError`). - */ - getDiscoveryState( - serverUrl: string, - ): Promise; - - /** - * Save the RFC 9728/8414 discovery state. Persisted alongside the code verifier - * so it survives the authorization redirect round-trip. - */ - saveDiscoveryState( - serverUrl: string, - state: OAuthDiscoveryState, - ): Promise; - - /** - * Clear the cached discovery state (SDK `invalidateCredentials('discovery')`). - */ - clearDiscoveryState(serverUrl: string): Promise; - - /** - * Clear all OAuth data for a server - */ - clear(serverUrl: string): Promise; - - /** - * Get cached IdP OIDC session for EMA (keyed by issuer). - */ - getIdpSession(issuer: string): Promise; - - /** - * Save IdP OIDC session fields for EMA. - */ - saveIdpSession( - issuer: string, - session: Partial, - ): Promise; - - /** - * Clear cached IdP session for an issuer. - */ - clearIdpSession(issuer: string): Promise; - - /** - * Remove per-server OAuth state for MCP servers whose tokens were minted via EMA. - */ - clearEnterpriseManagedResourceServers(): Promise; -} - -/** - * Cached IdP OIDC session for EMA leg 1. - */ -export interface IdpSessionState { - idToken?: string; - refreshToken?: string; - /** Epoch ms when the ID Token expires (when known). */ - idTokenExpiresAt?: number; -} - -/** - * Generate server-specific storage key - */ -export function getServerSpecificKey( - baseKey: string, - serverUrl: string, -): string { - return `[${serverUrl}] ${baseKey}`; -} - -/** - * Base storage keys for OAuth data - */ -export const OAUTH_STORAGE_KEYS = { - CODE_VERIFIER: "mcp_code_verifier", - TOKENS: "mcp_tokens", - CLIENT_INFORMATION: "mcp_client_information", - PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", - SERVER_METADATA: "mcp_server_metadata", - SCOPE: "mcp_scope", -} as const; diff --git a/packages/workbench/src/inspector/vendor/core/auth/types.ts b/packages/workbench/src/inspector/vendor/core/auth/types.ts deleted file mode 100644 index b21d7cf1a..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/types.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { - OAuthMetadata, - OAuthClientInformation, - OAuthClientInformationFull, - OAuthTokens, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/client"; - -// OAuth flow steps. Extended for the 2026-07-28 authorization hardening: -// `cimd_fetch` (SEP-991 client-id metadata document registration), `issuer_comparison` -// (SEP-2352 authorization-server binding check), and `scope_step_up` (SEP-2350 -// accumulated-scope re-authorization). -export type OAuthStep = - | "metadata_discovery" - | "client_registration" - /** SEP-991 — registering via a Client ID Metadata Document instead of DCR. */ - | "cimd_fetch" - /** SEP-2352 — comparing the resolved AS `issuer` against stored/discovered state. */ - | "issuer_comparison" - | "authorization_redirect" - | "authorization_code" - /** SEP-2350 — re-authorizing with the accumulated (prior ∪ challenged) scope union. */ - | "scope_step_up" - | "token_request" - | "complete"; - -// Message types for inline feedback -export type MessageType = "success" | "error" | "info"; - -export interface StatusMessage { - type: MessageType; - message: string; -} - -/** Which authorization protocol applies. */ -export type AuthProtocol = "standard" | "ema"; - -/** How the active OAuth client id was established for this MCP server. */ -export type OAuthClientRegistrationKind = "static" | "dcr" | "cimd"; - -/** Persisted OAuth authorization snapshot for an HTTP MCP server (storage + config). */ -export interface OAuthConnectionState { - authorized: boolean; - protocol: AuthProtocol; - serverUrl: string; - configuredScope?: string; - grantedScope?: string; - tokens?: OAuthTokens; - client?: { - clientId: string; - /** Absent for legacy storage entries predating registration kind tracking. */ - registrationKind?: OAuthClientRegistrationKind; - hasClientSecret: boolean; - }; - authorizationServerMetadata?: OAuthMetadata; - enterpriseManaged?: boolean; - ema?: { - idpIssuer: string; - idpClientId: string; - idpSession: "none" | "logged_in" | "expired"; - idpMetadata?: OAuthMetadata; - }; -} - -export function authProtocolFromEnterpriseManaged( - enterpriseManaged?: boolean, -): AuthProtocol { - return enterpriseManaged ? "ema" : "standard"; -} - -/** In-memory snapshot while an OAuth flow is active or just completed. */ -export interface OAuthFlowState { - /** When auth reached step "complete" (ms since epoch), if applicable. */ - completedAt: number | null; - isInitiatingAuth: boolean; - oauthTokens: OAuthTokens | null; - oauthStep: OAuthStep; - resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: Error | null; - resource: URL | null; - authServerUrl: URL | null; - oauthMetadata: OAuthMetadata | null; - oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; - authorizationUrl: URL | null; - authorizationCode: string; - latestError: Error | null; - statusMessage: StatusMessage | null; - validationError: string | null; -} - -export const EMPTY_OAUTH_FLOW_STATE: OAuthFlowState = { - completedAt: null, - isInitiatingAuth: false, - oauthTokens: null, - oauthStep: "authorization_code", - oauthMetadata: null, - resourceMetadata: null, - resourceMetadataError: null, - resource: null, - authServerUrl: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, -}; - -// The parsed query parameters returned by the Authorization Server -// representing either a valid authorization_code or an error -// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2 -export type CallbackParams = - | { - successful: true; - // The authorization code is generated by the authorization server. - code: string; - // RFC 9207 `iss`. Present only when the authorization server includes it. - // Forwarded to the SDK, which validates it against the issuer in the - // validated metadata (RFC 9207 §2.4) to detect mix-up attacks. Servers - // advertising `authorization_response_iss_parameter_supported: true` MUST - // send it, and the SDK rejects the callback when it is missing. - iss?: string; - } - | { - successful: false; - // The OAuth 2.1 Error Code. - // Usually one of: - // ``` - // invalid_request, unauthorized_client, access_denied, unsupported_response_type, - // invalid_scope, server_error, temporarily_unavailable - // ``` - error: string; - // Human-readable ASCII text providing additional information, used to assist the - // developer in understanding the error that occurred. - error_description: string | null; - // A URI identifying a human-readable web page with information about the error, - // used to provide the client developer with additional information about the error. - error_uri: string | null; - }; diff --git a/packages/workbench/src/inspector/vendor/core/auth/utils.ts b/packages/workbench/src/inspector/vendor/core/auth/utils.ts deleted file mode 100644 index e4f587ab2..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/utils.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { UnauthorizedError } from "@modelcontextprotocol/client"; -import type { CallbackParams } from "./types.js"; -import { ZodError } from "zod"; - -type ZodIssueLike = { - path?: unknown[]; - message?: string; - code?: string; -}; - -function isZodIssueArray(value: unknown): value is ZodIssueLike[] { - return ( - Array.isArray(value) && - value.length > 0 && - typeof value[0] === "object" && - value[0] !== null && - "code" in value[0] - ); -} - -function formatZodIssues(issues: ZodIssueLike[]): string { - const tokenResponseIssue = issues.some( - (issue) => - Array.isArray(issue.path) && - (issue.path.includes("access_token") || - issue.path.includes("token_type")), - ); - if (tokenResponseIssue) { - return "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again."; - } - return issues - .map((issue) => { - const path = - Array.isArray(issue.path) && issue.path.length - ? issue.path.join(".") - : "input"; - return `${path}: ${issue.message ?? "invalid"}`; - }) - .join(" "); -} - -/** - * Human-readable detail for OAuth failure toasts/banners (never raw Zod JSON). - */ -export function formatOAuthFailureDetail(detail: unknown): string { - if (detail instanceof ZodError) { - return formatZodIssues(detail.issues); - } - const raw = - detail instanceof Error - ? detail.message - : typeof detail === "string" - ? detail - : String(detail); - const trimmed = raw.trim(); - if (trimmed.startsWith("[")) { - try { - const parsed: unknown = JSON.parse(trimmed); - if (isZodIssueArray(parsed)) { - return formatZodIssues(parsed); - } - } catch { - // fall through - } - } - return raw; -} - -/** - * Parse a string as an absolute URL. On failure, throws with `label` and the - * offending value so callers (and UI toasts) can show what to fix. - */ -export function parseHttpUrl(value: string, label: string): URL { - const trimmed = value.trim(); - try { - return new URL(trimmed); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`, { - cause: err, - }); - } -} - -/** - * Parses OAuth 2.1 callback parameters from a URL search string - * @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied") - * @returns Parsed callback parameters with success/error information - */ -export const parseOAuthCallbackParams = (location: string): CallbackParams => { - const params = new URLSearchParams(location); - - const code = params.get("code"); - if (code) { - const iss = params.get("iss"); - return iss === null - ? { successful: true, code } - : { successful: true, code, iss }; - } - - const error = params.get("error"); - const error_description = params.get("error_description"); - const error_uri = params.get("error_uri"); - - if (error) { - return { successful: false, error, error_description, error_uri }; - } - - return { - successful: false, - error: "invalid_request", - error_description: "Missing code or error in response", - error_uri: null, - }; -}; - -/** - * Generate a random state for the OAuth 2.0 flow. - * Works in both browser and Node.js environments. - * - * @returns A random state for the OAuth 2.0 flow. - */ -export const generateOAuthState = (): string => { - // OAuth state is a CSRF token — it MUST be unpredictable. crypto.getRandomValues - // is available in every supported runtime (browsers, Node ≥15); if it's somehow - // missing, fail loudly rather than silently degrading to Math.random (whose - // output is predictable from a small amount of observed state). - if (typeof crypto === "undefined" || !crypto.getRandomValues) { - throw new Error( - "crypto.getRandomValues is not available; refusing to generate an OAuth state with a non-cryptographic RNG.", - ); - } - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( - "", - ); -}; - -/** - * Parse OAuth `state` to extract the auth session id (CSRF token). - * Must be the 64-char hex value from {@link generateOAuthState}. - */ -export const parseOAuthState = (state: string): { authId: string } | null => { - if (!state || typeof state !== "string") return null; - if (/^[a-f0-9]{64}$/i.test(state)) { - return { authId: state }; - } - return null; -}; - -/** - * Generates a human-readable error description from OAuth callback error parameters - * @param params OAuth error callback parameters containing error details - * @returns Formatted multiline error message with error code, description, and optional URI - */ -export const generateOAuthErrorDescription = ( - params: Extract, -): string => { - const error = params.error; - const errorDescription = params.error_description; - const errorUri = params.error_uri; - - return [ - `Error: ${error}.`, - errorDescription ? `Details: ${errorDescription}.` : "", - errorUri ? `More info: ${errorUri}.` : "", - ] - .filter(Boolean) - .join("\n"); -}; - -/** - * True when a thrown connect error represents an upstream 401. The remote - * transport preserves the status on the error object; as a fallback, match - * transport wording `"failed …(401)"` so unrelated `(401)` in messages does - * not trigger OAuth. - * - * Under `protocolEra: auto|modern`, a negotiation-probe 401 can surface as - * `SdkError(EraNegotiationFailed)` with the real {@link UnauthorizedError} at - * `error.data.cause` (and sometimes native `error.cause`). Walk that chain so - * OAuth recovery still starts. - */ -export function isUnauthorizedError(err: unknown): boolean { - return isUnauthorizedErrorDeep(err, new Set()); -} - -function isUnauthorizedErrorDeep(err: unknown, seen: Set): boolean { - if (err == null) return false; - if (typeof err !== "object") { - return /\bfailed\b[^\n]*\(401\)/i.test(String(err)); - } - if (seen.has(err)) return false; - seen.add(err); - - if (UnauthorizedError.isInstance(err)) return true; - - const status = (err as { status?: number }).status; - const code = (err as { code?: unknown }).code; - if (status === 401 || code === 401) return true; - - if (err instanceof Error && /\bfailed\b[^\n]*\(401\)/i.test(err.message)) { - return true; - } - - if ( - "cause" in err && - isUnauthorizedErrorDeep((err as { cause: unknown }).cause, seen) - ) { - return true; - } - - const data = (err as { data?: unknown }).data; - if ( - data !== null && - typeof data === "object" && - "cause" in data && - isUnauthorizedErrorDeep((data as { cause: unknown }).cause, seen) - ) { - return true; - } - - return false; -} diff --git a/packages/workbench/src/inspector/vendor/core/client/types.ts b/packages/workbench/src/inspector/vendor/core/client/types.ts deleted file mode 100644 index ff02f3402..000000000 --- a/packages/workbench/src/inspector/vendor/core/client/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Install-level client configuration (IdP / EMA settings, later client identity). - * Persisted in ~/.mcp-inspector/storage/client.json via /api/storage/client. - */ - -/** OIDC client credentials for the enterprise IdP (legs 1–2). */ -export interface EnterpriseManagedAuthIdpConfig { - issuer: string; - clientId: string; - /** Present after keychain merge; omitted from on-disk client.json. */ - clientSecret?: string; -} - -/** Install-level CIMD (Client ID Metadata Document) settings. */ -export interface CimdConfig { - /** When false, the metadata URL is kept but CIMD is inactive install-wide. */ - enabled?: boolean; - clientMetadataUrl: string; -} - -export interface ClientConfig { - enterpriseManagedAuth?: { - /** When false, IdP credentials are kept but EMA is inactive install-wide. */ - enabled?: boolean; - idp: EnterpriseManagedAuthIdpConfig; - }; - cimd?: CimdConfig; -} - -/** True when install-level EMA IdP config is active (not just stored). */ -export function isEnterpriseManagedAuthEnabled(config: ClientConfig): boolean { - const ema = config.enterpriseManagedAuth; - if (!ema?.idp) return false; - return ema.enabled !== false; -} - -export function getActiveEnterpriseManagedAuthIdp( - config: ClientConfig, -): EnterpriseManagedAuthIdpConfig | undefined { - if (!isEnterpriseManagedAuthEnabled(config)) return undefined; - return config.enterpriseManagedAuth!.idp; -} - -/** True when install-level CIMD is active (not just stored). */ -export function isCimdEnabled(config: ClientConfig): boolean { - return config.cimd?.enabled === true; -} - -export function getActiveCimdClientMetadataUrl( - config: ClientConfig, -): string | undefined { - if (!isCimdEnabled(config)) return undefined; - const url = config.cimd?.clientMetadataUrl?.trim(); - return url || undefined; -} diff --git a/packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts b/packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts deleted file mode 100644 index 7366bb9cb..000000000 --- a/packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Tool } from "@modelcontextprotocol/client"; - -/** - * JSON value type used across the inspector project - */ -export type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -export type JsonObject = { [key: string]: JsonValue }; - -/** - * Widen a typed object to a generic string-keyed record so its keys can be - * iterated or read/written generically. Many of the project's config/SDK types - * (`StoredMCPServer`, `MCPServerConfig`, `pino.Logger`, DOM `Window`, …) have no - * index signature, so a direct `value as Record` at a call - * site is a TS2352 error that would otherwise force an `as unknown as` double - * cast. Taking the argument as the general `object` type makes the single `as` - * legal — `Record` is assignable to `object`, so the two types - * sufficiently overlap — letting this one audited spot own the widening while - * the double casts stay out of the call sites. Purely a structural view of the - * same object; no runtime effect. - */ -export function toRecord(value: object): Record { - return value as Record; -} - -/** - * Simple schema type for parameter conversion - */ -type ParameterSchema = { - type?: string; -}; - -/** - * Convert a string parameter value to the appropriate JSON type based on schema - */ -export function convertParameterValue( - value: string, - schema: ParameterSchema, -): JsonValue { - if (!value) { - return value; - } - - if (schema.type === "number" || schema.type === "integer") { - return Number(value); - } - - if (schema.type === "boolean") { - return value.toLowerCase() === "true"; - } - - if (schema.type === "object" || schema.type === "array") { - try { - return JSON.parse(value) as JsonValue; - } catch { - return value; - } - } - - return value; -} - -/** - * Convert string parameters to JSON values based on tool schema - */ -export function convertToolParameters( - tool: Tool, - params: Record, -): Record { - const result: Record = {}; - const properties = tool.inputSchema?.properties || {}; - - for (const [key, value] of Object.entries(params)) { - const paramSchema = properties[key] as ParameterSchema | undefined; - - if (paramSchema) { - result[key] = convertParameterValue(value, paramSchema); - } else { - result[key] = value; - } - } - - return result; -} - -/** - * Convert prompt arguments (JsonValue) to strings for prompt API - */ -export function convertPromptArguments( - args: Record, -): Record { - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } else if (value === null || value === undefined) { - stringArgs[key] = String(value); - } else { - stringArgs[key] = JSON.stringify(value); - } - } - return stringArgs; -} diff --git a/packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts b/packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts deleted file mode 100644 index 739eb6a2b..000000000 --- a/packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * SEP-2243 `x-mcp-header` annotation tooling. - * - * A modern (≥2026-07-28) Streamable HTTP server may annotate a tool - * `inputSchema` property with `x-mcp-header: "{Name}"`; a conforming client then - * mirrors that argument's value into an `Mcp-Param-{Name}` HTTP header on the - * `tools/call`. The spec places strict constraints on which properties may carry - * the annotation, and — crucially for a debugging tool — makes a *violating* - * annotation invalidate the WHOLE tool: a Streamable HTTP client MUST drop such - * a tool from `tools/list`. - * - * The client SDK enforces that exclusion internally (its `listTools()` silently - * filters invalid tools), but it does not surface *which* tools were dropped or - * *why*. This module re-implements the SDK's scan so the Inspector can show - * excluded tools with their reason, and indicate which args mirror to headers on - * the tools it keeps. It is a faithful port of the SDK's - * `scanXMcpHeaderDeclarations` (the helper is not part of the SDK's public - * surface), kept pure and fully unit-testable — no rendering, no I/O. - */ - -import type { Tool } from "@modelcontextprotocol/client"; - -/** The schema-extension property name a tool's `inputSchema` carries. */ -export const X_MCP_HEADER_KEY = "x-mcp-header"; - -/** The fixed prefix every mirrored custom-parameter header carries. */ -export const MCP_PARAM_HEADER_PREFIX = "Mcp-Param-"; - -/** - * RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control - * characters (including CR/LF), and the listed HTTP delimiters. - */ -const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - -/** - * JSON Schema `type` values the spec admits on an `x-mcp-header` property. - * - * The spec text names `integer`, `string`, `boolean` and explicitly excludes - * `number`. The published conformance referee at the pinned release ships its - * `http-custom-headers` scenario with `type: "number"` `x-mcp-header` params and - * expects the client to mirror them, so the SDK accepts `number` for the - * conformance gate; this port matches the SDK so exclusions agree exactly. - * Everything else (`object`, `array`, `null`, absent) is rejected. - */ -const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set([ - "string", - "integer", - "boolean", - "number", -]); - -/** - * JSON Schema keywords whose subschemas the static-reachability constraint - * excludes from the `properties`-only chain. An `x-mcp-header` found under any - * of these invalidates the tool definition. - */ -const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ - "items", - "prefixItems", - "contains", - "additionalProperties", - "unevaluatedProperties", - "unevaluatedItems", - "propertyNames", - "patternProperties", - "dependentSchemas", - "oneOf", - "anyOf", - "allOf", - "not", - "if", - "then", - "else", - "$defs", - "definitions", -] as const; - -/** - * Subschema-carrying keywords whose value is a `name → subschema` object (not a - * single subschema or array of subschemas). The visit branches over - * `Object.values()` for these. - */ -const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet = new Set([ - "patternProperties", - "dependentSchemas", - "$defs", - "definitions", -]); - -/** One validated `x-mcp-header` declaration found on a tool's input schema. */ -export interface XMcpHeaderDeclaration { - /** The chain of `properties` keys locating the annotated property. */ - path: string[]; - /** The declared header suffix — the `{Name}` in `Mcp-Param-{Name}`. */ - headerName: string; - /** The property's JSON Schema `type` (a permitted primitive). */ - type: string; -} - -/** The result of scanning a tool's input schema for `x-mcp-header` usage. */ -export type XMcpHeaderScan = - | { valid: true; declarations: XMcpHeaderDeclaration[] } - | { valid: false; reason: string }; - -function pathName(path: string[]): string { - return path.length === 0 ? "" : path.join("."); -} - -function isRecord(node: unknown): node is Record { - return node !== null && typeof node === "object"; -} - -/** - * Scan a tool's `inputSchema` for `x-mcp-header` declarations and validate every - * constraint the spec places on them. Returns the collected declarations - * (possibly empty) on success, or the first violated constraint's reason. - * - * The walk descends through `properties` at any depth (the spec's "any nesting - * depth" clause). The static-reachability MUST is enforced structurally: every - * position the chain MUST NOT pass through (`items`/`additionalProperties`, - * `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, and `$defs`/`definitions` - * bodies) is visited too, and an `x-mcp-header` found anywhere off the - * `properties` chain invalidates the schema — "an annotation anywhere else makes - * the tool definition invalid". `$ref` is never followed: a property reachable - * only through a `$ref` is therefore correctly treated as non-statically- - * reachable (its annotation, if any, lives in the unreachable `$defs` body). - */ -export function scanXMcpHeaderDeclarations( - inputSchema: unknown, -): XMcpHeaderScan { - const declarations: XMcpHeaderDeclaration[] = []; - const seenLower = new Map(); - - const visit = ( - node: unknown, - path: string[], - reachable: boolean, - ): string | undefined => { - if (!isRecord(node)) return undefined; - const schema = node; - - if (X_MCP_HEADER_KEY in schema) { - if (!reachable || path.length === 0) { - return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`; - } - const raw = schema[X_MCP_HEADER_KEY]; - if (typeof raw !== "string" || raw.length === 0) { - return `${pathName(path)}: x-mcp-header MUST be a non-empty string`; - } - if (!RFC9110_TOKEN.test(raw)) { - return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`; - } - const type = typeof schema.type === "string" ? schema.type : undefined; - if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) { - return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ""}`; - } - const lower = raw.toLowerCase(); - const prior = seenLower.get(lower); - if (prior !== undefined) { - return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`; - } - seenLower.set(lower, raw); - declarations.push({ path, headerName: raw, type }); - } - - const properties = schema.properties; - if (isRecord(properties)) { - for (const [key, child] of Object.entries(properties)) { - const fault = visit(child, [...path, key], reachable); - if (fault !== undefined) return fault; - } - } - - for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) { - const sub = schema[k]; - if (sub === undefined) continue; - const branches = Array.isArray(sub) - ? sub - : isRecord(sub) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k) - ? Object.values(sub) - : [sub]; - for (const branch of branches) { - const fault = visit(branch, [...path, `<${k}>`], false); - if (fault !== undefined) return fault; - } - } - - return undefined; - }; - - const fault = visit(inputSchema, [], true); - return fault === undefined - ? { valid: true, declarations } - : { valid: false, reason: fault }; -} - -/** - * The `=?base64?…?=` sentinel wrapping a value that cannot be sent as a plain - * ASCII HTTP field value (SEP-2243 value-encoding rules). - */ -const BASE64_SENTINEL_PREFIX = "=?base64?"; -const BASE64_SENTINEL_SUFFIX = "?="; - -/** - * Convert a primitive argument value to its string form per the spec's - * type-conversion rules: strings pass through, booleans become lowercase - * `'true'`/`'false'`, integers/numbers become their decimal string. Non-finite - * numbers and integers outside the safe range are refused (returns `undefined`, - * meaning "do not emit a header for this value"). Anything non-primitive - * (object/array/null/undefined) also yields `undefined`. - */ -function mcpParamPrimitiveToString(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") { - if (!Number.isFinite(value)) return undefined; - if (Number.isInteger(value) && !Number.isSafeInteger(value)) { - return undefined; - } - return String(value); - } - return undefined; -} - -/** - * `true` when `s` cannot be safely represented as a plain ASCII HTTP field - * value (RFC 9110 §5.5): it is empty, contains a byte outside `0x20–0x7E`/`0x09`, - * has leading/trailing whitespace (which field parsing strips), or already - * matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" rule). - */ -function needsBase64(s: string): boolean { - if (s.length === 0) return true; - if ( - s.startsWith(BASE64_SENTINEL_PREFIX) && - s.endsWith(BASE64_SENTINEL_SUFFIX) - ) { - return true; - } - if (s !== s.trim()) return true; - for (let i = 0; i < s.length; i++) { - // Non-null: `i` is always in bounds, so `codePointAt` returns a number. - const c = s.codePointAt(i)!; - if (c === 9 || (c >= 32 && c <= 126)) continue; - return true; - } - return false; -} - -function utf8ToBase64(s: string): string { - const bytes = new TextEncoder().encode(s); - let bin = ""; - for (const b of bytes) bin += String.fromCodePoint(b); - return btoa(bin); -} - -/** - * Encode a string value as an HTTP field value per SEP-2243: a value that is - * already a safe plain-ASCII field value passes through unchanged; anything - * else is wrapped as `=?base64?{b64-of-utf8}?=`. - */ -function encodeMcpParamValue(value: string): string { - return needsBase64(value) - ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` - : value; -} - -function valueAtPath(root: unknown, path: string[]): unknown { - let node: unknown = root; - for (const key of path) { - if (node === null || typeof node !== "object") return undefined; - node = (node as Record)[key]; - } - return node; -} - -/** - * Build the `Mcp-Param-{Name}` headers for one `tools/call` from validated - * `x-mcp-header` declarations and the call's `arguments`. A declaration whose - * value is `null` or absent is omitted (the spec's "client MUST omit the header" - * rows); a value that is not a primitive of the declared kind is omitted rather - * than emitted malformed. Faithful port of the SDK's internal helper (not part - * of its public surface), so the headers match what a conforming client sends. - */ -export function buildMcpParamHeaders( - declarations: XMcpHeaderDeclaration[], - args: Record, -): Record { - const out: Record = {}; - for (const decl of declarations) { - const raw = valueAtPath(args, decl.path); - if (raw === undefined || raw === null) continue; - const stringValue = mcpParamPrimitiveToString(raw); - if (stringValue === undefined) continue; - out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = - encodeMcpParamValue(stringValue); - } - return out; -} - -/** - * SEP-2243 `Mcp-Param-*` headers a `tools/call` must carry for a given tool and - * arguments. Returns `{}` when the tool declares no `x-mcp-header`, when its - * annotations are invalid (such a tool is excluded from `tools/list`), or when - * no declared argument has a mirrorable value. Callers attach the result to the - * `tools/call` request headers on a modern connection. - */ -export function mcpParamHeadersForTool( - tool: Tool, - args: Record, -): Record { - const scan = scanXMcpHeaderDeclarations(tool.inputSchema); - if (!scan.valid || scan.declarations.length === 0) return {}; - return buildMcpParamHeaders(scan.declarations, args); -} - -/** A tool the Inspector keeps, paired with its mirrored-header declarations. */ -export interface MirroredHeaderParam { - /** Dot-joined property path (e.g. `region` or `filter.city`). */ - path: string; - /** The full header a conforming client sends: `Mcp-Param-{Name}`. */ - header: string; - /** The declared header suffix. */ - headerName: string; - /** The property's JSON Schema primitive type. */ - type: string; -} - -/** - * The mirrored-header params for a tool the Inspector kept (its annotations are - * all valid). Returns `[]` when the tool declares no `x-mcp-header`, and — since - * a caller only reaches here for *kept* tools — also `[]` for the (unreachable - * for kept tools) invalid case. Each entry names the arg and the - * `Mcp-Param-{Name}` header its value mirrors to on a `tools/call`. - */ -export function getMirroredHeaderParams(tool: Tool): MirroredHeaderParam[] { - const scan = scanXMcpHeaderDeclarations(tool.inputSchema); - if (!scan.valid) return []; - return scan.declarations.map((d) => ({ - path: d.path.join("."), - header: `${MCP_PARAM_HEADER_PREFIX}${d.headerName}`, - headerName: d.headerName, - type: d.type, - })); -} diff --git a/packages/workbench/src/inspector/vendor/core/logging/logger.ts b/packages/workbench/src/inspector/vendor/core/logging/logger.ts deleted file mode 100644 index e95ac93cc..000000000 --- a/packages/workbench/src/inspector/vendor/core/logging/logger.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Bindings, LevelWithSilentOrString, LogFn } from "pino"; - -/** - * Logging surface InspectorClient uses. Real pino loggers satisfy this; - * the default silent logger implements it without opening a stream. - */ -export interface InspectorLogger { - level: LevelWithSilentOrString; - fatal: LogFn; - error: LogFn; - warn: LogFn; - info: LogFn; - debug: LogFn; - trace: LogFn; - silent: LogFn; - child(bindings?: Bindings): InspectorLogger; -} - -const noop: LogFn = () => {}; - -function createSilentLogger(): InspectorLogger { - const logger: InspectorLogger = { - level: "silent", - fatal: noop, - error: noop, - warn: noop, - info: noop, - debug: noop, - trace: noop, - silent: noop, - child: () => logger, - }; - return logger; -} - -/** - * Default logger when none is injected. No-op at all levels; no SonicBoom stream. - */ -export const silentLogger: InspectorLogger = createSilentLogger(); diff --git a/packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts b/packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts deleted file mode 100644 index 7e1e58648..000000000 --- a/packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts +++ /dev/null @@ -1,411 +0,0 @@ -import type { FetchRequestEntryBase } from "./types.js"; - -/** - * Header names whose values are replaced with `REDACTED_HEADER_VALUE` before a - * fetch entry is recorded. The recorded entry flows to the in-memory log, the - * pino logger, and (via session storage) to disk — none of those sinks should - * ever see a live bearer token or session cookie. - */ -const SENSITIVE_HEADERS: ReadonlySet = new Set([ - "authorization", - "cookie", - "set-cookie", - "proxy-authorization", - "x-api-key", - // The inspector backend's own bearer (createRemoteFetch stamps this on every - // proxied request); same exposure as Authorization. - "x-mcp-remote-auth", -]); - -/** Placeholder substituted for sensitive header values in recorded entries. */ -export const REDACTED_HEADER_VALUE = "[REDACTED]"; - -/** - * Field / query-parameter names whose values are masked in a recorded fetch - * entry's request body, response body, and URL query string. These are the - * credentials that ride in OAuth token exchanges (and similar flows): the - * header slice masks `Authorization`, but the same secrets show up verbatim in - * the form/JSON body (`client_secret`, `code`, `refresh_token`, …) and are - * sometimes carried as URL query params. Matching is case-insensitive. - */ -const SENSITIVE_BODY_FIELDS: ReadonlySet = new Set([ - "client_secret", - "code", - "refresh_token", - "access_token", - "id_token", - "code_verifier", - "client_assertion", - "assertion", - "password", - "token", -]); - -/** - * Placeholder substituted for sensitive body / URL values in recorded entries. - * Deliberately kept separate from {@link REDACTED_HEADER_VALUE} (even though both - * are `"[REDACTED]"` today) so the header and body/URL redaction paths can evolve - * their sentinels independently. - */ -export const REDACTED_VALUE = "[REDACTED]"; - -/** Whether `name` (any casing) is a known-sensitive field / query-param name. */ -function isSensitiveField(name: string): boolean { - return SENSITIVE_BODY_FIELDS.has(name.toLowerCase()); -} - -/** - * Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value - * replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive - * (HTTP header names are case-insensitive); the original casing of every key is - * preserved so the recorded entry still shows what the client actually sent. - */ -export function redactSensitiveHeaders( - headers: Record, -): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(headers)) { - out[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) - ? REDACTED_HEADER_VALUE - : value; - } - return out; -} - -/** - * Returns `url` with every {@link SENSITIVE_BODY_FIELDS} query-parameter value - * replaced by {@link REDACTED_VALUE}. The path and non-sensitive params stay - * readable. Best-effort: if the URL (or its query string) can't be parsed the - * original string is returned unchanged. Only the recorded copy is redacted — - * the live request still uses the original `input`/`init`. - */ -export function redactUrlQuery(url: string): string { - const queryStart = url.indexOf("?"); - if (queryStart === -1) return url; - - const base = url.slice(0, queryStart); - const afterQuery = url.slice(queryStart + 1); - // Preserve a trailing fragment (#…) untouched — it never carries query params. - const hashStart = afterQuery.indexOf("#"); - const query = hashStart === -1 ? afterQuery : afterQuery.slice(0, hashStart); - const fragment = hashStart === -1 ? "" : afterQuery.slice(hashStart); - - try { - const params = new URLSearchParams(query); - let changed = false; - for (const key of new Set(params.keys())) { - if (isSensitiveField(key)) { - changed = true; - // Collapse repeated occurrences to a single redacted value. - params.set(key, REDACTED_VALUE); - } - } - if (!changed) return url; - return `${base}?${params.toString()}${fragment}`; - } catch { - return url; - } -} - -/** Recursively redact sensitive keys in a parsed JSON value (in place). */ -function redactJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(redactJsonValue); - } - if (value !== null && typeof value === "object") { - const out: Record = {}; - for (const [key, val] of Object.entries(value)) { - // Only STRING values of a sensitive-named field are masked. The secrets - // this targets (OAuth `code`, `access_token`, `client_secret`, …) are - // always strings; a non-string is never one of them. This is important - // for JSON-RPC bodies, whose numeric `error.code` (e.g. -32020) collides - // with the OAuth authorization-`code` name — masking it would destroy the - // very field the Network tab classifies the modern spec errors on. - // - // Assumes a sensitive value is a scalar or an object (recursed, so an inner - // sensitive string key is still masked). A sensitive key whose value is an - // *array of scalars* (e.g. `{ password: ["a", "b"] }`) would recurse - // element-by-element with no key context and slip through — no OAuth/token - // payload has that shape, so it is not handled. - out[key] = - isSensitiveField(key) && typeof val === "string" - ? REDACTED_VALUE - : redactJsonValue(val); - } - return out; - } - return value; -} - -/** - * Returns `body` with every {@link SENSITIVE_BODY_FIELDS} value masked, for - * `application/x-www-form-urlencoded` and JSON payloads. The surrounding shape - * (field order, non-sensitive fields, JSON structure) is preserved — only the - * values change. Best-effort and never throws: an empty, non-string, or - * unparseable body is returned unchanged. Only the recorded copy is redacted; - * the live request body is never touched. - * - * Scope is deliberately limited to `application/x-www-form-urlencoded` and JSON: - * these cover the OAuth token flows this redaction targets. `multipart/form-data` - * (and other binary/opaque bodies) are passed through verbatim — OAuth never uses - * multipart, so the risk is low; revisit if a multipart secret path appears. - */ -export function redactBody( - body: string | undefined, - contentType: string | null | undefined, -): string | undefined { - if (!body) return body; - - const type = (contentType ?? "").toLowerCase(); - - // Form-encoded bodies (the OAuth token endpoint's request format). - if (type.includes("application/x-www-form-urlencoded")) { - try { - const params = new URLSearchParams(body); - let changed = false; - for (const key of new Set(params.keys())) { - if (isSensitiveField(key)) { - changed = true; - params.set(key, REDACTED_VALUE); - } - } - return changed ? params.toString() : body; - } catch { - return body; - } - } - - // JSON bodies — either explicitly typed, or (when the content-type is - // missing/other) any string that parses as a JSON object/array. A bare - // JSON scalar has no field names, so it can't carry a sensitive key. - try { - const parsed: unknown = JSON.parse(body); - if (parsed !== null && typeof parsed === "object") { - return JSON.stringify(redactJsonValue(parsed)); - } - } catch { - // Not JSON — fall through and leave as-is. - } - - return body; -} - -/** Case-insensitive lookup of a header value from a plain header record. */ -function findHeader( - headers: Record, - name: string, -): string | undefined { - const target = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === target) return value; - } - return undefined; -} - -/** - * Whether a response represents an unbounded (long-lived) HTTP stream - * whose body cannot be cloned + read to completion. The streamable HTTP - * spec uses `GET` + `text/event-stream` for the long-lived server-push - * channel; `POST` SSE replies are bounded (server closes after the - * JSON-RPC response) and therefore safe to capture. Shared between the - * fetch tracker (where it decides whether to read the body) and the - * Network UI (where it decides which placeholder to show). - */ -export function isLongLivedStreamResponse( - method: string, - contentType: string | null | undefined, -): boolean { - if (method !== "GET") return false; - if (!contentType) return false; - return ( - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson") - ); -} - -export interface FetchTrackingCallbacks { - trackRequest?: (entry: FetchRequestEntryBase) => void; - /** - * Called after the response body has been read asynchronously. Lets the - * consumer patch the already-dispatched entry with the body without - * blocking the transport on body reading. Fires only on success — if the - * body couldn't be read (long-lived stream, clone failure), this is - * never invoked and the entry's responseBody stays undefined. - */ - updateResponseBody?: (id: string, responseBody: string) => void; -} - -/** - * Creates a fetch wrapper that tracks HTTP requests and responses - */ -export function createFetchTracker( - baseFetch: typeof fetch, - callbacks: FetchTrackingCallbacks, -): typeof fetch { - return async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - const startTime = Date.now(); - const timestamp = new Date(); - const id = `${timestamp.getTime()}-${Math.random().toString(36).slice(2, 11)}`; - - // Extract request information - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - const method = init?.method || "GET"; - - // Extract headers, redacting sensitive values BEFORE they reach any - // downstream sink (logger, in-memory list, persisted session storage). - const rawRequestHeaders: Record = {}; - if (input instanceof Request) { - input.headers.forEach((value, key) => { - rawRequestHeaders[key] = value; - }); - } - if (init?.headers) { - const headers = new Headers(init.headers); - headers.forEach((value, key) => { - rawRequestHeaders[key] = value; - }); - } - const requestHeaders = redactSensitiveHeaders(rawRequestHeaders); - const requestContentType = findHeader(rawRequestHeaders, "content-type"); - - // Redact sensitive query params in the recorded URL (live `input` is - // untouched — only this logged copy is masked). - const redactedUrl = redactUrlQuery(url); - - // Extract body (if present and readable) - let requestBody: string | undefined; - if (init?.body) { - if (typeof init.body === "string") { - requestBody = init.body; - } else { - // Try to convert to string, but skip if it fails (e.g., ReadableStream) - try { - requestBody = String(init.body); - } catch { - requestBody = undefined; - } - } - } else if (input instanceof Request && input.body) { - // Try to clone and read the request body - // Clone protects the original body from being consumed - try { - const cloned = input.clone(); - requestBody = await cloned.text(); - } catch { - // Can't read body (might be consumed, not readable, or other issue) - requestBody = undefined; - } - } - - // Redact sensitive fields in the recorded request body. The live request - // body (`init.body` / `input`) is never touched — only this logged string. - const redactedRequestBody = redactBody(requestBody, requestContentType); - - // Make the actual fetch request - let response: Response; - let error: string | undefined; - try { - response = await baseFetch(input, init); - } catch (err) { - error = err instanceof Error ? err.message : String(err); - // Create a minimal error entry - const entry: FetchRequestEntryBase = { - id, - timestamp, - method, - url: redactedUrl, - requestHeaders, - requestBody: redactedRequestBody, - error, - duration: Date.now() - startTime, - }; - callbacks.trackRequest?.(entry); - throw err; - } - - // Extract response information - const responseStatus = response.status; - const responseStatusText = response.statusText; - - // Extract response headers (redacted — Set-Cookie etc. are credentials too) - const rawResponseHeaders: Record = {}; - response.headers.forEach((value, key) => { - rawResponseHeaders[key] = value; - }); - const responseHeaders = redactSensitiveHeaders(rawResponseHeaders); - - // Skip body reading only for *long-lived* streams. On streamable HTTP, - // GET /mcp opens an unbounded SSE channel for server-to-client pushes - // — calling `.text()` on a clone of that would buffer forever. POST - // responses with the same content-type are bounded: the server emits - // the JSON-RPC reply (sometimes preceded by progress events) and - // closes the connection, so cloning + reading is safe and gives the - // user the raw SSE payload they were missing. - const isLongLivedStream = isLongLivedStreamResponse( - method, - response.headers.get("content-type"), - ); - - const duration = Date.now() - startTime; - - // Create entry and track it immediately. The body is read asynchronously - // below to avoid blocking the transport — for streaming responses (POST - // + SSE), the server keeps the connection open until it has delivered - // every progress notification plus the final reply, so awaiting - // `.text()` here would force the transport to wait for all events - // before it could process any of them. - const entry: FetchRequestEntryBase = { - id, - timestamp, - method, - url: redactedUrl, - requestHeaders, - requestBody: redactedRequestBody, - responseStatus, - responseStatusText, - responseHeaders, - responseBody: undefined, - duration, - }; - - callbacks.trackRequest?.(entry); - - // Kick off a fire-and-forget read of the cloned body. The clone is an - // independent tee'd stream so the transport keeps consuming the - // original at its own pace. When the read resolves we patch the entry - // via `updateResponseBody`. Skipped for long-lived streams (GET + - // SSE / ndjson) because `.text()` would never resolve on those. - if (!isLongLivedStream && response.body && !response.bodyUsed) { - const responseContentType = response.headers.get("content-type"); - try { - const cloned = response.clone(); - cloned - .text() - .then((body) => { - // Mask token-endpoint secrets (access_token, refresh_token, …) - // before the body reaches any sink. - callbacks.updateResponseBody?.( - id, - redactBody(body, responseContentType) ?? body, - ); - }) - .catch(() => { - // Stream errored after clone — leave the body undefined. - }); - } catch { - // Clone failed (consumed body, transport quirks). Leave body - // undefined; the entry is already dispatched. - } - } - - return response; - }; -} diff --git a/packages/workbench/src/inspector/vendor/core/mcp/types.ts b/packages/workbench/src/inspector/vendor/core/mcp/types.ts deleted file mode 100644 index 307f3abdd..000000000 --- a/packages/workbench/src/inspector/vendor/core/mcp/types.ts +++ /dev/null @@ -1,1062 +0,0 @@ -import type { - CallToolResult, - ClientNotification, - ClientRequest, - GetPromptResult, - Implementation, - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResultResponse, - LoggingLevel, - Prompt, - ReadResourceResult, - Resource, - Root, - ServerCapabilities, - ServerNotification, - ServerRequest, - Tool, - VersionNegotiationOptions, -} from "@modelcontextprotocol/client"; -import type { Client } from "@modelcontextprotocol/client"; -import type { OAuthClientProvider } from "@modelcontextprotocol/client"; -import type { Transport } from "@modelcontextprotocol/client"; -import type { InspectorLogger } from "../logging/logger.js"; -import type { JsonValue } from "../json/jsonUtils.js"; -import type { - ClientConfig, - EnterpriseManagedAuthIdpConfig, -} from "../client/types.js"; -import type { - OAuthNavigation, - RedirectUrlProvider, -} from "../auth/providers.js"; -import type { OAuthStorage } from "../auth/storage.js"; - -// Stdio transport config -export interface StdioServerConfig { - // Optional: stdio is the implicit default when `type` is absent. A - // narrowing `switch (config.type)` must therefore cover the `undefined` - // branch as `StdioServerConfig`. - type?: "stdio"; - command: string; - args?: string[]; - env?: Record; - cwd?: string; -} - -// StreamableHTTP transport config -export interface StreamableHttpServerConfig { - type: "streamable-http"; - url: string; - requestInit?: Record; -} - -export type MCPServerConfig = - | StdioServerConfig - | StreamableHttpServerConfig; - -export type ServerType = "stdio" | "streamable-http"; - -/** - * On-disk shape for a single `mcp.json` server entry (post-#1358). The base - * is the SDK-compatible `MCPServerConfig`; each Inspector-specific extension - * field lives directly alongside `type` / `url` / `command` rather than - * under a nested `settings` wrapper. This matches the shape Claude Code / - * Cursor / Cline write to their own `.mcp.json` files (`headers` as a - * `Record`, `oauth` as a nested object), so a hand-edited - * file from any of those tools is readable on Inspector's first connect. - * - * The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings` - * keeps its pair-array `headers` and flat `oauth*` fields because the form - * needs them in that shape to drive controlled-component editing. The - * conversion between disk-flat and memory-pair-array lives in - * `serverList.ts` (`mcpConfigToServerEntries` / - * `serverEntriesToMcpConfig`) and the `/api/servers` route's - * `buildStoredEntry`. - * - * Files written by the pre-#1358 build (one #1352 release of v2/main that - * never shipped a stable tag) had a nested `settings` block here; that - * shape is dropped on read with a warn and not re-emitted on next write. - */ -export type StoredMCPServer = MCPServerConfig & { - /** - * HTTP headers for Streamable HTTP transports. Persisted as a flat - * `Record` matching the Claude Code / Cursor / Cline - * `.mcp.json` convention. Lifted into `InspectorServerSettings.headers` - * (pair-array form) when read into memory. - */ - headers?: Record; - /** - * Default `_meta` keys merged into every outgoing MCP request. Inspector- - * specific (no analog in the broader mcp.json ecosystem), so the pair-array - * shape is preserved on disk and in memory. - */ - metadata?: { key: string; value: string }[]; - /** - * Protocol era to negotiate with this server (`"legacy" | "auto" | "modern"`), - * orthogonal to the transport `type`. Inspector-specific (no analog in the - * broader mcp.json ecosystem). Omitted on disk when it equals the default - * (`"legacy"`). (#1626) - */ - protocolEra?: ServerProtocolEra; - /** - * Modern-era per-request log level stamped by default (`"off"` or one of the - * eight logging levels). Inspector-specific. Omitted on disk when it equals - * `DEFAULT_MODERN_LOG_LEVEL` (`"debug"`). Only affects modern connections. - * (#1629) - */ - modernLogLevel?: ModernLogLevel; - /** Inspector-specific connect-time timeout (ms). */ - connectionTimeout?: number; - /** Inspector-specific request timeout (ms). */ - requestTimeout?: number; - /** Inspector-specific TTL (ms) for tasks created via "Run as task". */ - taskTtl?: number; - /** - * When true, the managed list state auto-refreshes on `list_changed` - * notifications instead of only flagging the list-changed indicator and - * waiting for the user to pull. Inspector-specific. Omitted on disk when - * false (the default). (#1402) - */ - autoRefreshOnListChanged?: boolean; - /** - * When true, the tools/resources/prompts lists are fetched one page at a time - * (a manual "Load next page" control surfaces the server's `nextCursor`) - * instead of auto-aggregating every page on load. A defensive default for - * servers with very large lists. Inspector-specific. Omitted on disk when - * false (the default). (#1721) - */ - paginatedLists?: boolean; - /** - * Per-extension overrides for which extensions the Inspector advertises to - * this server (keyed by extension id; a present key wins over the registry - * default). Inspector-specific. Omitted on disk when empty, keeping the file - * diff minimal for servers that never toggled one. (#1739) - */ - advertisedExtensions?: Record; - /** - * Maximum number of HTTP fetch requests retained in the Network log for this - * server (oldest rotate out past the cap). Inspector-specific. Omitted on - * disk when it equals `DEFAULT_MAX_FETCH_REQUESTS` (the default), keeping the - * file diff minimal for servers that never tuned it. `0` means unlimited. - */ - maxFetchRequests?: number; - /** - * Pre-configured OAuth client credentials for HTTP transports. Nested to - * match Claude Code's `.mcp.json` shape; lifted into the flat `oauthClientId` - * / `oauthClientSecret` / `oauthScopes` fields on `InspectorServerSettings` - * when read into memory. - */ - oauth?: { - clientId?: string; - clientSecret?: string; - scopes?: string; - /** When true, connect via enterprise IdP (EMA) instead of standard resource OAuth. */ - enterpriseManaged?: boolean; - /** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */ - onInsufficientScope?: OnInsufficientScopePolicy; - }; - /** - * Filesystem/URI roots advertised to the server via the `roots` client - * capability. Inspector-specific (no analog in the broader mcp.json - * ecosystem). Each root is the SDK `Root` shape `{ uri, name? }`; unlike v1 - * (URI-only), the optional `name` round-trips here. Persisted as-is on disk - * and lifted onto `InspectorServerSettings.roots` when read into memory. - */ - roots?: Root[]; -}; - -export interface MCPConfig { - mcpServers: Record; -} - -export type ConnectionStatus = - | "disconnected" - | "connecting" - | "connected" - | "error"; - -/** - * True when a connection has settled into a non-live terminal state — either a - * clean `"disconnected"` or a crashed `"error"`. Both mean the session is over - * and any cached server state (tool/resource/prompt lists, message log, - * subscriptions) should be torn down. - * - * Session-teardown consumers must key off this predicate rather than branching - * on `status === "disconnected"` alone: on a real mid-session crash many SDK - * transports fire BOTH `onclose` and `onerror` in a transport-dependent order, - * and the canonical terminal status is now `"error"` regardless of ordering - * (see InspectorClient's `onclose` handler, #1490). A bare `=== "disconnected"` - * check would therefore tear down in one ordering but not the other. - */ -export function isTerminalStatus( - status: ConnectionStatus | undefined, -): boolean { - return status === "disconnected" || status === "error"; -} - -/** - * Snapshot of a server's connection state, used by dumb components - * that display status, retry count, and error details. - */ -export interface ConnectionState { - status: ConnectionStatus; - retryCount?: number; - error?: { message: string; details?: string }; - /** - * MCP protocol version negotiated with the server during initialize - * (e.g. "2025-06-18"). Only present once connected; surfaced in the - * ServerCard transport row. Populated when #1324 plumbs the value - * through `useInspectorClient`. - */ - protocolVersion?: string; -} - -export interface ServerEntry { - /** Stable unique identifier — the MCPConfig.mcpServers map key. */ - id: string; - /** Display label shown in the card header. May or may not equal id. */ - name: string; - config: MCPServerConfig; - /** - * Optional per-server runtime settings (headers, metadata, timeouts, OAuth - * credentials). On disk these live as direct keys on the entry (post-#1358); - * in memory they're grouped here in the pair-array / flat-OAuth shape the - * form needs for controlled-component editing. Edited via ServerSettingsForm; - * consumed by the transport / InspectorClient at connect time. - */ - settings?: InspectorServerSettings; - info?: Implementation; - connection: ConnectionState; -} - -export interface StderrLogEntry { - timestamp: Date; - message: string; -} - -/** Who sent a tracked message: the inspector ("client") or the "server". */ -export type MessageOrigin = "client" | "server"; - -/** - * How a pending sampling/elicitation request reached the Inspector, so the - * pending-request UI can show era-accurate semantics: - * - `"server-request"` — a legacy (≤2025-11-25) server→client JSON-RPC request - * (`sampling/createMessage` / `elicitation/create`) delivered to our handler. - * - `"input-required"` — a modern (2026-07-28) MRTR round: the request was - * embedded in a tool-call/prompt/resource `input_required` result, and the - * user's answer is echoed back to the server as a retry (SEP-2322). - * - `"task-input-required"` — a modern task (SEP-2663) that reached - * `input_required`: the request came from the task's `tasks/get` `inputRequests` - * map, and the user's answer is submitted via a `tasks/update` request (NOT a - * retry of the original call, unlike MRTR). - */ -export type PendingRequestOrigin = - | "server-request" - | "input-required" - | "task-input-required"; - -export interface MessageEntry { - id: string; - timestamp: Date; - direction: "request" | "response" | "notification"; - /** - * Who sent the message — drives the History direction badge (client → server - * vs client ← server). Set at tracking time: outgoing (transport `send`) is - * "client", incoming (`onmessage`) is "server". Optional for back-compat with - * older logs and test fixtures that predate it. - */ - origin?: MessageOrigin; - message: - | JSONRPCRequest - | JSONRPCNotification - | JSONRPCResultResponse - | JSONRPCErrorResponse; - response?: JSONRPCResultResponse | JSONRPCErrorResponse; - duration?: number; // Time between request and response in ms - /** - * Why the CLIENT rejected an otherwise well-formed response — e.g. the SDK's - * era codec refusing a 2026-07-28 `tools/list` result that omits - * `ttlMs`/`cacheScope`. Distinct from a JSON-RPC `error` response: the server - * answered successfully and the wire frame is valid, so without this the - * entry renders as a clean success even though the call failed (#1953). - */ - clientError?: string; -} - -/** Method name for any MessageEntry traffic, plus synthetic "response" for result/error entries. */ -export type MessageMethod = - | ClientRequest["method"] - | ClientNotification["method"] - | ServerRequest["method"] - | ServerNotification["method"] - | "response"; - -export type FetchRequestCategory = "auth" | "transport"; - -export interface FetchRequestEntry { - id: string; - timestamp: Date; - method: string; - url: string; - requestHeaders: Record; - requestBody?: string; - responseStatus?: number; - responseStatusText?: string; - responseHeaders?: Record; - responseBody?: string; - duration?: number; // Time between request and response in ms - error?: string; - /** Distinguishes OAuth/auth fetches from MCP transport fetches */ - category: FetchRequestCategory; -} - -/** Entry shape from createFetchTracker before category is added by the caller */ -export type FetchRequestEntryBase = Omit; - -export interface ServerState { - status: ConnectionStatus; - error: string | null; - capabilities?: ServerCapabilities; - serverInfo?: Implementation; - instructions?: string; - resources: Resource[]; - prompts: Prompt[]; - tools: Tool[]; - stderrLogs: StderrLogEntry[]; -} - -/** - * Represents a complete resource read invocation, including request parameters, - * response, and metadata. - */ -export interface ResourceReadInvocation { - result: ReadResourceResult; - timestamp: Date; - uri: string; - metadata?: Record; -} - -/** - * Represents a complete resource template read invocation, including request parameters, - * response, and metadata. - */ -export interface ResourceTemplateReadInvocation { - uriTemplate: string; - expandedUri: string; - result: ReadResourceResult; - timestamp: Date; - params: Record; - metadata?: Record; -} - -/** - * Represents a complete prompt get invocation, including request parameters, - * response, and metadata. - */ -export interface PromptGetInvocation { - result: GetPromptResult; - timestamp: Date; - name: string; - params?: Record; - metadata?: Record; -} - -/** - * Represents a complete tool call invocation, including request parameters, - * response, and metadata. - */ -export interface ToolCallInvocation { - toolName: string; - params: Record; - result: CallToolResult | null; - timestamp: Date; - success: boolean; - error?: string; - metadata?: Record; - /** - * Set only on the `skipOutputValidation` path: present when the (delivered) - * result's structuredContent does NOT match the tool's declared outputSchema. - * The call still succeeds and the result is returned — this is a non-fatal - * advisory so callers can warn that strict MCP clients would reject the - * payload (and the app may not render in them). - */ - outputValidationError?: string; -} - -// v2-only wrapper types (no v1.5 equivalent) - -/** - * Resource subscription wrapper used by the Resources screen to track - * subscribed resources and the time of the last update notification. - */ -export interface InspectorResourceSubscription { - resource: Resource; - lastUpdated?: Date; -} - -/** - * Lifecycle status of the single modern-era `subscriptions/listen` stream that - * backs every resource subscription on a 2026-07-28 server (#1630). - * - * - `"connecting"` — a `listen()` request is in flight and hasn't been - * acknowledged yet (the optimistic state shown the moment the user subscribes, - * so the UI responds to the click without waiting for the ack round-trip). - * - `"acknowledged"` — the `listen()` request resolved and the server sent - * `notifications/subscriptions/acknowledged`; the stream is open and carrying - * updates. - * - `"reconnecting"` — the stream dropped unexpectedly (`closed` resolved - * `"remote"`) and a re-listen is in flight (reconnect-by-re-listen; there is - * no resumability, so the re-listen re-establishes the full filter). - * - `"ended"` — the server tore the stream down deliberately (`closed` resolved - * `"graceful"`, e.g. on shutdown) or reconnection was abandoned; no automatic - * re-listen. - */ -export type ResourceSubscriptionStreamStatus = - | "connecting" - | "acknowledged" - | "reconnecting" - | "ended"; - -/** - * State of the modern-era resource-subscription listen stream (#1630). - * - * On the legacy era each `resources/subscribe` is an independent request with no - * persistent stream, so `active` is `false` and the UI surfaces no stream chrome. - * On the modern era all subscriptions are a filter over one long-lived - * `subscriptions/listen` stream; `active` is `true` whenever that stream is being - * managed *for resource subscriptions* (i.e. at least one URI is subscribed), and - * `honoredUris` is the subset of requested URIs the server acknowledged in its - * `honoredFilter` (may be a strict subset — a server is allowed to decline some). - * - * `active: false` does not imply no stream: the same stream also carries the - * list-change opt-ins, so it can be open with no subscribed URI at all (#1920). - * This state describes the Subscriptions section, which has nothing to show for - * such a stream. - */ -export interface ResourceSubscriptionStreamState { - active: boolean; - status: ResourceSubscriptionStreamStatus; - honoredUris: string[]; -} - -/** The stream state reported on the legacy era (or before any subscription). */ -export const INACTIVE_SUBSCRIPTION_STREAM_STATE: ResourceSubscriptionStreamState = - { - active: false, - status: "ended", - honoredUris: [], - }; - -/** - * Wraps a URL-based elicit request from the server. v1.5 only supports - * form elicitation; v2 introduces URL elicitation as a discriminated variant - * of the inline elicitation panel. The wrapper carries the request payload - * plus the URL the user must visit to satisfy it. - */ -export interface InspectorUrlElicitRequest { - id: string; - timestamp: Date; - /** Free-form prompt shown alongside the URL (server-supplied). */ - message: string; - /** Authorization or interaction URL the user must visit. */ - url: string; - /** Optional task association for grouping in the tasks view. */ - taskId?: string; -} - -/** - * Generic envelope for pending server-originated requests surfaced to - * dumb components. Used by the pending-request panel to list anything - * the user must act on before the protocol can proceed (sampling, elicitation, - * URL elicitation, roots list, etc.). - */ -export interface InspectorPendingRequest { - id: string; - timestamp: Date; - kind: "sampling" | "elicitation" | "urlElicitation" | "rootsList"; - /** Display label rendered on the queue row. */ - label: string; - /** Optional task association so panels can group/route. */ - taskId?: string; -} - -/** - * Single entry rendered in the history view. v2 extracts this from the - * message log so the HistoryScreen can filter/group entries without needing - * to re-derive direction or method from raw JSON-RPC frames. - */ -export interface InspectorRequestHistoryItem { - id: string; - timestamp: Date; - direction: "request" | "response" | "notification"; - method: string; - durationMs?: number; - /** Surfaces the original log entry for detail panes. */ - messageId: MessageEntry["id"]; -} - -/** - * OAuth credentials surfaced by the settings form. The form callback - * passes this whole object so callers don't have to thread per-field - * dispatches through stringly-typed key arguments. - */ -export interface OAuthSettings { - clientId: string; - clientSecret: string; - scopes: string; - enterpriseManaged?: boolean; - onInsufficientScope?: OnInsufficientScopePolicy; -} - -/** - * SEP-2350 step-up policy for a `403 insufficient_scope` challenge. `reauthorize` - * (the SDK default) drives step-up authorization with the accumulated scope union; - * `throw` surfaces the challenge to the host instead. Forwarded to the - * StreamableHTTP transport's `onInsufficientScope` option. - */ -export type OnInsufficientScopePolicy = "reauthorize" | "throw"; - -/** - * Default TTL (ms) for tasks created via "Run as task". Mirrors v1/v1.5's - * `MCP_TASK_TTL` config default. Used when a server has no explicit `taskTtl`. - */ -export const DEFAULT_TASK_TTL_MS = 60000; - -/** - * Default maximum number of HTTP fetch requests retained in the Network log - * (per server). When exceeded, the oldest entries rotate out. A larger value - * keeps more history at the cost of memory; `0` means unlimited (not - * recommended). Mirrors `FetchRequestLogState`'s built-in default so the form - * and the log state agree on the omit-sentinel. - */ -export const DEFAULT_MAX_FETCH_REQUESTS = 1000; - -/** - * Per-server protocol era (SEP §7.8 backward-compat model), an orthogonal - * dimension to the transport `type`. Drives the SDK Client's - * `versionNegotiation`: - * - * - `"legacy"` — the plain 2025-11-25 `initialize` handshake, byte-identical to - * a client without negotiation. **This is the default** per the SDK's - * guidance that a debugging tool must not auto-probe (a probe stalls on - * silent stdio legacy servers and pollutes recorded transcripts). - * - `"auto"` — probe `server/discover` at connect and fall back to `initialize` - * on any non-modern outcome. - * - `"modern"` — pin the modern era at exactly `MODERN_PROTOCOL_VERSION`; no - * fallback (a non-modern server fails loudly). - */ -export type ServerProtocolEra = "legacy" | "auto" | "modern"; - -/** The default per-server protocol era when none is configured. */ -export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy"; - -/** - * Per-server modern (2026-07-28) per-request log level (#1629). `logging/setLevel` - * is gone on the modern era; instead the client opts into logs by stamping - * `_meta["io.modelcontextprotocol/logLevel"]` on each request. This setting is - * the level stamped by default on a modern connection — one of the eight logging - * levels, or `"off"` to not opt in (no server logs). Legacy connections ignore - * it (they use the session-scoped `logging/setLevel` instead). - */ -export type ModernLogLevel = LoggingLevel | "off"; - -/** - * The default modern per-request log level when none is configured. Defaults to - * opted-in at the most verbose level so a modern connection surfaces server logs - * out of the box (the Inspector is a debugging tool); set `"off"` per server to - * opt back out. - */ -export const DEFAULT_MODERN_LOG_LEVEL: ModernLogLevel = "debug"; - -/** - * The live modern per-request log level a server's settings imply: the - * configured value, or {@link DEFAULT_MODERN_LOG_LEVEL} when unset, with - * `"off"` meaning not opted in. - * - * One derivation, because the client stamps `_meta` from it while the web Logs - * control displays it — computing it separately on each side is how the two - * come to disagree, which is a bad failure for a tool whose job is showing what - * it sent (#1629, #1797). The web maps `undefined` to `null` at its own - * boundary; that is display, not a second derivation. - */ -export function resolveModernLogLevel( - settings?: Pick, -): LoggingLevel | undefined { - const level = settings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; - return level === "off" ? undefined : level; -} - -/** All modern-log-level values, for form options and the runtime guard. */ -export const MODERN_LOG_LEVELS: ModernLogLevel[] = [ - "off", - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency", -]; - -/** Runtime guard for the {@link ModernLogLevel} literal (hand-edited files). */ -export function isModernLogLevel(value: unknown): value is ModernLogLevel { - return ( - typeof value === "string" && (MODERN_LOG_LEVELS as string[]).includes(value) - ); -} - -/** - * The modern protocol revision `"modern"` era pins to. The successor to - * 2025-11-25; the first revision with the per-request-metadata / sessionless - * model (SEP §7.1). - */ -export const MODERN_PROTOCOL_VERSION = "2026-07-28"; - -/** - * Map a per-server {@link ServerProtocolEra} onto the SDK Client's - * `versionNegotiation` option. `"modern"` pins {@link MODERN_PROTOCOL_VERSION}; - * `"legacy"`/`"auto"` pass their mode straight through. - */ -export function eraToVersionNegotiation( - era: ServerProtocolEra, -): VersionNegotiationOptions { - switch (era) { - case "auto": - return { mode: "auto" }; - case "modern": - return { mode: { pin: MODERN_PROTOCOL_VERSION } }; - case "legacy": - return { mode: "legacy" }; - } -} - -/** - * Runtime settings for a configured server. A subset of - * InspectorClientOptions (v1.5) relevant to the settings form: - * headers, metadata, timeouts, and OAuth credentials. - */ -export interface InspectorServerSettings { - headers: { key: string; value: string }[]; - metadata: { key: string; value: string }[]; - /** - * Environment variables for stdio servers, edited as controlled key/value - * rows (mirrors `headers`). Only meaningful for stdio transports; non-stdio - * servers keep this an empty list. These do NOT live on disk as a settings - * field — they round-trip through the SDK config's `env` (the standard - * mcp.json location). The settings layer mirrors them for the form, and the - * `/api/servers` PUT route writes an edited list back onto `config.env` when - * the caller patches settings only. Empty-key rows are dropped on persist. - */ - env: { key: string; value: string }[]; - /** - * Working directory for stdio servers (`config.cwd`). Like `env`, this is a - * mirror of the SDK config field rather than a persisted settings field; - * empty/unset means "inherit". Only meaningful for stdio transports. - */ - cwd?: string; - connectionTimeout: number; - requestTimeout: number; - /** TTL (ms) for tasks created via "Run as task". Defaults to 60000. */ - taskTtl: number; - oauthClientId?: string; - oauthClientSecret?: string; - oauthScopes?: string; - /** - * SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this - * server's HTTP transport. Defaults to `reauthorize` when unset. - */ - oauthOnInsufficientScope?: OnInsufficientScopePolicy; - /** - * When true, connect via the configured enterprise IdP (EMA) instead of - * interactive OAuth to the MCP authorization server. Per-server OAuth - * fields below are resource AS credentials. (#1509) - */ - enterpriseManaged?: boolean; - /** - * When true, lists auto-refresh on `list_changed` notifications; when - * false (default), the notification only lights the list-changed indicator - * and the user pulls the new list via Refresh. (#1402) - */ - autoRefreshOnListChanged?: boolean; - /** - * When true, the tools/resources/prompts lists fetch one page at a time (a - * manual "Load next page" control) instead of auto-aggregating all pages. - * Default false. Server-wide; the per-list sidebar toggle edits this. (#1721) - */ - paginatedLists?: boolean; - /** - * Maximum number of HTTP fetch requests retained in the Network log for this - * server. When exceeded, the oldest entries rotate out (and any deferred - * response body that arrives after its entry rotated out is dropped — see - * `FetchRequestLogState`). Concrete value so the form always has something to - * render; defaults to `DEFAULT_MAX_FETCH_REQUESTS`. `0` means unlimited. - */ - maxFetchRequests: number; - /** - * Roots advertised to the server via the `roots` client capability. Each - * root carries a required `uri` and an optional `name` (SDK `Root`). The - * form edits these as controlled rows; empty-uri rows are dropped on - * persist (see `inspectorSettingsToStoredFields`). - */ - roots: Root[]; - /** - * Protocol era to negotiate with this server (orthogonal to the transport - * `type`). Drives the SDK Client's `versionNegotiation`. Optional so a bare - * settings node reads back without one; absence means {@link - * DEFAULT_PROTOCOL_ERA} (`"legacy"`). Persisted on disk as `protocolEra` and - * omitted when it equals the default, keeping the file diff minimal. - */ - protocolEra?: ServerProtocolEra; - /** - * Modern-era per-request log level stamped by default on this server's - * connections (#1629). One of the eight logging levels, or `"off"` to not opt - * in. Absence means {@link DEFAULT_MODERN_LOG_LEVEL} (`"debug"`). Only affects - * modern (2026-07-28) connections; legacy uses `logging/setLevel`. Persisted - * on disk as `modernLogLevel` and omitted when it equals the default. - */ - modernLogLevel?: ModernLogLevel; - /** - * Per-extension overrides for which extensions the Inspector advertises to - * this server in `capabilities.extensions`, keyed by extension id. A present - * key wins over the registry default in `ADVERTISABLE_EXTENSIONS`; an absent - * key falls back to it. Toggling an entry is a debugging knob — a server may - * change tool registration on a client-declared extension. Persisted on disk - * as `advertisedExtensions` and omitted when empty. (#1739) - */ - advertisedExtensions?: Record; -} - -/** - * Draft state for importing a server from registry JSON. Owned by the - * ImportServerJsonPanel wiring layer. `parsed` is typed `unknown` until the - * registry schema type is added in a follow-up. - */ -export interface InspectorServerJsonDraft { - rawText: string; - parsed?: unknown; - selectedPackageIndex?: number; - envOverrides: Record; - nameOverride?: string; -} - -// --------------------------------------------------------------------------- -// v1.5 InspectorClient runtime types (#1302) -// These are required by the ported InspectorClient class and its supporting -// modules (oauthManager, transports). v2 had pruned them when it kept only -// the static InspectorClientProtocol interface; restoring them verbatim from -// v1.5 keeps the ported client compilable. -// --------------------------------------------------------------------------- - -export interface CreateTransportOptions { - /** - * Optional fetch function. When provided, used as the base for transport HTTP requests - * (Streamable HTTP). Enables proxy fetch in browser (CORS bypass). - */ - fetchFn?: typeof fetch; - - /** - * Optional callback to handle stderr logs from stdio transports - */ - onStderr?: (entry: StderrLogEntry) => void; - - /** - * Whether to pipe stderr for stdio transports (default: true for TUI, false for CLI) - */ - pipeStderr?: boolean; - - /** - * Optional callback to track HTTP fetch requests for Streamable HTTP transports. - * Receives entries without category; caller adds category when storing. - */ - onFetchRequest?: (entry: FetchRequestEntryBase) => void; - - /** - * Optional callback fired asynchronously when a previously tracked - * fetch's response body has been read. Lets the consumer update the - * already-dispatched entry without blocking the transport on body - * reading (critical for SSE responses that include progress events). - */ - onFetchResponseBody?: (id: string, responseBody: string) => void; - - /** - * Optional OAuth client provider for Streamable HTTP Bearer authentication. - * When set, the SDK injects tokens and handles 401 via the provider. - */ - authProvider?: OAuthClientProvider; - - /** - * Optional per-server runtime settings. Currently used to source custom - * HTTP headers (settings.headers) for Streamable HTTP transports. - * Stdio ignores this — headers are not applicable. - */ - settings?: InspectorServerSettings; - - /** - * When true, wrap HTTP transport fetch with auth-challenge detection so 401/403 - * become {@link AuthChallengeError} before the SDK calls `auth()` on a frozen provider. - */ - interceptAuthChallenges?: boolean; -} - -export interface CreateTransportResult { - transport: Transport; -} - -/** - * A tool a conforming Streamable HTTP client MUST exclude from `tools/list` - * because its `x-mcp-header` annotations violate SEP-2243 (the whole tool - * definition is invalidated). The SDK's `listTools()` drops these silently; the - * Inspector surfaces them — with the constraint they broke — so a user can see - * *why* a tool vanished (#1632). Only modern non-stdio connections exclude. - */ -export interface ExcludedTool { - tool: Tool; - /** The first violated constraint, from the `x-mcp-header` scan. */ - reason: string; -} - -/** - * Factory that creates a client transport for an MCP server configuration. - * Required by InspectorClient; caller provides the implementation for their - * environment (e.g. createTransport for Node, RemoteClientTransport factory for browser). - */ -export type CreateTransport = ( - config: MCPServerConfig, - options: CreateTransportOptions, -) => CreateTransportResult; - -/** - * Type for the client-like object passed to AppRenderer / @mcp-ui. - * Structurally compatible with the MCP SDK Client but denotes the app-renderer - * proxy, not the raw client. Use this type when passing the client to the Apps tab. - */ -export type AppRendererClient = Client; - -/** - * Consolidated environment interface that defines all environment-specific seams. - * Each environment (Node, browser, tests) provides a complete implementation bundle. - */ -export interface InspectorClientEnvironment { - /** - * Factory that creates a client transport for the given server config. - * Required. Environment provides the implementation: - * - Node: createTransportNode - * - Browser: createRemoteTransport - */ - transport: CreateTransport; - - /** - * Optional fetch function for HTTP requests (OAuth discovery/token exchange and - * MCP transport). When provided, used for both auth and transport to bypass CORS. - * - Node: undefined (uses global fetch) - * - Browser: createRemoteFetch - */ - fetch?: typeof fetch; - - /** - * Optional logger for InspectorClient events (transport, OAuth, etc.). - * - Node: pino file logger - * - Browser: createRemoteLogger - */ - logger?: InspectorLogger; - - /** - * OAuth environment components - */ - oauth?: { - /** - * OAuth storage implementation - * - Node: NodeOAuthStorage (file-based) - * - Browser: BrowserOAuthStorage (sessionStorage) or RemoteOAuthStorage (shared state) - */ - storage?: OAuthStorage; - - /** - * Navigation handler for redirecting users to authorization URLs - * - Node: ConsoleNavigation - * - Browser: BrowserNavigation - */ - navigation?: OAuthNavigation; - - /** - * Redirect URL provider - * - Node: from OAuth callback server - * - Browser: from window.location or callback route - */ - redirectUrlProvider?: RedirectUrlProvider; - }; -} - -export interface InspectorClientOptions { - /** - * Environment-specific implementations (transport, fetch, logger, OAuth components) - */ - environment: InspectorClientEnvironment; - - /** - * Client identity (name and version) - */ - clientIdentity?: { - name: string; - version: string; - }; - /** - * Whether to pipe stderr for stdio transports (default: true for TUI, false for CLI) - */ - pipeStderr?: boolean; - - /** - * Initial logging level to set after connection (if server supports logging) - * If not provided, logging level will not be set automatically - */ - initialLoggingLevel?: LoggingLevel; - - /** - * Whether to advertise sampling capability (default: true) - */ - sample?: boolean; - - /** - * Elicitation capability configuration - * - `true` - support form-based elicitation only (default, for backward compatibility) - * - `{ form: true }` - support form-based elicitation only - * - `{ url: true }` - support URL-based elicitation only - * - `{ form: true, url: true }` - support both form and URL-based elicitation - * - `false` or `undefined` - no elicitation support - */ - elicit?: - | boolean - | { - form?: boolean; - url?: boolean; - }; - - /** - * Initial roots to configure. If provided (even if empty array), the client will - * advertise roots capability and handle roots/list requests from the server. - */ - roots?: Root[]; - - /** - * Per-extension overrides for which extensions the Inspector advertises in - * `capabilities.extensions`, keyed by extension id. A present key wins over - * the registry default in `ADVERTISABLE_EXTENSIONS`; an absent key falls back - * to it. Lets a user toggle advertised extensions as a debugging knob — - * servers legitimately change tool registration on client-declared extensions - * (#1633). EMA is not configured here (it follows the auth mode). (#1738) - */ - advertisedExtensions?: Record; - - /** - * Whether to enable listChanged notification handlers (default: true) - * If enabled, InspectorClient will subscribe to list_changed notifications and fire - * corresponding events (toolsListChanged, resourcesListChanged, promptsListChanged). - */ - listChangedNotifications?: { - tools?: boolean; - resources?: boolean; - prompts?: boolean; - }; - - /** - * Whether to enable progress notification handling (default: true) - * If enabled, InspectorClient will register a handler for progress notifications and dispatch progressNotification events - */ - progress?: boolean; - - /** - * If true, receiving a progress notification resets the request timeout (default: true). - * Only applies to requests that can receive progress. Set to false for strict timeout caps. - */ - resetTimeoutOnProgress?: boolean; - - /** - * Per-request timeout in milliseconds. If not set, the SDK default (60_000) is used. - */ - timeout?: number; - - /** - * Default `_meta` payload merged into every outgoing request the client - * issues (tools/list, tools/call, prompts/get, resources/read, etc.). Call- - * site metadata wins on key collision. Set this from `InspectorServerSettings.metadata` - * so persisted server-wide metadata reaches the wire on the first request. - */ - defaultMetadata?: Record; - - /** - * Optional per-server runtime settings forwarded to the transport factory - * (for HTTP transports, settings.headers becomes the wire headers). The - * other fields on `InspectorServerSettings` are unpacked by the caller - * into `timeout`, `defaultMetadata`, and `oauth` on this options object — - * `serverSettings` itself is only consumed by the transport. - */ - serverSettings?: InspectorServerSettings; - - /** - * Protocol version negotiation for the SDK Client (SEP §7.8 era model). - * When omitted, the client pins the legacy 2025-11-25 era - * (`{ mode: "legacy" }`), byte-identical to a client without negotiation. - * Callers derive this from the per-server {@link ServerProtocolEra} via - * {@link eraToVersionNegotiation}. (#1626) - */ - versionNegotiation?: VersionNegotiationOptions; - - /** - * OAuth configuration (client credentials, scope, etc.) - * Note: OAuth environment components (storage, navigation, redirectUrlProvider) - * are in environment.oauth, but clientId/clientSecret/scope are config. - */ - oauth?: { - clientId?: string; - clientSecret?: string; - clientMetadataUrl?: string; - scope?: string; - /** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */ - enterpriseManaged?: boolean; - }; - - /** - * Global enterprise IdP credentials (from client.json). Used for EMA legs 1–2 - * when {@link oauth.enterpriseManaged} is true on the server. - */ - enterpriseManagedAuth?: { - idp: EnterpriseManagedAuthIdpConfig; - }; - - /** - * Full install-level EMA config from client.json (including when disabled). - * Used to produce friendly errors when a server expects EMA but IdP is inactive. - */ - installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; - - /** - * When true, direct transports (TUI/CLI) route MCP 401/403 through - * `handleAuthChallenge()` via fetch intercept instead of the SDK auth() path. - * Web remote clients should leave this false (default). - */ - directAuthRecovery?: boolean; - - /** - * Optional session ID. If not provided, will be extracted from OAuth state - * when OAuth flow starts. Passed in saveSession event for FetchRequestLogState. - */ - sessionId?: string; - - /** - * When true, advertise receiver-task capability and handle task-augmented - * sampling/createMessage and elicit; register tasks/list, tasks/get, - * tasks/result, tasks/cancel handlers. Default false. - */ - receiverTasks?: boolean; - - /** - * TTL in ms for receiver tasks when server sends params.task without ttl. - * Only used when receiverTasks is true. If a function, called at task creation. - * Default 60_000 when omitted. - */ - receiverTaskTtlMs?: number | (() => number); -} diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 775dcf964..21f67bd8c 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -1,7 +1,5 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { MantineProvider } from '@mantine/core'; - import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts'; import type { ArtifactInspection } from '../../agent-bundle/src/contracts/artifacts.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from '../../agent-bundle/src/contracts/mcp-apps.ts'; @@ -19,13 +17,12 @@ import { EvalsPage } from './evals/evals-page.tsx'; import { ArtifactsPage } from './artifacts/artifacts-page.tsx'; import { HookClient } from './hooks/hook-client.ts'; import { HooksPage } from './hooks/hooks-page.tsx'; -import { InspectorSessionAdapter } from './inspector/adapter/inspector-session-adapter-entry.ts'; import { createRuntimeAppBridgeFactory, type RuntimeAppBridgeFactory, type RuntimeAppBridgeOperationTrace, type RuntimeAppBridgeTrace, -} from './inspector/adapter/runtime-app-bridge.ts'; +} from './mcp/runtime-app-bridge.ts'; import { McpAppClient, type McpAppConsentChallenge } from './mcp/mcp-app-client.ts'; import type { McpAppConsentChallenge as RuntimeMcpAppConsentChallenge } from '../../agent-bundle/src/contracts/mcp-apps.ts'; import { RuntimeConsentDialog } from './mcp/runtime-consent-dialog.tsx'; @@ -305,8 +302,6 @@ const StateMark = ({ state }: { readonly state: string }) => ( type WorkbenchPage = GeneralWorkbenchPage | 'runtime'; type RuntimeCapability = 'available' | 'unavailable' | 'unknown'; -type McpPresentation = 'inspector' | 'playground'; - type CapabilityState = | Readonly<{ readonly state: 'empty' }> | Readonly<{ readonly buildId: string; readonly state: 'loading' }> @@ -634,7 +629,7 @@ const HooksScreen = ({ connectionError, hookClient, onNavigate, pages, runtimeDi ; -const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, presentation, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, setPresentation, status }: { +const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, status }: { readonly appPreviewClient: McpAppClient; readonly artifactClient: ArtifactClient; readonly connectionError?: string; @@ -648,9 +643,7 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll readonly runtimeDiagnostic?: string; readonly runtimeHandoff?: RuntimeMcpHandoff; readonly runtimePreviewDependencies: McpPageRuntimePreviewDependencies; - readonly presentation: McpPresentation; readonly registerPreviewClose: (close: () => Promise) => () => void; - readonly setPresentation: (presentation: McpPresentation) => void; readonly status: ProjectStatus; }) => { useEffect(() => { @@ -685,113 +678,32 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll sameRuntimeMcpAppBinding(runtimeHandoff.source.binding, runtimeSource.binding) ? runtimeHandoff.initialPreview : undefined; - const runtimeAvailability = !isRuntimeModelBinding(model.binding) ? undefined : Object.freeze({ - prompts: 'not-routed' as const, - resourceTemplates: 'not-routed' as const, - resources: 'available' as const, - tools: 'available' as const, - }); - const presentationTabs = useRef>>({}); - const selectPresentation = (next: McpPresentation): void => { - setPresentation(next); - presentationTabs.current[next]?.focus(); - }; - const onPresentationKeyDown = (event: ReactKeyboardEvent, current: McpPresentation): void => { - const presentations: readonly McpPresentation[] = ['playground', 'inspector']; - const index = presentations.indexOf(current); - const next = event.key === 'ArrowRight' || event.key === 'ArrowDown' - ? presentations[(index + 1) % presentations.length] - : event.key === 'ArrowLeft' || event.key === 'ArrowUp' - ? presentations[(index + presentations.length - 1) % presentations.length] - : event.key === 'Home' - ? presentations[0] - : event.key === 'End' - ? presentations[presentations.length - 1] - : undefined; - if (next === undefined) return; - event.preventDefault(); - selectPresentation(next); - }; - const exportInspectorTrace = (entries: typeof model.timeline.entries): void => { - downloadMcpFile(mcpProtocolTraceDownload({ - history: controller.history, - model: { ...model, timeline: { ...model.timeline, entries } }, - })); - }; return
{mcpDepartureDiagnostic === undefined ? undefined :

{mcpDepartureDiagnostic}

} -
- - -
- - + {runtimeSource === undefined + ? + : }
; }; @@ -870,7 +782,6 @@ const Workbench = () => { if (runtimeConsentQueue.current === undefined) { runtimeConsentQueue.current = createRuntimeConsentQueue(setRuntimeConsent); } - const [mcpPresentation, setMcpPresentation] = useState('playground'); const [status, setStatus] = useState(); const [changedFiles, setChangedFiles] = useState([]); const [runtimeOperationTraces, setRuntimeOperationTraces] = useState(emptyRuntimeOperationTraces); @@ -947,7 +858,6 @@ const Workbench = () => { } const hash = `#${available}`; if (window.location.hash !== hash) window.history.pushState(undefined, '', hash); - if (available === 'mcp') setMcpPresentation('playground'); setPage(available); }, []); @@ -1113,7 +1023,7 @@ const Workbench = () => { const authority = prepareRuntimeMcpHandoffAuthority(props, runtimeProfileId); const appClient = mcpAppClient.current; if (authority === undefined || appClient === undefined) return undefined; - return { }} run={props.run} surface={props.surface} - />; + />; }, [createBridgeFactory, runtimeOperationTraces]); const liveMcpPageAdapter = useMemo(() => Object.freeze({ @@ -1336,7 +1246,6 @@ const Workbench = () => { const next = pageForHash(runtimeAvailable, pages); if (!fromHashChange && next === pageRef.current) return; navigate(next); - if (fromHashChange && next === 'mcp' && pageRef.current !== 'mcp') setMcpPresentation('playground'); }; const onHashChange = () => updatePage(true); updatePage(false); @@ -1388,8 +1297,6 @@ const Workbench = () => { runtimeDiagnostic={runtimeError} runtimeHandoff={runtimeHandoff} runtimePreviewDependencies={runtimePreviewDependencies} - presentation={mcpPresentation} - setPresentation={setMcpPresentation} status={status} />); } diff --git a/packages/workbench/src/inspector/LICENSE.inspector b/packages/workbench/src/mcp/APP-RENDERER-LICENSE similarity index 100% rename from packages/workbench/src/inspector/LICENSE.inspector rename to packages/workbench/src/mcp/APP-RENDERER-LICENSE diff --git a/packages/workbench/src/mcp/app-renderer.tsx b/packages/workbench/src/mcp/app-renderer.tsx new file mode 100644 index 000000000..f2206fe4b --- /dev/null +++ b/packages/workbench/src/mcp/app-renderer.tsx @@ -0,0 +1,521 @@ +import type { CallToolResult, Tool } from '@modelcontextprotocol/client'; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, + type Ref, + type RefObject, +} from 'react'; + +/** + * First-party MCP App renderer, adapted from the MCP Inspector's AppRenderer + * (modelcontextprotocol/inspector 672f9f41, MIT). The Workbench previously + * vendored the Inspector; only this renderer survived the removal, retyped + * against the shapes the preview pipeline compiles against. The Workbench has + * no design-token system, so the host advertises no styles - apps use their + * own defaults, which the ext-apps spec permits - and the theme derives from + * the system color scheme. + */ + +export type McpAppRendererDisplayMode = 'fullscreen' | 'inline' | 'pip'; + +export type McpAppRendererJsonArray = readonly McpAppRendererJsonValue[]; + +export interface McpAppRendererJsonObject { + readonly [key: string]: McpAppRendererJsonValue; +} + +export type McpAppRendererJsonValue = + | null + | boolean + | number + | string + | McpAppRendererJsonArray + | McpAppRendererJsonObject; + +export type McpAppRendererTool = Tool; + +export interface McpAppRendererMessage { + readonly content: readonly McpAppRendererJsonValue[]; + readonly role: 'user'; +} + +export interface McpAppRendererHostContext { + readonly availableDisplayModes?: readonly McpAppRendererDisplayMode[]; + readonly containerDimensions?: Readonly<{ readonly height: number; readonly width: number }>; + readonly displayMode?: McpAppRendererDisplayMode; + readonly theme?: 'dark' | 'light'; +} + +export interface AppRendererBridge { + addEventListener(type: 'initialized', listener: () => void): void; + addEventListener(type: 'loggingmessage', listener: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void): void; + addEventListener(type: 'sizechange', listener: (params: Readonly<{ readonly height?: number; readonly width?: number }>) => void): void; + close(): Promise; + onmessage?: (params: McpAppRendererMessage) => Promise>; + onrequestdisplaymode?: (params: Readonly<{ readonly mode: McpAppRendererDisplayMode }>) => Promise>; + sendHostContextChange(context: Partial): Promise; + sendToolCancelled(params: Readonly<{ readonly reason: string }>): Promise; + sendToolInput(params: Readonly<{ readonly arguments: Record }>): Promise; + sendToolInputPartial(params: Readonly<{ readonly arguments: Record }>): Promise; + sendToolResult(result: CallToolResult): Promise; + teardownResource(params: Readonly>): Promise>>; +} + +/** + * Constructs the bridge for a freshly mounted sandbox iframe. Wrap with + * `useCallback` (or hoist out of render) - the renderer treats a new factory + * identity as a signal to tear down the current bridge and rebuild, so an + * unstable factory will thrash the iframe on every render. + */ +export type BridgeFactory = ( + iframe: HTMLIFrameElement, + tool: McpAppRendererTool, +) => AppRendererBridge | Promise; + +export interface AppRendererHandle { + sendToolCancelled(reason: string): Promise; + sendToolInput(args: Record): Promise; + sendToolResult(result: CallToolResult): Promise; + teardown(): Promise; +} + +/** + * High-level lifecycle of a running app, surfaced so a host (or an automated + * driver polling a `data-app-status` attribute) can wait for the right moment: + * `loading` while the bridge is being built and the view's `ui/initialize` + * handshake is in flight; `ready` once the view has fired + * `notifications/initialized`; `error` when the bridge factory throws or + * rejects (no live view to wait on). + */ +export type AppRendererStatus = 'error' | 'loading' | 'ready'; + +export interface AppRendererProps { + readonly bridgeFactory: BridgeFactory; + /** + * Current host display mode for the app frame. Pushed to the running view + * whenever it changes (e.g. Maximize/Restore), so an app can adapt its + * layout to inline vs fullscreen. + */ + readonly displayMode?: McpAppRendererDisplayMode; + readonly onAppStatusChange?: (status: AppRendererStatus) => void; + readonly onError?: (error: Error) => void; + /** Called for each MCP log notification the running view emits. */ + readonly onLog?: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void; + /** + * Called when the running view submits a user-role message via + * `ui/message`. The renderer returns the spec-required empty result on the + * host's behalf, so the callback is fire-and-forget. + */ + readonly onMessage?: (params: McpAppRendererMessage) => void; + /** + * Handles a view-originated `ui/request-display-mode`. Return the mode the + * host actually applied - the spec lets the host decline an unsupported + * mode by returning its current one. + */ + readonly onRequestDisplayMode?: (requested: McpAppRendererDisplayMode) => McpAppRendererDisplayMode; + /** Reports the view's rendered content size so the host can fit the frame. */ + readonly onSizeChange?: (size: Readonly<{ readonly height?: number; readonly width?: number }>) => void; + /** + * Ordered tool-input fragments to replay before the complete `tool-input`, + * exercising widgets that render progressively. Captured at bridge-build + * time so prop churn never rebuilds the iframe. + */ + readonly partialInputs?: readonly Readonly>[]; + /** + * The host-controlled box the app renders within, used to derive + * `hostContext.containerDimensions`. This MUST be an element whose size is + * driven by the host's layout and NOT by the view's own size reports - + * otherwise the two signals couple into a feedback loop. Falls back to the + * iframe element when omitted. + */ + readonly containerRef?: RefObject; + readonly ref?: Ref; + readonly sandboxPath: string; + readonly tool: McpAppRendererTool; +} + +const currentTheme = (): 'dark' | 'light' => + typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + +const measureContainerDimensions = ( + element: HTMLElement, +): Readonly<{ readonly height: number; readonly width: number }> | undefined => { + if (typeof element.getBoundingClientRect !== 'function') return undefined; + const rect = element.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (width <= 0 || height <= 0) return undefined; + return { height, width }; +}; + +/** + * Read the live host UI state for the bridge handshake - the single place + * that decides which fields the host seeds. Optional fields are omitted (not + * set undefined) so the bridge's diff stays accurate; subsequent live changes + * are pushed by the renderer's observers as partial host-context changes. The + * seed assumes the app opens inline; the live displayMode push carries any + * subsequent inline-fullscreen transition. + */ +export const snapshotHostContext = ( + container: HTMLElement | null, + availableDisplayModes: readonly McpAppRendererDisplayMode[], +): McpAppRendererHostContext => { + const containerDimensions = container === null ? undefined : measureContainerDimensions(container); + return { + availableDisplayModes: [...availableDisplayModes], + ...(containerDimensions === undefined ? {} : { containerDimensions }), + displayMode: 'inline', + theme: currentTheme(), + }; +}; + +const toError = (value: unknown): Error => value instanceof Error ? value : new Error(String(value)); + +const disposeBridge = async (bridge: AppRendererBridge): Promise => { + // Best-effort: still close the transport even if teardownResource fails, + // otherwise the iframe unmount would leak MessagePort listeners. + try { + await bridge.teardownResource({}); + } catch { + /* swallow - closing transport below is the load-bearing step */ + } + try { + await bridge.close(); + } catch { + /* swallow - already disposing */ + } +}; + +/** + * Bridge lifecycle (the interlocking refs below): + * + * mount -> build (buildId++) -> factory(iframe, tool) -async-> bridgeRef set + * | on "initialized" + * v -> flushPending + * cleanup -> scheduleDispose() --microtask--> dispose (unless cancelled) + * ^ | + * +-- re-setup with SAME inputs -----+ cancel + REUSE bridge + * + * - `buildId` (monotonic): a bridge resolved from an older build self-disposes. + * - `disposeScheduled`: a dispose is queued (microtask); a synchronous + * re-setup (StrictMode double-invoke, or a transient re-render) cancels it + * and reuses the live bridge instead of rebuilding (rebuild double-loads + * the sandbox and races the app handshake). A re-setup with CHANGED inputs + * disposes + rebuilds. + * - `lastDeps`: distinguishes "same inputs -> reuse" from "changed -> rebuild". + * - `initialized`: gates flushing buffered input/result until the view is ready. + * - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open. + * - `teardownStarted`: makes the imperative teardown() idempotent vs unmount. + */ +export const AppRenderer = ({ + bridgeFactory, + containerRef, + displayMode, + onAppStatusChange, + onError, + onLog, + onMessage, + onRequestDisplayMode, + onSizeChange, + partialInputs, + ref, + sandboxPath, + tool, +}: AppRendererProps): React.ReactNode => { + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const initializedRef = useRef(false); + const pendingPartialsRef = useRef>[]>([]); + const pendingInputRef = useRef | null>(null); + const pendingResultRef = useRef(null); + const teardownStartedRef = useRef(false); + const buildIdRef = useRef(0); + const disposeScheduledRef = useRef(false); + const lastDepsRef = useRef | null>(null); + const onErrorRef = useRef(onError); + const onAppStatusChangeRef = useRef(onAppStatusChange); + const onSizeChangeRef = useRef(onSizeChange); + const displayModeRef = useRef(displayMode); + const onRequestDisplayModeRef = useRef(onRequestDisplayMode); + const onMessageRef = useRef(onMessage); + const onLogRef = useRef(onLog); + const partialInputsRef = useRef(partialInputs); + useEffect(() => { + onErrorRef.current = onError; + onAppStatusChangeRef.current = onAppStatusChange; + onSizeChangeRef.current = onSizeChange; + displayModeRef.current = displayMode; + onRequestDisplayModeRef.current = onRequestDisplayMode; + onMessageRef.current = onMessage; + onLogRef.current = onLog; + partialInputsRef.current = partialInputs; + }); + + // Flush buffered tool input/result to the view, but only once the bridge + // exists AND the view has signalled `initialized`. The spec requires tool + // input/result to arrive after initialization, yet a host-initiated open + // fires before the iframe's app has loaded - so the latest values buffer + // and release when the view is ready. Input is always sent before result. + const flushPending = useCallback(() => { + const bridge = bridgeRef.current; + if (bridge === null || !initializedRef.current) return; + for (const args of pendingPartialsRef.current) { + void bridge.sendToolInputPartial({ arguments: { ...args } }); + } + pendingPartialsRef.current = []; + if (pendingInputRef.current !== null) { + const args = pendingInputRef.current; + pendingInputRef.current = null; + void bridge.sendToolInput({ arguments: args }); + } + if (pendingResultRef.current !== null) { + const result = pendingResultRef.current; + pendingResultRef.current = null; + void bridge.sendToolResult(result); + } + }, []); + + // Dispose the live bridge, but deferred to a microtask. React StrictMode + // runs effects setup->cleanup->setup synchronously in dev; deferring lets + // the re-setup cancel the disposal and keep the SAME bridge, instead of + // tearing it down and rebuilding. A rebuild here spins up a second + // transport that re-posts sandbox-resource-ready (the sandbox loads the app + // twice) and races the app's ui/initialize handshake. + const scheduleDispose = useCallback(() => { + disposeScheduledRef.current = true; + queueMicrotask(() => { + if (!disposeScheduledRef.current) return; + disposeScheduledRef.current = false; + buildIdRef.current += 1; + const bridge = bridgeRef.current; + bridgeRef.current = null; + initializedRef.current = false; + lastDepsRef.current = null; + pendingPartialsRef.current = []; + if (bridge !== null) void disposeBridge(bridge); + }); + }, []); + + useEffect(() => { + const iframe = iframeRef.current; + if (iframe === null) return undefined; + + const previous = lastDepsRef.current; + const sameInputs = + previous !== null && + previous.bridgeFactory === bridgeFactory && + previous.sandboxPath === sandboxPath && + previous.tool === tool; + + // A disposal scheduled by the immediately-preceding cleanup means this is + // a synchronous re-setup. If the inputs are identical (StrictMode's + // double-invoke, or a transient re-render) keep the live bridge: cancel + // the disposal and re-deliver any buffered input/result to it. + /* v8 ignore next 4 -- StrictMode's replayed effect body is invisible to coverage. */ + if (disposeScheduledRef.current && sameInputs) { + disposeScheduledRef.current = false; + flushPending(); + return scheduleDispose; + } + + // Otherwise this is a real (re)build. If a disposal was pending (inputs + // changed), run it synchronously before building the replacement. + if (disposeScheduledRef.current) { + disposeScheduledRef.current = false; + buildIdRef.current += 1; + const old = bridgeRef.current; + bridgeRef.current = null; + initializedRef.current = false; + if (old !== null) void disposeBridge(old); + } + + lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; + const buildId = buildIdRef.current + 1; + buildIdRef.current = buildId; + teardownStartedRef.current = false; + initializedRef.current = false; + onAppStatusChangeRef.current?.('loading'); + // Snapshot the staged partial-input fragments for THIS bridge build (read + // via the ref so the prop is not a dep - adding/removing fragments must + // not rebuild the iframe). + pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; + + let pending: Promise; + try { + pending = Promise.resolve(bridgeFactory(iframe, tool)); + } catch (error) { + onAppStatusChangeRef.current?.('error'); + onErrorRef.current?.(toError(error)); + return scheduleDispose; + } + + pending + .then((bridge) => { + if (buildIdRef.current !== buildId) { + void disposeBridge(bridge); + return; + } + bridgeRef.current = bridge; + // Registered before the inner app can finish loading, so the view's + // `initialized` signal is never missed. + bridge.addEventListener('initialized', () => { + initializedRef.current = true; + onAppStatusChangeRef.current?.('ready'); + // The factory already seeded theme/displayMode into the handshake + // hostContext; only containerDimensions can plausibly differ + // between bridge construction and initialization (layout settles). + const container = containerRef?.current ?? iframeRef.current; + const containerDimensions = container === null ? undefined : measureContainerDimensions(container); + if (containerDimensions !== undefined) { + void bridge.sendHostContextChange({ containerDimensions }); + } + flushPending(); + }); + bridge.addEventListener('sizechange', (size) => { + onSizeChangeRef.current?.(size); + }); + bridge.addEventListener('loggingmessage', (params) => { + onLogRef.current?.(params); + }); + // Handle ui/request-display-mode: the host decides what mode actually + // applies. With no handler the request is declined by returning the + // current host-side mode. + bridge.onrequestdisplaymode = async ({ mode }) => { + const handler = onRequestDisplayModeRef.current; + const applied = handler === undefined ? (displayModeRef.current ?? 'inline') : handler(mode); + return { mode: applied }; + }; + // Handle ui/message: surface the submitted content and return the + // spec-required empty result. With no handler the submission is + // declined by returning isError. + bridge.onmessage = async (params) => { + const handler = onMessageRef.current; + if (handler === undefined) return { isError: true }; + handler(params); + return {}; + }; + flushPending(); + }) + .catch((error: unknown) => { + if (buildIdRef.current !== buildId) return; + onAppStatusChangeRef.current?.('error'); + onErrorRef.current?.(toError(error)); + }); + + return scheduleDispose; + // `containerRef` is listed for exhaustive-deps completeness, but a change + // to its identity does NOT force a rebuild: the `sameInputs` check above + // ignores it, and the `initialized` handler reads `containerRef?.current` + // lazily. The other deps are the real rebuild keys. + }, [ + bridgeFactory, + sandboxPath, + tool, + containerRef, + flushPending, + scheduleDispose, + ]); + + // Theme: the Workbench has no theme system of its own, so the system color + // scheme is the only live theme signal; forward changes to the running view + // once it has initialized. + useEffect(() => { + if (typeof window === 'undefined' || window.matchMedia === undefined) return undefined; + const query = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = (): void => { + if (!initializedRef.current) return; + void bridgeRef.current?.sendHostContextChange({ theme: currentTheme() }); + }; + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, []); + + // Container size: observes the host-controlled container (or the iframe as + // a fallback) - NOT an element whose height is driven by the view's own + // size reports, which would couple the two signals into a feedback loop. + // Gated on the view's `initialized` signal; a 0x0 (not-yet-laid-out) + // measurement and a value-equal repeat are both skipped. + useEffect(() => { + const target = containerRef?.current ?? iframeRef.current; + if (typeof ResizeObserver === 'undefined' || target === null) return undefined; + let last: Readonly<{ readonly height: number; readonly width: number }> | undefined; + const observer = new ResizeObserver(() => { + if (!initializedRef.current) return; + const next = measureContainerDimensions(target); + if (next === undefined) return; + if (last !== undefined && last.width === next.width && last.height === next.height) return; + last = next; + void bridgeRef.current?.sendHostContextChange({ containerDimensions: next }); + }); + observer.observe(target); + return () => observer.disconnect(); + }, [containerRef]); + + // Display mode: pushes whenever the prop changes (Maximize/Restore). Gated + // on `initialized` for the same reason as the other host-context pushes. + useEffect(() => { + if (displayMode === undefined) return; + if (!initializedRef.current) return; + void bridgeRef.current?.sendHostContextChange({ displayMode }); + }, [displayMode]); + + useImperativeHandle( + ref, + () => ({ + async sendToolCancelled(reason) { + const bridge = bridgeRef.current; + if (bridge === null) return; + await bridge.sendToolCancelled({ reason }); + }, + async sendToolInput(args) { + // Buffered (latest-wins) and released by flushPending once the view + // is initialized - the handle may be invoked before the bridge + // resolves. + pendingInputRef.current = args; + flushPending(); + }, + async sendToolResult(result) { + pendingResultRef.current = result; + flushPending(); + }, + async teardown() { + const bridge = bridgeRef.current; + if (bridge === null || teardownStartedRef.current) return; + teardownStartedRef.current = true; + // Null the ref synchronously so a concurrent unmount cleanup cannot + // see a still-live bridge and dispose it a second time. Bumping the + // build id makes any in-flight factory self-dispose, and clearing the + // pending-dispose flag/cached deps prevents the deferred dispose from + // acting on an already torn-down bridge. + buildIdRef.current += 1; + disposeScheduledRef.current = false; + lastDepsRef.current = null; + bridgeRef.current = null; + initializedRef.current = false; + pendingInputRef.current = null; + pendingResultRef.current = null; + await disposeBridge(bridge); + }, + }), + [flushPending], + ); + + // The iframe deliberately has no `sandbox` attribute: `sandboxPath` + // resolves to the host's own trusted same-origin sandbox page, which then + // loads the untrusted MCP App content into a nested sandboxed iframe. + // Sandboxing this outer frame would block the postMessage bridge. + return ( +