diff --git a/.changeset/workbench-inspector-new-tab.md b/.changeset/workbench-inspector-new-tab.md new file mode 100644 index 000000000..950b24fe1 --- /dev/null +++ b/.changeset/workbench-inspector-new-tab.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Workbench MCP page: launch the standalone MCP Inspector and open it in a new tab, deep-linked to the selected session; launch failures surface `AB8112`/`AB8113` inline (#579) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4586b3769..5c39402ff 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -42,6 +42,7 @@ even when no error diagnostic was reported. | `AB8215`–`AB8218` | Workbench read-only host discovery route. | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | +| `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index e3918d1cb..682e80187 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -1093,8 +1093,8 @@ harness. and Codex selections are refused when it is configured. - Raw HTML, JSX/MDX, and Mermaid in Skill Markdown are inert in the workbench renderer. -Third-party notices, including the vendored MCP Inspector snapshot's license and provenance, ship in -the published package. +Third-party notices, including the MIT license and provenance of the MCP App renderer derived from +the MCP Inspector's `AppRenderer`, ship in the published package. ## License diff --git a/packages/agent-bundle/src/dev/inspector-launcher.ts b/packages/agent-bundle/src/dev/inspector-launcher.ts index fff166e47..50c8369dd 100644 --- a/packages/agent-bundle/src/dev/inspector-launcher.ts +++ b/packages/agent-bundle/src/dev/inspector-launcher.ts @@ -111,25 +111,33 @@ const inspectableUrl = (raw: string): URL | undefined => { const hasTokenQuery = (url: URL): boolean => [...url.searchParams.keys()].some((key) => key.toLowerCase().includes('token')); +/** `URL.hostname` keeps the brackets of an IPv6 literal, so `[::1]` is the spelling that arrives. */ const isLocalhost = (url: URL): boolean => { const host = url.hostname.toLowerCase(); - return host === 'localhost' || host === '127.0.0.1' || host === '::1'; + return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1'; }; -/** First stdout http(s) URL with a token query param, else the first delimited localhost URL. */ +/** + * First delimited loopback http(s) URL with a token query param, else the first delimited + * loopback URL. Only loopback hosts qualify: the Workbench hands this URL, token included, to + * the browser as a link, so a non-loopback host (an inherited `HOST=0.0.0.0`, say) is never + * published. A URL that ends the buffer is never chosen either: stdout arrives in chunks, and + * a boundary inside the token value would otherwise publish a truncated token. + * + * Inspector 2.x prints `http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=…` (no slash before + * `?`; `new URL()` adds it) followed by a token-less + * `Sandbox (MCP Apps): http://127.0.0.1:6275/sandbox` line, which must not be selected. + */ export const parseInspectorStdoutUrl = (stdout: string): string | undefined => { const text = stripAnsi(stdout); - const found: { readonly delimited: boolean; readonly url: URL }[] = []; + const found: URL[] = []; for (const match of text.matchAll(httpUrl)) { const url = inspectableUrl(match[0]!); - if (url === undefined || match.index === undefined) continue; + if (url === undefined || match.index === undefined || !isLocalhost(url)) continue; const next = text[match.index + match[0].length]; - found.push(Object.freeze({ - delimited: next !== undefined && urlDelimiter.test(next), - url, - })); + if (next !== undefined && urlDelimiter.test(next)) found.push(url); } - return (found.find((entry) => hasTokenQuery(entry.url)) ?? found.find((entry) => entry.delimited && isLocalhost(entry.url)))?.url.href; + return (found.find(hasTokenQuery) ?? found[0])?.href; }; const alreadyClosed = (child: ChildProcess): boolean => diff --git a/packages/agent-bundle/tests/inspector-launcher.test.ts b/packages/agent-bundle/tests/inspector-launcher.test.ts index 0a1d3fb78..983d1b649 100644 --- a/packages/agent-bundle/tests/inspector-launcher.test.ts +++ b/packages/agent-bundle/tests/inspector-launcher.test.ts @@ -17,6 +17,22 @@ import { 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'; +const inspectorToken = 'a4f15a21448a409c76c5b9aebb214b18bbc507037062c804ba970c74fc5e9b3a'; +const inspectorTokenUrl = `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=${inspectorToken}`; +/** Verbatim `@modelcontextprotocol/inspector` 2.5.0 stdout banner: no `/` before `?`. */ +const inspectorBanner = [ + 'Starting MCP inspector...', + '', + ' MCP Inspector Web is up and running at:', + ` http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=${inspectorToken}`, + '', + ' Sandbox (MCP Apps): http://127.0.0.1:6275/sandbox', + '', + ` Auth token: ${inspectorToken}`, + '', + ' Secrets: OS keychain', + '', +].join('\n'); interface SpawnInvocation { readonly args: readonly string[]; @@ -68,6 +84,22 @@ const fakeSpawn = (): { }); }; +/** Splits `text` right after the first occurrence of `marker`. */ +const splitAfter = (text: string, marker: string): readonly [string, string] => { + const index = text.indexOf(marker); + if (index === -1) throw new Error(`marker not found: ${marker}`); + const at = index + marker.length; + return [text.slice(0, at), text.slice(at)]; +}; + +/** 'pending' unless `promise` settles before the macrotask queue turns over. */ +const settlement = (promise: Promise): Promise<'pending' | 'settled'> => Promise.race([ + promise.then(() => 'settled' as const, () => 'settled' as const), + new Promise<'pending'>((resolvePromise) => { + setImmediate(() => resolvePromise('pending')); + }), +]); + it('stays idle until launch is requested and never auto-spawns', () => { const spawned = fakeSpawn(); const launcher = createInspectorLauncher({ @@ -114,17 +146,46 @@ 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( + expect(parseInspectorStdoutUrl('https://localhost:6274/?sessionToken=abc\n')).toBe( 'https://localhost:6274/?sessionToken=abc', ); - expect(parseInspectorStdoutUrl('http://example.com/nope')).toBeUndefined(); + expect(parseInspectorStdoutUrl('http://example.com/nope\n')).toBeUndefined(); expect(parseInspectorStdoutUrl('http://localhost:6274/?MCP_PROXY_AUTH_')).toBeUndefined(); }); +it('accepts every loopback spelling, including a bracketed IPv6 literal', () => { + for (const origin of ['http://localhost:6274', 'http://127.0.0.1:6274', 'http://[::1]:6274', 'http://LOCALHOST:6274']) { + expect(parseInspectorStdoutUrl(`${origin}?MCP_INSPECTOR_API_TOKEN=abc\n`)).toBe( + `${origin.toLowerCase()}/?MCP_INSPECTOR_API_TOKEN=abc`, + ); + } +}); + +it('never publishes a token URL on a non-loopback host, even when no loopback URL follows', () => { + expect(parseInspectorStdoutUrl('http://0.0.0.0:6274/?MCP_INSPECTOR_API_TOKEN=abc\n')).toBeUndefined(); + expect(parseInspectorStdoutUrl('https://inspector.example.com/?MCP_INSPECTOR_API_TOKEN=abc\n')).toBeUndefined(); + expect(parseInspectorStdoutUrl([ + 'https://inspector.example.com/?MCP_INSPECTOR_API_TOKEN=abc', + 'http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=def', + '', + ].join('\n'))).toBe('http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=def'); +}); + +it('does not choose a URL that ends the buffer, whether or not it already carries a token key', () => { + // A later chunk may still extend the token value, so an undelimited URL is not evidence yet. + expect(parseInspectorStdoutUrl('https://localhost:6274/?sessionToken=abc')).toBeUndefined(); + expect(parseInspectorStdoutUrl('listening on http://127.0.0.1:6274/inspector')).toBeUndefined(); +}); + +it('selects the tokenized URL from the Inspector 2.5.0 banner and skips the sandbox URL', () => { + expect(parseInspectorStdoutUrl(inspectorBanner)).toBe(inspectorTokenUrl); +}); + it('joins a URL split across stdout chunks before resolving', async () => { const spawned = fakeSpawn(); const launcher = createInspectorLauncher({ @@ -140,6 +201,46 @@ it('joins a URL split across stdout chunks before resolving', async () => { }); }); +it('keeps a 2.5.0 launch pending while the token key is split across stdout chunks', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + const [head, tail] = splitAfter(inspectorBanner, 'MCP_INSPECTOR_API_'); + + const pending = launcher.launch(); + spawned.children[0]!.stdout.write(head); + await expect(settlement(pending)).resolves.toBe('pending'); + expect(launcher.status()).toEqual({ state: 'starting' }); + + spawned.children[0]!.stdout.write(tail); + await expect(pending).resolves.toEqual({ url: inspectorTokenUrl }); + expect(launcher.status()).toEqual({ state: 'running', url: inspectorTokenUrl }); +}); + +it('keeps a 2.5.0 launch pending while the token value is split across stdout chunks', async () => { + // A chunk that ends inside the token value already looks like a complete token URL; only + // the delimiter that follows the URL proves the value is whole. + const [head, tail] = splitAfter(inspectorBanner, 'MCP_INSPECTOR_API_TOKEN=a4f15a21'); + expect(parseInspectorStdoutUrl(head)).toBeUndefined(); + + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + spawned.children[0]!.stdout.write(head); + await expect(settlement(pending)).resolves.toBe('pending'); + expect(launcher.status()).toEqual({ state: 'starting' }); + + spawned.children[0]!.stdout.write(tail); + await expect(pending).resolves.toEqual({ url: inspectorTokenUrl }); + expect(launcher.status()).toEqual({ state: 'running', url: inspectorTokenUrl }); +}); + it('kills the child and rejects when the startup budget elapses', async () => { const spawned = fakeSpawn(); const launcher = createInspectorLauncher(withSeams({ diff --git a/packages/workbench/src/client-helpers.ts b/packages/workbench/src/client-helpers.ts index cdfc8831d..43fb1086c 100644 --- a/packages/workbench/src/client-helpers.ts +++ b/packages/workbench/src/client-helpers.ts @@ -35,6 +35,29 @@ export const jsonEquivalent = (left: unknown, right: unknown): boolean => { export const isRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); +/** An absolute `http:` or `https:` URL string; unparseable input is not one. */ +export const isHttpUrl = (value: unknown): value is string => { + if (typeof value !== 'string') return false; + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const loopbackHosts: ReadonlySet = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * An `isHttpUrl` on a loopback host without embedded credentials. Token-bearing URLs the + * Workbench turns into links (the standalone Inspector's) must never point off this machine. + */ +export const isLoopbackHttpUrl = (value: unknown): value is string => { + if (!isHttpUrl(value)) return false; + const { hostname, password, username } = new URL(value); + return loopbackHosts.has(hostname.toLowerCase()) && username === '' && password === ''; +}; + /** * Hands the viewer a browser download. The object URL is revoked on a queued * task: a synchronous revoke can abort the scheduled download of larger blobs. diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 77599a9d0..59a84840f 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -33,6 +33,7 @@ import type { McpAppConsentChallenge as RuntimeMcpAppConsentChallenge } from '.. import { RuntimeConsentDialog } from './mcp/runtime-consent-dialog.tsx'; import { createRuntimeConsentQueue, type RuntimeConsentQueue, type RuntimeConsentQueueCurrent } from './mcp/runtime-consent-queue.ts'; import { McpAppPreview } from './mcp/mcp-app-preview.tsx'; +import { createMcpInspectorLaunchController, type McpInspectorLaunchController } from './mcp/mcp-inspector-launch-controller.ts'; import { McpPage, mcpPageEmptyServerCatalogFor, mcpPageServerCatalogFor, type McpConfigDownload, type McpPagePreviewSelection, type McpPageRuntimePreviewDependencies, type McpPageServerCatalog } from './mcp/mcp-page.tsx'; import { ForegroundRouteClient, McpRouteClient } from './mcp/mcp-route-client.ts'; import { createMcpSessionController } from './mcp/mcp-session-controller.ts'; @@ -679,12 +680,13 @@ const HostsScreen = ({ connectionError, discoveryClient, manifestDigest, onNavig ; -const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, initialToolPrefill, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, status }: { +const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, initialToolPrefill, inspectorLaunch, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, status }: { readonly appPreviewClient: McpAppClient; readonly artifactClient: ArtifactClient; readonly connectionError?: string; readonly controller: ReturnType; readonly initialToolPrefill?: McpToolPrefill; + readonly inspectorLaunch: McpInspectorLaunchController; readonly mcpDepartureDiagnostic?: string; readonly model: ReturnType['model']; readonly onNavigate: (page: WorkbenchPage) => void; @@ -742,6 +744,7 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll ...(initialToolPrefill === undefined ? {} : { serverName: initialToolPrefill.serverName }), }} initialToolPrefill={initialToolPrefill} + inspectorLaunch={inspectorLaunch} onDownloadConfig={downloadMcpFile} onDownloadTrace={downloadMcpFile} onResetSession={onResetSession} @@ -754,6 +757,7 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll : { const client = useRef(undefined); const foreground = useRef(undefined); const mcpRoutes = useRef(undefined); + const inspectorLaunch = useRef(undefined); const mcpControllerRef = useRef(undefined); const mcpAppClient = useRef(undefined); const runtimeClient = useRef(undefined); @@ -824,6 +829,8 @@ const Workbench = () => { if (foreground.current === undefined) foreground.current = new ForegroundRouteClient(); const foregroundClient = foreground.current; if (mcpRoutes.current === undefined) mcpRoutes.current = new WorkbenchMcpRouteClient({ foreground: foregroundClient }); + if (inspectorLaunch.current === undefined) inspectorLaunch.current = createMcpInspectorLaunchController({ routes: mcpRoutes.current }); + const inspectorLaunchController = inspectorLaunch.current; const [mcpController, setMcpController] = useState(() => createMcpController(mcpRoutes.current!)); const [mcpModel, setMcpModel] = useState(() => mcpController.model); @@ -1366,6 +1373,7 @@ const Workbench = () => { connectionError={connectionError} controller={mcpController} initialToolPrefill={mcpToolPrefillFromNavigationState(window.history.state)} + inspectorLaunch={inspectorLaunchController} mcpDepartureDiagnostic={mcpDepartureError} model={mcpModel} onNavigate={navigate} diff --git a/packages/workbench/src/mcp/mcp-inspector-launch-controller.ts b/packages/workbench/src/mcp/mcp-inspector-launch-controller.ts new file mode 100644 index 000000000..41a911579 --- /dev/null +++ b/packages/workbench/src/mcp/mcp-inspector-launch-controller.ts @@ -0,0 +1,135 @@ +import { errorMessage } from '../client-helpers.ts'; +import { + createMcpInspectorLaunchModel, + reduceMcpInspectorLaunch, + type McpInspectorLaunchDiagnostic, + type McpInspectorLaunchEvent, + type McpInspectorLaunchModel, +} from './mcp-inspector-launch-model.ts'; +import type { McpInspectorRouteStatus } from './mcp-route-client.ts'; + +export interface McpInspectorLaunchRoutes { + inspectorLaunch(): Promise>; + inspectorStatus(): Promise; +} + +export type McpInspectorLaunchListener = (model: McpInspectorLaunchModel) => void; + +export interface McpInspectorLaunchControllerOptions { + readonly routes: McpInspectorLaunchRoutes; +} + +export interface McpInspectorLaunchController { + readonly model: McpInspectorLaunchModel; + /** + * Asks the dev server to launch the standalone Inspector. Publishes + * `starting`, then `ready` with its tokenized URL or `error`. Never rejects; + * concurrent calls while a launch is in flight share that one route call. + */ + launch(): Promise; + /** + * Re-reads the launcher status. A running Inspector publishes `ready`; any + * other state publishes `idle` unless a launch is in flight. Never rejects. + */ + refresh(): Promise; + /** Invokes `listener` immediately with the current model, then on every change. Listener exceptions are swallowed. */ + subscribe(listener: McpInspectorLaunchListener): () => void; +} + +const LAUNCH_FAILED = Object.freeze({ code: 'mcp.inspector.launch.failed', message: 'MCP Inspector could not be launched.' }); +const STATUS_FAILED = Object.freeze({ code: 'mcp.inspector.status.failed', message: 'MCP Inspector status is not available.' }); + +/** Route-client errors carry the server's diagnostic code as an own property; anything else gets the local fallback. */ +const failureDiagnostic = (reason: unknown, fallback: McpInspectorLaunchDiagnostic): McpInspectorLaunchDiagnostic => { + if (reason instanceof Error && Object.hasOwn(reason, 'code')) { + const { code } = reason as Error & { readonly code?: unknown }; + if (typeof code === 'string') return { code, message: reason.message }; + } + return { code: fallback.code, message: errorMessage(reason, fallback.message) || fallback.message }; +}; + +class McpInspectorLaunchControllerImpl implements McpInspectorLaunchController { + readonly #listeners = new Set(); + readonly #routes: McpInspectorLaunchRoutes; + #launching: Promise | undefined; + /** Count of launches started; a refresh that began under an older count is superseded. */ + #launches = 0; + #model = createMcpInspectorLaunchModel(); + + constructor(options: McpInspectorLaunchControllerOptions) { + this.#routes = options.routes; + } + + get model(): McpInspectorLaunchModel { + return this.#model; + } + + launch(): Promise { + if (this.#launching !== undefined) return this.#launching; + this.#launches += 1; + this.#publish({ type: 'launch' }); + this.#launching = this.#runLaunch().finally(() => { + this.#launching = undefined; + }); + return this.#launching; + } + + /** + * A status read is evidence about the moment it was requested. One that began while a launch + * was in flight, completes while one is in flight, or completes after a later launch began is + * superseded and discarded: it would otherwise turn a fresh `ready` back into `idle` or erase + * a launch diagnostic. + */ + async refresh(): Promise { + const launches = this.#launches; + const beganDuringLaunch = this.#launching !== undefined; + const superseded = (): boolean => beganDuringLaunch || this.#launching !== undefined || launches !== this.#launches; + try { + const status = await this.#routes.inspectorStatus(); + if (superseded()) return; + if (status.state === 'running' && status.url !== undefined) { + this.#publish({ type: 'running', url: status.url }); + } else { + this.#publish({ type: 'stopped' }); + } + } catch (reason) { + if (superseded()) return; + this.#publish({ diagnostic: failureDiagnostic(reason, STATUS_FAILED), type: 'failed' }); + } + } + + subscribe(listener: McpInspectorLaunchListener): () => void { + this.#listeners.add(listener); + this.#notify(listener, this.#model); + return () => { + this.#listeners.delete(listener); + }; + } + + async #runLaunch(): Promise { + try { + const { url } = await this.#routes.inspectorLaunch(); + this.#publish({ type: 'running', url }); + } catch (reason) { + this.#publish({ diagnostic: failureDiagnostic(reason, LAUNCH_FAILED), type: 'failed' }); + } + } + + #publish(event: McpInspectorLaunchEvent): void { + const next = reduceMcpInspectorLaunch(this.#model, event); + if (next === this.#model) return; + this.#model = next; + for (const listener of this.#listeners) this.#notify(listener, next); + } + + #notify(listener: McpInspectorLaunchListener, model: McpInspectorLaunchModel): void { + try { + listener(model); + } catch { + // A view listener must not affect the launch lifecycle. + } + } +} + +export const createMcpInspectorLaunchController = (options: McpInspectorLaunchControllerOptions): McpInspectorLaunchController => + new McpInspectorLaunchControllerImpl(options); diff --git a/packages/workbench/src/mcp/mcp-inspector-launch-model.ts b/packages/workbench/src/mcp/mcp-inspector-launch-model.ts new file mode 100644 index 000000000..9397f2a3a --- /dev/null +++ b/packages/workbench/src/mcp/mcp-inspector-launch-model.ts @@ -0,0 +1,91 @@ +import type { McpSessionInspectorConfig } from '../../../agent-bundle/src/contracts/mcp-session.ts'; +import { isHttpUrl } from '../client-helpers.ts'; +import { deepFreeze } from '../freeze.ts'; + +export type McpInspectorLaunchPhase = 'idle' | 'starting' | 'ready' | 'error'; + +export interface McpInspectorLaunchDiagnostic { + readonly code: string; + readonly message: string; +} + +export interface McpInspectorLaunchModel { + /** Present only while `phase === 'error'`. */ + readonly diagnostic?: McpInspectorLaunchDiagnostic; + readonly phase: McpInspectorLaunchPhase; + /** The Inspector's tokenized base URL; present only while `phase === 'ready'`. */ + readonly url?: string; +} + +export type McpInspectorLaunchEvent = + | Readonly<{ readonly type: 'launch' }> + | Readonly<{ readonly type: 'running'; readonly url: string }> + | Readonly<{ readonly type: 'stopped' }> + | Readonly<{ readonly diagnostic: McpInspectorLaunchDiagnostic; readonly type: 'failed' }>; + +const idleModel: McpInspectorLaunchModel = Object.freeze({ phase: 'idle' }); +const startingModel: McpInspectorLaunchModel = Object.freeze({ phase: 'starting' }); + +export const createMcpInspectorLaunchModel = (): McpInspectorLaunchModel => idleModel; + +/** + * Pure launch-state reducer. Every result is frozen, and a transition that + * would not change the model returns the same object so the controller can + * skip publishing it. + */ +export const reduceMcpInspectorLaunch = ( + model: McpInspectorLaunchModel, + event: McpInspectorLaunchEvent, +): McpInspectorLaunchModel => { + switch (event.type) { + case 'launch': + return model.phase === 'starting' ? model : startingModel; + case 'running': + if (model.phase === 'ready' && model.url === event.url) return model; + return Object.freeze({ phase: 'ready', url: event.url }); + case 'stopped': + // A launch in flight owns the outcome; a stale status poll must not reset it. + if (model.phase === 'starting' || model.phase === 'idle') return model; + return idleModel; + case 'failed': + if ( + model.phase === 'error' + && model.diagnostic?.code === event.diagnostic.code + && model.diagnostic.message === event.diagnostic.message + ) return model; + return deepFreeze({ + diagnostic: { code: event.diagnostic.code, message: event.diagnostic.message }, + phase: 'error', + }); + default: { + const exhaustive: never = event; + throw new TypeError(`Unknown MCP Inspector launch event: ${String(exhaustive)}`); + } + } +}; + +/** + * Inspector 2.x deep link (`serverUrl`, `transport`, `autoConnect`). Only a + * `streamable-http` session can be pre-connected; Inspector 2.x no longer + * spawns a process from a URL, so the link never carries a command, its + * arguments, a working directory, or environment. + */ +export const mcpInspectorDeepLink = (inspectorUrl: string, config: McpSessionInspectorConfig | undefined): string => { + let url: URL; + try { + url = new URL(inspectorUrl); + } catch { + return inspectorUrl; + } + const token = url.searchParams.get('MCP_INSPECTOR_API_TOKEN'); + if ( + config?.launch.kind === 'streamable-http' + && token !== null && token.length > 0 + && isHttpUrl(config.launch.url) + ) { + url.searchParams.set('serverUrl', config.launch.url); + url.searchParams.set('transport', 'http'); + url.searchParams.set('autoConnect', token); + } + return url.href; +}; diff --git a/packages/workbench/src/mcp/mcp-page.css b/packages/workbench/src/mcp/mcp-page.css index c680611bb..74ca969f3 100644 --- a/packages/workbench/src/mcp/mcp-page.css +++ b/packages/workbench/src/mcp/mcp-page.css @@ -174,6 +174,34 @@ gap: 0.5rem; } +/* The Inspector opens through a real link (new tab, no opener), styled like the page's buttons. */ +.mcp-page-inspector-link { + align-items: center; + background: #2468c3; + border: 1px solid #3f82dd; + border-radius: 0.4rem; + color: #fff; + cursor: pointer; + display: inline-flex; + padding: 0.45rem 0.65rem; + text-decoration: none; +} + +.mcp-page-inspector-link:hover, +.mcp-page-inspector-link:focus-visible { + background: #3f82dd; +} + +.mcp-page-inspector-status { + color: #a8b8ca; + margin: 0; +} + +.mcp-page-inspector-error { + color: #ffcf97; + margin: 0; +} + .mcp-page-session-timeout { color: #c5d7ea; min-width: 0; diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index da8a6e076..e8ef3f45b 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -21,6 +21,7 @@ import type { McpAppPreviewProfile, } from './mcp-app-client.ts'; import { createMcpAppFrameRelay } from './mcp-app-frame.tsx'; +import { mcpInspectorDeepLink, type McpInspectorLaunchModel } from './mcp-inspector-launch-model.ts'; import type { McpBrowserSessionInvocation, McpBrowserSessionModel, @@ -51,11 +52,21 @@ export interface McpPageController { subscribe(listener: (model: McpBrowserSessionModel) => void): () => void; } +/** Launches the standalone MCP Inspector through the dev server and tracks its tokenized URL. */ +export interface McpPageInspectorLaunch { + readonly model: McpInspectorLaunchModel; + launch(): Promise; + refresh(): Promise; + subscribe(listener: (model: McpInspectorLaunchModel) => void): () => void; +} + interface McpPageCommonProps { readonly controller: McpPageController; readonly initialBinding?: Partial; /** A validated Routes-page handoff; it selects form state but never executes a call. */ readonly initialToolPrefill?: McpToolPrefill; + /** Absent when the host has no Inspector launcher; the section then offers only the config download. */ + readonly inspectorLaunch?: McpPageInspectorLaunch; readonly onDownloadConfig?: (download: McpConfigDownload) => void; readonly onDownloadTrace?: (download: McpDownload) => void; /** Replaces the terminal controller with a fresh idle controller in the parent. */ @@ -1199,8 +1210,63 @@ const McpPageAppPreview = ({ artifactClient, host, onLifecycleChange, previewPro />; }; +const mcpPageInspectorControl = ( + inspectorLaunch: McpPageInspectorLaunch, + inspectorModel: McpInspectorLaunchModel, + config: McpSessionInspectorConfig | undefined, +): React.ReactElement => { + const launchButton = ; + switch (inspectorModel.phase) { + case 'ready': + // The click still navigates; the refresh only re-syncs a link the server has since invalidated. + return inspectorModel.url === undefined + ? launchButton + : { void inspectorLaunch.refresh(); }} + rel="noopener noreferrer" + target="_blank" + >Open MCP Inspector in a new tab; + case 'starting': + return ; + case 'idle': + case 'error': + return launchButton; + default: { + const exhaustive: never = inspectorModel.phase; + throw new TypeError(`Unknown MCP Inspector launch phase: ${String(exhaustive)}`); + } + } +}; + +const mcpPageInspectorStatusLine = ( + inspectorModel: McpInspectorLaunchModel, + config: McpSessionInspectorConfig | undefined, +): React.ReactElement | undefined => { + switch (inspectorModel.phase) { + case 'starting': + return

Starting the MCP Inspector. The first launch downloads @modelcontextprotocol/inspector and can take up to 30 seconds.

; + case 'error': + return inspectorModel.diagnostic === undefined + ? undefined + :

{inspectorModel.diagnostic.code} {inspectorModel.diagnostic.message}

; + case 'ready': + if (config === undefined) return undefined; + return config.launch.kind === 'streamable-http' + ?

The link pre-connects the Inspector to {config.launch.url}.

+ :

Inspector 2.x does not start a stdio server from a link; add this session’s command and arguments inside the Inspector. The downloaded config carries the same values.

; + case 'idle': + return undefined; + default: { + const exhaustive: never = inspectorModel.phase; + throw new TypeError(`Unknown MCP Inspector launch phase: ${String(exhaustive)}`); + } + } +}; + export const McpPage = (props: McpPageProps) => { - const { controller, initialBinding, initialPreview, initialToolPrefill, onDownloadConfig, onDownloadTrace, onResetSession, registerPreviewClose } = props; + const { controller, initialBinding, initialPreview, initialToolPrefill, inspectorLaunch, onDownloadConfig, onDownloadTrace, onResetSession, registerPreviewClose } = props; const runtimeProps: McpPageRuntimeProps | undefined = 'runtimePreviewDependencies' in props ? props : undefined; const artifactProps: McpPageArtifactProps | undefined = 'runtimePreviewDependencies' in props ? undefined : props; const [runtimeAdmission] = useState(() => runtimeProps === undefined @@ -1252,6 +1318,8 @@ export const McpPage = (props: McpPageProps) => { const [appPreviewBusy, setAppPreviewBusy] = useState(false); const [appPreviewProfile, setAppPreviewProfile] = useState('portable'); const [appHost] = useState(browserMcpAppHost); + // Seeded from the controller so a static render (no effects) already shows the current launch state. + const [inspectorModel, setInspectorModel] = useState(() => inspectorLaunch?.model); const actionSession = useRef(createMcpPageActionSession()); const appPreviewClosePromise = useRef | undefined>(undefined); const appPreviewController = useRef(undefined); @@ -1291,6 +1359,12 @@ export const McpPage = (props: McpPageProps) => { }); }, [initialBinding?.epochId, model.phase, serverCatalogState, serverOptions, targetOptions]); useEffect(() => () => { void appPreviewController.current?.close(); }, []); + useEffect(() => { + if (inspectorLaunch === undefined) return undefined; + const unsubscribe = inspectorLaunch.subscribe(setInspectorModel); + void inspectorLaunch.refresh(); + return unsubscribe; + }, [inspectorLaunch]); const setActiveAppPreviewController = useCallback((next: McpPagePreviewLifecycle | undefined, current?: McpPagePreviewLifecycle): void => { if (current !== undefined && appPreviewController.current !== current) return; @@ -1801,11 +1875,15 @@ export const McpPage = (props: McpPageProps) => {
-

Inspector config

-

Export the selected session’s resolved command and non-secret environment for the standalone Inspector.

- +

MCP Inspector

+

The standalone MCP Inspector is a separate localhost app with its own token URL. It opens in a new tab and is never embedded here; export the selected session’s resolved command and non-secret environment to configure it.

+
+ {inspectorLaunch === undefined || inspectorModel === undefined ? undefined : mcpPageInspectorControl(inspectorLaunch, inspectorModel, config)} + +
+ {inspectorLaunch === undefined || inspectorModel === undefined ? undefined : mcpPageInspectorStatusLine(inspectorModel, config)}
{actionError === undefined && model.diagnostics.length === 0 ? undefined :
diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index 5a1189a51..0aaecf53f 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -9,7 +9,7 @@ import type { } from '../../../agent-bundle/src/contracts/runtime.ts'; import type { JsonObject } from '../../../agent-bundle/src/contracts/runtime.ts'; import { isMcpSessionTarget, type McpSessionTarget } from '../../../agent-bundle/src/contracts/mcp-session.ts'; -import { exactKeys, isRecord } from '../client-helpers.ts'; +import { exactKeys, isLoopbackHttpUrl, isRecord } from '../client-helpers.ts'; import { hasOnlyOwnKeys } from '../strict-json.ts'; export type McpRouteTarget = McpSessionTarget; @@ -64,6 +64,14 @@ export interface McpRouteRuntimeRestart { readonly session: McpRouteRuntimeSession; } +export type McpInspectorRouteState = 'exited' | 'idle' | 'running' | 'starting'; + +/** The dev server's view of the standalone MCP Inspector process; `url` is present only while running. */ +export interface McpInspectorRouteStatus { + readonly state: McpInspectorRouteState; + readonly url?: string; +} + export interface McpRouteCatalog { readonly prompts: readonly unknown[]; readonly resourceTemplates: readonly unknown[]; @@ -406,6 +414,31 @@ const runtimeOperationRequest = (request: DevRuntimeMcpOperationRequest): DevRun throw new McpRouteClientError('AB8015', 'Runtime MCP operation request is not valid.'); }; +const inspectorRouteStates: readonly McpInspectorRouteState[] = Object.freeze(['exited', 'idle', 'running', 'starting']); + +const isInspectorRouteState = (value: unknown): value is McpInspectorRouteState => + (inspectorRouteStates as readonly unknown[]).includes(value); + +const inspectorRouteStatus = (value: unknown): McpInspectorRouteStatus => { + const invalid = (): McpRouteClientError => new McpRouteClientError('AB8019', 'Inspector status route returned an invalid response.'); + const response = isRecord(value) ? asRecord(value) : undefined; + if (response === undefined || !hasExactKeys(response, ['status']) || !isRecord(response.status)) throw invalid(); + const status = response.status; + if ( + !hasOnlyKeys(status, ['state', 'url']) || !isInspectorRouteState(status.state) || + (status.url !== undefined && !isLoopbackHttpUrl(status.url)) + ) throw invalid(); + return Object.freeze({ state: status.state, ...(status.url === undefined ? {} : { url: status.url }) }); +}; + +const inspectorRouteLaunch = (value: unknown): Readonly<{ readonly url: string }> => { + const response = isRecord(value) ? asRecord(value) : undefined; + if (response === undefined || !hasExactKeys(response, ['url']) || !isLoopbackHttpUrl(response.url)) { + throw new McpRouteClientError('AB8019', 'Inspector launch route returned an invalid response.'); + } + return Object.freeze({ url: response.url }); +}; + const encode = (value: string): string => encodeURIComponent(value); const foregroundRoute = (path: string): string => { @@ -787,6 +820,19 @@ export class McpRouteClient { return result; } + async inspectorStatus(): Promise { + return inspectorRouteStatus(await this.#json('/api/inspector/status')); + } + + /** Idempotent while the Inspector is starting or running; the server owns the command it spawns. */ + async inspectorLaunch(): Promise> { + return inspectorRouteLaunch(await this.#json('/api/inspector/launch', { + body: '{}', + headers: { 'content-type': 'application/json' }, + method: 'POST', + })); + } + forgetAuthentication(): void { this.#foreground.forgetAuthentication(); } diff --git a/packages/workbench/tests/contract-imports.test.ts b/packages/workbench/tests/contract-imports.test.ts index 35236657c..57f6f0582 100644 --- a/packages/workbench/tests/contract-imports.test.ts +++ b/packages/workbench/tests/contract-imports.test.ts @@ -5,13 +5,6 @@ import { expect, it } from '@rstest/core'; const srcRoot = fileURLToPath(new URL('../src', import.meta.url)); -// Vendored inspector code and its patches are third-party surface the -// contract boundary does not govern. -const excludedDirectories: ReadonlySet = new Set([ - join('inspector', 'vendor'), - join('inspector', 'patches'), -]); - const sourceFilePattern = /\.(?:ts|tsx)$/u; // Matches static `from '...'` clauses, dynamic `import('...')` calls, and // bare side-effect imports (`import '...'`). @@ -23,7 +16,6 @@ const listSourceFiles = async (directory: string): Promise => for (const entry of await readdir(directory, { withFileTypes: true })) { const entryPath = join(directory, entry.name); if (entry.isDirectory()) { - if (excludedDirectories.has(relative(srcRoot, entryPath))) continue; files.push(...(await listSourceFiles(entryPath))); continue; } diff --git a/packages/workbench/tests/mcp-inspector-launch.test.ts b/packages/workbench/tests/mcp-inspector-launch.test.ts new file mode 100644 index 000000000..35f31aafd --- /dev/null +++ b/packages/workbench/tests/mcp-inspector-launch.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, it } from '@rstest/core'; + +import type { McpSessionInspectorConfig } from '../../agent-bundle/src/contracts/mcp-session.ts'; +import { + createMcpInspectorLaunchController, + type McpInspectorLaunchController, + type McpInspectorLaunchRoutes, +} from '../src/mcp/mcp-inspector-launch-controller.ts'; +import { + createMcpInspectorLaunchModel, + mcpInspectorDeepLink, + reduceMcpInspectorLaunch, + type McpInspectorLaunchModel, +} from '../src/mcp/mcp-inspector-launch-model.ts'; +import type { McpInspectorRouteStatus } from '../src/mcp/mcp-route-client.ts'; + +const inspectorUrl = 'http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=tok-123'; +const launchFailure = Object.freeze({ code: 'AB8112', message: 'MCP Inspector could not be launched.' }); +const routesUnavailable = Object.freeze({ code: 'AB8113', message: 'Inspector routes are not available.' }); + +const stdioConfig: McpSessionInspectorConfig = { + launch: { + args: ['dist/mcp/weather.mjs', '--flag'], + command: 'node', + cwd: '/proj', + env: { SAFE: 'true', SECRET_TOKEN: '[redacted]' }, + kind: 'stdio', + }, + origin: 'artifact', +}; + +const streamableConfig = (url: string): McpSessionInspectorConfig => ({ launch: { kind: 'streamable-http', url }, origin: 'artifact' }); + +const codedError = (code: string, message: string): Error => Object.assign(new Error(message), { code }); + +const deferred = () => { + let reject: (reason?: unknown) => void = () => undefined; + let resolve: (value: Value) => void = () => undefined; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +}; + +const tick = async (): Promise => new Promise((resolve) => setImmediate(resolve)); + +interface FakeRouteBehavior { + readonly launch?: () => Promise>; + readonly status?: () => Promise; +} + +const fakeRoutes = (behavior: FakeRouteBehavior = {}) => { + const calls = { launch: 0, status: 0 }; + const routes: McpInspectorLaunchRoutes = { + inspectorLaunch: async () => { + calls.launch += 1; + return behavior.launch === undefined ? { url: inspectorUrl } : behavior.launch(); + }, + inspectorStatus: async () => { + calls.status += 1; + return behavior.status === undefined ? { state: 'idle' } : behavior.status(); + }, + }; + return { calls, routes }; +}; + +const observed = (controller: McpInspectorLaunchController): McpInspectorLaunchModel[] => { + const models: McpInspectorLaunchModel[] = []; + controller.subscribe((model) => { models.push(model); }); + return models; +}; + +describe('MCP Inspector launch model', () => { + it('starts idle and frozen without a URL or diagnostic', () => { + const model = createMcpInspectorLaunchModel(); + + expect(model).toEqual({ phase: 'idle' }); + expect(model.url).toBeUndefined(); + expect(model.diagnostic).toBeUndefined(); + expect(Object.isFrozen(model)).toBe(true); + }); + + it('walks idle to starting to ready and back to idle through launch, running, and stopped', () => { + const idle = createMcpInspectorLaunchModel(); + const starting = reduceMcpInspectorLaunch(idle, { type: 'launch' }); + const ready = reduceMcpInspectorLaunch(starting, { type: 'running', url: inspectorUrl }); + const stopped = reduceMcpInspectorLaunch(ready, { type: 'stopped' }); + + expect(starting).toEqual({ phase: 'starting' }); + expect(starting.url).toBeUndefined(); + expect(starting.diagnostic).toBeUndefined(); + expect(ready).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(ready.diagnostic).toBeUndefined(); + expect(stopped).toEqual({ phase: 'idle' }); + expect(stopped.url).toBeUndefined(); + for (const model of [idle, starting, ready, stopped]) expect(Object.isFrozen(model)).toBe(true); + }); + + it('ignores stopped while starting and returns the same model reference', () => { + const starting = reduceMcpInspectorLaunch(createMcpInspectorLaunchModel(), { type: 'launch' }); + + expect(reduceMcpInspectorLaunch(starting, { type: 'stopped' })).toBe(starting); + expect(reduceMcpInspectorLaunch(createMcpInspectorLaunchModel(), { type: 'stopped' })).toEqual({ phase: 'idle' }); + }); + + it('records a failure without a URL and clears the diagnostic on the next launch', () => { + const starting = reduceMcpInspectorLaunch(createMcpInspectorLaunchModel(), { type: 'launch' }); + const ready = reduceMcpInspectorLaunch(starting, { type: 'running', url: inspectorUrl }); + const failed = reduceMcpInspectorLaunch(ready, { diagnostic: launchFailure, type: 'failed' }); + const relaunched = reduceMcpInspectorLaunch(failed, { type: 'launch' }); + + expect(failed).toEqual({ diagnostic: launchFailure, phase: 'error' }); + expect(failed.url).toBeUndefined(); + expect(relaunched).toEqual({ phase: 'starting' }); + expect(relaunched.diagnostic).toBeUndefined(); + expect(relaunched.url).toBeUndefined(); + expect(Object.isFrozen(failed)).toBe(true); + expect(Object.isFrozen(relaunched)).toBe(true); + }); + + it('accepts running and failed from any phase so a refresh can adopt an already-running Inspector', () => { + const idle = createMcpInspectorLaunchModel(); + const ready = reduceMcpInspectorLaunch(idle, { type: 'running', url: inspectorUrl }); + const failed = reduceMcpInspectorLaunch(idle, { diagnostic: routesUnavailable, type: 'failed' }); + + expect(ready).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(failed).toEqual({ diagnostic: routesUnavailable, phase: 'error' }); + expect(reduceMcpInspectorLaunch(failed, { type: 'stopped' })).toEqual({ phase: 'idle' }); + expect(reduceMcpInspectorLaunch(ready, { type: 'launch' })).toEqual({ phase: 'starting' }); + expect(Object.isFrozen(ready)).toBe(true); + expect(Object.isFrozen(failed)).toBe(true); + }); +}); + +describe('mcpInspectorDeepLink', () => { + it('never carries a stdio command, arguments, cwd, or environment into the link', () => { + const link = mcpInspectorDeepLink(inspectorUrl, stdioConfig); + + expect(link).toBe(inspectorUrl); + for (const leak of ['serverUrl', 'serverCommand', 'serverArgs', 'autoConnect', 'node', 'weather', 'SAFE', 'redacted', '/proj']) { + expect(link).not.toContain(leak); + } + }); + + it('normalizes a slashless Inspector origin while preserving the token', () => { + expect(mcpInspectorDeepLink('http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=tok-123', stdioConfig)).toBe(inspectorUrl); + expect(mcpInspectorDeepLink('http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=tok-123', undefined)).toBe(inspectorUrl); + }); + + it('adds the Inspector 2.x auto-connect parameters for a streamable HTTP session', () => { + const link = new URL(mcpInspectorDeepLink(inspectorUrl, streamableConfig('http://127.0.0.1:3100/mcp/host/weather'))); + + expect(link.origin).toBe('http://127.0.0.1:6274'); + expect(link.pathname).toBe('/'); + expect([...link.searchParams.keys()].sort()).toEqual(['MCP_INSPECTOR_API_TOKEN', 'autoConnect', 'serverUrl', 'transport']); + expect(link.searchParams.get('MCP_INSPECTOR_API_TOKEN')).toBe('tok-123'); + expect(link.searchParams.get('serverUrl')).toBe('http://127.0.0.1:3100/mcp/host/weather'); + expect(link.searchParams.get('transport')).toBe('http'); + expect(link.searchParams.get('autoConnect')).toBe('tok-123'); + }); + + it('round-trips a server URL with its own query string through one encoded parameter', () => { + const serverUrl = 'http://127.0.0.1:3100/mcp?a=1&b=2'; + const link = new URL(mcpInspectorDeepLink(inspectorUrl, streamableConfig(serverUrl))); + + expect(link.searchParams.get('serverUrl')).toBe(serverUrl); + expect(link.searchParams.has('a')).toBe(false); + expect(link.searchParams.has('b')).toBe(false); + expect([...link.searchParams.keys()]).toHaveLength(4); + }); + + it('leaves the link untouched without a config, without a token, or with a non-HTTP server URL', () => { + const tokenless = 'http://127.0.0.1:6274/'; + + expect(mcpInspectorDeepLink(inspectorUrl, undefined)).toBe(inspectorUrl); + expect(mcpInspectorDeepLink(tokenless, streamableConfig('http://127.0.0.1:3100/mcp'))).toBe(tokenless); + expect(mcpInspectorDeepLink('http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=', streamableConfig('http://127.0.0.1:3100/mcp'))).toBe('http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN='); + expect(mcpInspectorDeepLink(inspectorUrl, streamableConfig('javascript:alert(1)'))).toBe(inspectorUrl); + expect(mcpInspectorDeepLink(inspectorUrl, streamableConfig('not a url'))).toBe(inspectorUrl); + }); + + it('returns an unparseable Inspector URL unchanged', () => { + expect(mcpInspectorDeepLink('not a url', streamableConfig('http://127.0.0.1:3100/mcp'))).toBe('not a url'); + }); +}); + +describe('MCP Inspector launch controller', () => { + it('publishes starting then ready and shares one in-flight launch across concurrent calls', async () => { + const launch = deferred>(); + const { calls, routes } = fakeRoutes({ launch: () => launch.promise }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + + expect(models).toEqual([{ phase: 'idle' }]); + const first = controller.launch(); + await tick(); + expect(controller.model.phase).toBe('starting'); + const second = controller.launch(); + await tick(); + expect(calls.launch).toBe(1); + + launch.resolve({ url: inspectorUrl }); + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBeUndefined(); + + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(models).toEqual([{ phase: 'idle' }, { phase: 'starting' }, { phase: 'ready', url: inspectorUrl }]); + expect(calls.launch).toBe(1); + expect(calls.status).toBe(0); + }); + + it('maps a rejected launch to an error diagnostic, resolves, and lets a later launch retry', async () => { + let failing = true; + const { calls, routes } = fakeRoutes({ + launch: async () => { + if (failing) throw codedError(launchFailure.code, launchFailure.message); + return { url: inspectorUrl }; + }, + }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + + await expect(controller.launch()).resolves.toBeUndefined(); + + expect(controller.model).toEqual({ diagnostic: launchFailure, phase: 'error' }); + expect(controller.model.url).toBeUndefined(); + expect(models.map((model) => model.phase)).toEqual(['idle', 'starting', 'error']); + + failing = false; + await controller.launch(); + + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(calls.launch).toBe(2); + expect(models.map((model) => model.phase)).toEqual(['idle', 'starting', 'error', 'starting', 'ready']); + }); + + it('falls back to a launch diagnostic code and message when the failure carries none', async () => { + const uncoded = createMcpInspectorLaunchController({ routes: fakeRoutes({ launch: async () => { throw new Error('spawn ENOENT'); } }).routes }); + const silent = createMcpInspectorLaunchController({ routes: fakeRoutes({ launch: async () => { throw new Error(''); } }).routes }); + + await uncoded.launch(); + await silent.launch(); + + expect(uncoded.model).toEqual({ diagnostic: { code: 'mcp.inspector.launch.failed', message: 'spawn ENOENT' }, phase: 'error' }); + expect(silent.model).toEqual({ diagnostic: { code: 'mcp.inspector.launch.failed', message: 'MCP Inspector could not be launched.' }, phase: 'error' }); + }); + + it('adopts a running Inspector from a status refresh and returns to idle once it exits', async () => { + let status: McpInspectorRouteStatus = { state: 'running', url: inspectorUrl }; + const { calls, routes } = fakeRoutes({ status: async () => status }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + + await expect(controller.refresh()).resolves.toBeUndefined(); + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + + status = { state: 'exited' }; + await controller.refresh(); + + expect(controller.model).toEqual({ phase: 'idle' }); + expect(controller.model.url).toBeUndefined(); + expect(calls.status).toBe(2); + expect(calls.launch).toBe(0); + expect(models.map((model) => model.phase)).toEqual(['idle', 'ready', 'idle']); + }); + + it('does not publish when a status refresh lands while a launch is still starting', async () => { + const launch = deferred>(); + const { calls, routes } = fakeRoutes({ launch: () => launch.promise, status: async () => ({ state: 'starting' }) }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + const pending = controller.launch(); + await tick(); + const starting = controller.model; + + await expect(controller.refresh()).resolves.toBeUndefined(); + + expect(starting.phase).toBe('starting'); + expect(controller.model).toBe(starting); + expect(calls.status).toBe(1); + expect(models).toHaveLength(2); + + launch.resolve({ url: inspectorUrl }); + await pending; + + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(models.map((model) => model.phase)).toEqual(['idle', 'starting', 'ready']); + }); + + it('discards a status refresh that began before a launch and lands after the launch is ready', async () => { + const status = deferred(); + const { routes } = fakeRoutes({ status: () => status.promise }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + + const refreshing = controller.refresh(); + await controller.launch(); + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + + status.resolve({ state: 'idle' }); + await expect(refreshing).resolves.toBeUndefined(); + + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(models.map((model) => model.phase)).toEqual(['idle', 'starting', 'ready']); + }); + + it('keeps a launch diagnostic when a superseded status refresh reports idle afterwards', async () => { + const status = deferred(); + const { routes } = fakeRoutes({ + launch: async () => { throw codedError(launchFailure.code, launchFailure.message); }, + status: () => status.promise, + }); + const controller = createMcpInspectorLaunchController({ routes }); + + const refreshing = controller.refresh(); + await controller.launch(); + expect(controller.model).toEqual({ diagnostic: launchFailure, phase: 'error' }); + + status.resolve({ state: 'idle' }); + await refreshing; + + expect(controller.model).toEqual({ diagnostic: launchFailure, phase: 'error' }); + }); + + it('ignores a status refresh failure that lands while a launch is in flight', async () => { + const launch = deferred>(); + const { routes } = fakeRoutes({ + launch: () => launch.promise, + status: async () => { throw codedError(routesUnavailable.code, routesUnavailable.message); }, + }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + const pending = controller.launch(); + + await expect(controller.refresh()).resolves.toBeUndefined(); + expect(controller.model).toEqual({ phase: 'starting' }); + + launch.resolve({ url: inspectorUrl }); + await pending; + + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(models.map((model) => model.phase)).toEqual(['idle', 'starting', 'ready']); + }); + + it('discards a status refresh that began during a launch and lands after the launch settled', async () => { + const launch = deferred>(); + const status = deferred(); + const { routes } = fakeRoutes({ launch: () => launch.promise, status: () => status.promise }); + const controller = createMcpInspectorLaunchController({ routes }); + const models = observed(controller); + + const pending = controller.launch(); + const refreshing = controller.refresh(); + launch.resolve({ url: inspectorUrl }); + await pending; + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + + status.resolve({ state: 'idle' }); + await expect(refreshing).resolves.toBeUndefined(); + + expect(controller.model).toEqual({ phase: 'ready', url: inspectorUrl }); + expect(models.map((model) => model.phase)).toEqual(['idle', 'starting', 'ready']); + }); + + it('keeps a launch diagnostic when a refresh that began during the launch fails afterwards', async () => { + const launch = deferred>(); + const status = deferred(); + const { routes } = fakeRoutes({ launch: () => launch.promise, status: () => status.promise }); + const controller = createMcpInspectorLaunchController({ routes }); + + const pending = controller.launch(); + const refreshing = controller.refresh(); + launch.reject(codedError(launchFailure.code, launchFailure.message)); + await pending; + expect(controller.model).toEqual({ diagnostic: launchFailure, phase: 'error' }); + + status.reject(codedError(routesUnavailable.code, routesUnavailable.message)); + await expect(refreshing).resolves.toBeUndefined(); + + expect(controller.model).toEqual({ diagnostic: launchFailure, phase: 'error' }); + }); + + it('applies a status refresh that begins after a launch has settled', async () => { + let status: McpInspectorRouteStatus = { state: 'running', url: inspectorUrl }; + const { routes } = fakeRoutes({ status: async () => status }); + const controller = createMcpInspectorLaunchController({ routes }); + + await controller.launch(); + status = { state: 'exited' }; + await controller.refresh(); + + expect(controller.model).toEqual({ phase: 'idle' }); + }); + + it('maps a rejected status refresh to an error diagnostic without rejecting', async () => { + const coded = createMcpInspectorLaunchController({ + routes: fakeRoutes({ status: async () => { throw codedError(routesUnavailable.code, routesUnavailable.message); } }).routes, + }); + const silent = createMcpInspectorLaunchController({ routes: fakeRoutes({ status: async () => { throw new Error(''); } }).routes }); + + await expect(coded.refresh()).resolves.toBeUndefined(); + await expect(silent.refresh()).resolves.toBeUndefined(); + + expect(coded.model).toEqual({ diagnostic: routesUnavailable, phase: 'error' }); + expect(silent.model).toEqual({ diagnostic: { code: 'mcp.inspector.status.failed', message: 'MCP Inspector status is not available.' }, phase: 'error' }); + }); + + it('notifies subscribers immediately, survives a throwing listener, and stops after unsubscribe', async () => { + const controller = createMcpInspectorLaunchController({ routes: fakeRoutes().routes }); + const phases: string[] = []; + controller.subscribe(() => { throw new Error('listener failure'); }); + const unsubscribe = controller.subscribe((model) => { phases.push(model.phase); }); + + expect(phases).toEqual(['idle']); + await expect(controller.launch()).resolves.toBeUndefined(); + expect(phases).toEqual(['idle', 'starting', 'ready']); + + unsubscribe(); + await controller.refresh(); + + expect(controller.model).toEqual({ phase: 'idle' }); + expect(phases).toEqual(['idle', 'starting', 'ready']); + }); +}); diff --git a/packages/workbench/tests/mcp-page.test.ts b/packages/workbench/tests/mcp-page.test.ts index 722a87882..c37e0e885 100644 --- a/packages/workbench/tests/mcp-page.test.ts +++ b/packages/workbench/tests/mcp-page.test.ts @@ -10,6 +10,7 @@ import { type McpBrowserSessionModel, } from '../src/mcp/mcp-session-model.ts'; import type { McpAppPreviewClient, McpAppRuntimePreviewProps } from '../src/mcp/mcp-app-preview.tsx'; +import type { McpInspectorLaunchModel } from '../src/mcp/mcp-inspector-launch-model.ts'; import { createMcpSessionController, type McpSessionControllerClient, @@ -33,6 +34,7 @@ import { supportedMcpAppPreviewProfiles, type McpPageArtifactProps, type McpPageController, + type McpPageInspectorLaunch, type McpPageRuntimeProps, } from '../src/mcp/mcp-page.tsx'; import * as mcpPage from '../src/mcp/mcp-page.tsx'; @@ -1105,3 +1107,130 @@ describe('MCP page', () => { expect(mcpPageSessionControls('idle', actions.pending, false)).toMatchObject({ close: true, open: false }); }); }); + +describe('MCP page inspector launch', () => { + const inspectorUrl = 'http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=tok-123'; + const launchButton = '>Open MCP Inspector'; + const linkText = 'Open MCP Inspector in a new tab'; + + const inspectorLaunchFor = (inspectorModel: McpInspectorLaunchModel): McpPageInspectorLaunch => ({ + launch: async () => undefined, + model: inspectorModel, + refresh: async () => undefined, + subscribe: (listener) => { + listener(inspectorModel); + return () => undefined; + }, + }); + + const inspectorMarkup = (inspectorModel: McpInspectorLaunchModel, session: McpBrowserSessionModel = model): string => renderToStaticMarkup(createElement(McpPage, { + controller: { ...controller(), model: session }, + epochOptions: ['epoch-1'], + inspectorLaunch: inspectorLaunchFor(inspectorModel), + onDownloadConfig: () => undefined, + targetOptions: ['codex'], + })); + + const inspectorLink = (markup: string): string => { + const anchor = /]*class="mcp-page-inspector-link"[^>]*>/u.exec(markup); + if (anchor === null) throw new Error('Expected an Inspector link.'); + return anchor[0]; + }; + + // Static markup escapes `&` inside attributes; undo it to parse the href as a URL. + const inspectorHref = (anchor: string): string => { + const href = /href="([^"]*)"/u.exec(anchor); + if (href === null) throw new Error('Expected an Inspector link href.'); + return href[1]!.replaceAll('&', '&'); + }; + + it('offers a launch button while idle without a link, status line, or inspector error', () => { + const markup = inspectorMarkup({ phase: 'idle' }); + + expect(markup).toContain('

MCP Inspector

'); + expect(markup).toContain('aria-label="Inspector actions"'); + expect(markup).toContain('never embedded here'); + expect(markup).toContain(launchButton); + expect(markup).toContain('Download Inspector config'); + expect(markup).not.toContain(linkText); + expect(markup).not.toContain('mcp-page-inspector-link'); + expect(markup).not.toContain('mcp-page-inspector-status'); + expect(markup).not.toContain('mcp-page-inspector-error'); + }); + + it('disables the control and explains the startup budget while starting', () => { + const markup = inspectorMarkup({ phase: 'starting' }); + + expect(markup).toContain(''); + expect(markup).toContain('

Starting the MCP Inspector.'); + expect(markup).toContain('can take up to 30 seconds'); + expect(markup).not.toContain(launchButton); + expect(markup).not.toContain(linkText); + expect(markup).not.toContain('mcp-page-inspector-error'); + }); + + it('renders a new-tab link to the tokenized Inspector URL without leaking the stdio launch', () => { + const markup = inspectorMarkup({ phase: 'ready', url: inspectorUrl }); + const anchor = inspectorLink(markup); + + expect(anchor).toContain(`href="${inspectorUrl}"`); + expect(anchor).toContain('target="_blank"'); + expect(anchor).toContain('rel="noopener noreferrer"'); + expect(inspectorHref(anchor)).toBe(inspectorUrl); + expect(markup).toContain(linkText); + expect(markup).toContain('does not start a stdio server from a link'); + expect(markup).not.toContain('serverUrl'); + expect(markup).not.toContain('autoConnect'); + expect(markup).not.toContain(launchButton); + expect(markup).not.toContain('mcp-page-inspector-error'); + expect(markup).toContain('Download Inspector config'); + }); + + it('deep-links a streamable HTTP session into the Inspector', () => { + const serverUrl = 'http://127.0.0.1:3100/mcp/host/weather'; + const session = reducedModelForConfig({ launch: { kind: 'streamable-http', url: serverUrl }, origin: 'artifact' }); + const markup = inspectorMarkup({ phase: 'ready', url: inspectorUrl }, session); + const anchor = inspectorLink(markup); + const href = new URL(inspectorHref(anchor)); + + expect(anchor).toContain('&serverUrl='); + expect(anchor).toContain('&transport=http'); + expect(anchor).toContain('&autoConnect=tok-123'); + expect(anchor).toContain('target="_blank"'); + expect(anchor).toContain('rel="noopener noreferrer"'); + expect(href.origin).toBe('http://127.0.0.1:6274'); + expect([...href.searchParams.keys()].sort()).toEqual(['MCP_INSPECTOR_API_TOKEN', 'autoConnect', 'serverUrl', 'transport']); + expect(href.searchParams.get('MCP_INSPECTOR_API_TOKEN')).toBe('tok-123'); + expect(href.searchParams.get('serverUrl')).toBe(serverUrl); + expect(href.searchParams.get('transport')).toBe('http'); + expect(href.searchParams.get('autoConnect')).toBe('tok-123'); + expect(markup).toContain(`The link pre-connects the Inspector to ${serverUrl}.`); + expect(markup).not.toContain('does not start a stdio server from a link'); + }); + + it('surfaces a launch failure inline and keeps the launch button available', () => { + const markup = inspectorMarkup({ diagnostic: { code: 'AB8112', message: 'MCP Inspector could not be launched.' }, phase: 'error' }); + + expect(markup).toContain('

'); + expect(markup).toContain(launchButton); + expect(markup).not.toContain(linkText); + expect(markup).not.toContain('mcp-page-inspector-link'); + expect(markup).not.toContain('mcp-page-inspector-status'); + }); + + it('renders only the config export when no launcher is provided', () => { + const markup = renderToStaticMarkup(createElement(McpPage, { + controller: controller(), + epochOptions: ['epoch-1'], + onDownloadConfig: () => undefined, + targetOptions: ['codex'], + })); + + expect(markup).toContain('

MCP Inspector

'); + expect(markup).toContain('Download Inspector config'); + expect(markup).not.toContain('Open MCP Inspector'); + expect(markup).not.toContain('mcp-page-inspector-link'); + expect(markup).not.toContain('mcp-page-inspector-status'); + expect(markup).not.toContain('mcp-page-inspector-error'); + }); +}); diff --git a/packages/workbench/tests/mcp-route-client.test.ts b/packages/workbench/tests/mcp-route-client.test.ts index ff7dc6b4d..fd175fb9c 100644 --- a/packages/workbench/tests/mcp-route-client.test.ts +++ b/packages/workbench/tests/mcp-route-client.test.ts @@ -1,6 +1,6 @@ -import { expect, it } from '@rstest/core'; +import { describe, expect, it } from '@rstest/core'; -import { ForegroundRouteClient, McpRouteClient } from '../src/mcp/mcp-route-client.ts'; +import { ForegroundRouteClient, McpRouteClient, McpRouteClientError } from '../src/mcp/mcp-route-client.ts'; const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, @@ -176,3 +176,127 @@ for (const [description, body] of invalidSessionBodies) { expect(routePaths).toEqual([]); }); } + +const foregroundSession = Object.freeze({ + cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', + instanceId: 'foreground-instance-a', + origin: 'http://127.0.0.1:4100', + token: 'foreground-secret', +}); + +interface RecordedRouteRequest { + readonly body: unknown; + readonly headers: Headers; + readonly method: string; + readonly path: string; +} + +const inspectorRouteClient = (respond: (request: RecordedRouteRequest) => Response) => { + const requests: RecordedRouteRequest[] = []; + const foreground = new ForegroundRouteClient({ + fetch: async (input, init) => { + if (String(input) === '/api/project/session') return json(foregroundSession); + const request: RecordedRouteRequest = { + body: init?.body, + headers: new Headers(init?.headers), + method: init?.method ?? 'GET', + path: String(input), + }; + requests.push(request); + return respond(request); + }, + }); + return { client: new McpRouteClient({ foreground }), requests }; +}; + +describe('MCP route client inspector routes', () => { + const inspectorUrl = 'http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=tok'; + + it('reads the Inspector status with the foreground session header', async () => { + const { client, requests } = inspectorRouteClient(() => json({ status: { state: 'running', url: inspectorUrl } })); + + const status = await client.inspectorStatus(); + + expect(status).toEqual({ state: 'running', url: inspectorUrl }); + expect(Object.isFrozen(status)).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ method: 'GET', path: '/api/inspector/status' }); + expect(requests[0]!.body).toBeUndefined(); + expect(requests[0]!.headers.get('x-agent-bundle-session')).toBe('foreground-secret'); + }); + + it('reads a not-running Inspector status without a URL', async () => { + const { client } = inspectorRouteClient(() => json({ status: { state: 'idle' } })); + + await expect(client.inspectorStatus()).resolves.toEqual({ state: 'idle' }); + }); + + const invalidStatusBodies: readonly [string, unknown][] = [ + ['an unknown state', { status: { state: 'bogus' } }], + ['an unexpected status field', { status: { extra: 1, state: 'idle' } }], + ['a non-HTTP Inspector URL', { status: { state: 'running', url: 'javascript:alert(1)' } }], + ['a non-loopback Inspector URL', { status: { state: 'running', url: 'https://inspector.example.com/?MCP_INSPECTOR_API_TOKEN=tok' } }], + ['an all-interfaces Inspector URL', { status: { state: 'running', url: 'http://0.0.0.0:6274/?MCP_INSPECTOR_API_TOKEN=tok' } }], + ['an Inspector URL carrying credentials', { status: { state: 'running', url: 'http://user:pass@127.0.0.1:6274/' } }], + ['a missing status', {}], + ]; + + for (const [description, body] of invalidStatusBodies) { + it(`rejects an Inspector status response with ${description}`, async () => { + const { client } = inspectorRouteClient(() => json(body)); + + const status = client.inspectorStatus(); + + await expect(status).rejects.toBeInstanceOf(McpRouteClientError); + await expect(status).rejects.toMatchObject({ code: 'AB8019' }); + }); + } + + it('launches the Inspector with an empty JSON object body and the foreground session header', async () => { + const { client, requests } = inspectorRouteClient(() => json({ url: inspectorUrl })); + + const launched = await client.inspectorLaunch(); + + expect(launched).toEqual({ url: inspectorUrl }); + expect(Object.isFrozen(launched)).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ body: '{}', method: 'POST', path: '/api/inspector/launch' }); + expect(requests[0]!.headers.get('content-type')).toBe('application/json'); + expect(requests[0]!.headers.get('x-agent-bundle-session')).toBe('foreground-secret'); + }); + + it('surfaces the server launch diagnostic as a typed MCP route error', async () => { + const { client } = inspectorRouteClient(() => json({ diagnostic: { code: 'AB8112', message: 'MCP Inspector could not be launched.' } }, 502)); + + const launch = client.inspectorLaunch(); + + await expect(launch).rejects.toBeInstanceOf(McpRouteClientError); + await expect(launch).rejects.toMatchObject({ code: 'AB8112', message: 'MCP Inspector could not be launched.' }); + }); + + const invalidLaunchBodies: readonly [string, unknown][] = [ + ['a non-HTTP Inspector URL', { url: 'javascript:alert(1)' }], + ['a non-loopback Inspector URL', { url: 'https://inspector.example.com/?MCP_INSPECTOR_API_TOKEN=tok' }], + ['an unexpected field', { extra: true, url: inspectorUrl }], + ['a missing URL', {}], + ]; + + it('accepts every loopback spelling for the Inspector URL', async () => { + for (const url of ['http://localhost:6274/?MCP_INSPECTOR_API_TOKEN=tok', 'http://[::1]:6274/?MCP_INSPECTOR_API_TOKEN=tok', inspectorUrl]) { + const { client } = inspectorRouteClient(() => json({ url })); + + await expect(client.inspectorLaunch()).resolves.toEqual({ url }); + } + }); + + for (const [description, body] of invalidLaunchBodies) { + it(`rejects an Inspector launch response with ${description}`, async () => { + const { client } = inspectorRouteClient(() => json(body)); + + const launch = client.inspectorLaunch(); + + await expect(launch).rejects.toBeInstanceOf(McpRouteClientError); + await expect(launch).rejects.toMatchObject({ code: 'AB8019' }); + }); + } +}); diff --git a/packages/workbench/tests/support/workbench-browser-modules.ts b/packages/workbench/tests/support/workbench-browser-modules.ts index 18baad6ab..9f7833d7e 100644 --- a/packages/workbench/tests/support/workbench-browser-modules.ts +++ b/packages/workbench/tests/support/workbench-browser-modules.ts @@ -2,7 +2,6 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; const workbenchRoot = join(import.meta.dirname, '..', '..'); -const vendorRoot = join(workbenchRoot, 'src', 'inspector', 'vendor'); const requireFromWorkbench = createRequire(join(workbenchRoot, 'package.json')); export const workbenchNodeModules = join(workbenchRoot, 'node_modules'); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 7cc07c239..42ad8a501 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -32,7 +32,7 @@ These are contracts, not defaults: | Overview | Project identity, normalized model, and diagnostics. | | Skills | Every Skill document, including each host's lowered output. | | Artifacts | The artifact tree with provenance and epoch comparison. | -| MCP | An artifact-bound playground with the raw protocol trace. | +| MCP | An artifact-bound playground with the raw protocol trace, MCP App previews, and a launcher for the standalone MCP Inspector. | | Hooks | A playground that runs the emitted hook wrapper. | | Playground | A durable, ordered trace with replay and export. | | Evals | Eval runs and run comparisons. | @@ -50,6 +50,32 @@ came from one generated server built from one set of inputs. standalone in a plain browser tab through `agent-bundle serve-app`; see [Serving an App standalone](../authoring/mcp.mdx#serving-an-app-standalone). +## Standalone MCP Inspector + +The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is a separate localhost app +with its own token URL. The Workbench never embeds it; the **MCP Inspector** section of the MCP page +launches it on demand and hands you a link. + +- **Open MCP Inspector** asks the dev server to run `npx --yes @modelcontextprotocol/inspector` in + the project root, with `MCP_AUTO_OPEN_ENABLED=false` so the Inspector does not open a window of + its own. The first launch downloads the package and can take up to 30 seconds. The browser never + chooses the command, its arguments, its working directory, or its environment. +- Once the Inspector prints its tokenized localhost URL, the dev server returns it and the page + renders **Open MCP Inspector in a new tab**. The link opens a new tab with + `rel="noopener noreferrer"`. If the Inspector is already running when the page loads, the link + appears immediately. +- For a `streamable-http` session the link is an Inspector 2.x deep link: `serverUrl` is the + session's server URL (credentials and query parameters already stripped), `transport=http`, and + `autoConnect` carries the Inspector's own token, so the Inspector connects to that server on + load. +- Inspector 2.x no longer accepts a command in the URL — upstream removed `serverCommand` and + `serverArgs` — so a `stdio` session cannot be deep-linked. Add the command inside the Inspector + instead; **Download Inspector config** exports the selected session's resolved command, + arguments, and non-secret environment for exactly that. +- Failures surface inline on the page: `AB8112` when the Inspector could not be launched, exited + before publishing a URL, or did not publish one within the 30-second startup budget; `AB8113` + when the Inspector routes are not available. + ## Playground owns its trace Only actions started in Playground join its ordered durable trace. Hook and MCP page operations diff --git a/website/docs/en/reference/security.mdx b/website/docs/en/reference/security.mdx index 0c69c6265..1e02ab307 100644 --- a/website/docs/en/reference/security.mdx +++ b/website/docs/en/reference/security.mdx @@ -89,7 +89,8 @@ root, and an MCP server name that would traverse out of its state root are all r The package publishes no example RSC provider and no host credentials. The native Claude and Codex eval harnesses (`runClaudeTrial`, `runCodexEvalTrial`, `--harness claude|codex`) drive a CLI you -have already installed and signed in to; they carry no credentials of their own. Third-party notices, including the vendored MCP Inspector snapshot's license and -provenance, ship in the published package. +have already installed and signed in to; they carry no credentials of their own. Third-party +notices, including the MIT license and provenance of the MCP App renderer derived from the MCP +Inspector's `AppRenderer`, ship in the published package. See [Limitations](./limitations.mdx) for what the evidence surfaces do **not** prove. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 566c74f37..ac9b24a63 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -28,7 +28,7 @@ npx agent-bundle dev --root . --port 3100 --no-open | Overview | 项目标识、规范化模型与诊断。 | | Skills | 每个 Skill 文档,包括各宿主降级后的输出。 | | Artifacts | 带 provenance 与 epoch 对比的产物树。 | -| MCP | 绑定到产物的 playground,带原始协议轨迹。 | +| MCP | 绑定到产物的 playground,带原始协议轨迹、MCP App 预览,以及独立 MCP Inspector 的启动器。 | | Hooks | 运行输出的钩子包装层的 playground。 | | Playground | 可重放、可导出的持久有序轨迹。 | | Evals | eval 运行与运行对比。 | @@ -44,6 +44,27 @@ epoch。这正是协议轨迹有意义的原因:其中每一帧都来自同一 - 兼容的 MCP App 通过同一个已绑定会话预览。同一套宿主栈也能通过 `agent-bundle serve-app` 在一个普通 浏览器标签页里独立提供某个 App;见[独立提供 App](../authoring/mcp.mdx#独立提供-app)。 +## 独立的 MCP Inspector + +[MCP Inspector](https://github.com/modelcontextprotocol/inspector) 是一个独立的 localhost 应用,拥有自己 +的 token URL。Workbench 绝不会把它内嵌进来;MCP 页面的 **MCP Inspector** 区块按需启动它,然后交给你一个 +链接。 + +- **Open MCP Inspector** 会请求开发服务器在项目根目录运行 `npx --yes @modelcontextprotocol/inspector`, + 并设置 `MCP_AUTO_OPEN_ENABLED=false`,使 Inspector 不会自行打开窗口。首次启动会下载该包,最多可能需要 + 30 秒。浏览器绝不选择命令、参数、工作目录或环境。 +- 一旦 Inspector 打印出带 token 的 localhost URL,开发服务器就把它返回给页面,页面随即渲染 + **Open MCP Inspector in a new tab**。该链接以 `rel="noopener noreferrer"` 在新标签页中打开。如果页面 + 加载时 Inspector 已经在运行,链接会立即出现。 +- 对于 `streamable-http` 会话,该链接是一条 Inspector 2.x 深度链接:`serverUrl` 是该会话的服务器 URL + (凭据与查询参数已被剥离),`transport=http`,而 `autoConnect` 携带 Inspector 自己的 token,因此 + Inspector 会在加载时连接到该服务器。 +- Inspector 2.x 不再接受 URL 中的命令——上游移除了 `serverCommand` 与 `serverArgs`——因此 `stdio` 会话 + 无法被深度链接。请改为在 Inspector 内部添加命令;**Download Inspector config** 会导出所选会话解析后的 + 命令、参数与非机密环境,正是为此而设。 +- 失败会内联显示在页面上:Inspector 无法启动、在发布 URL 之前退出,或未在 30 秒启动预算内发布 URL 时为 + `AB8112`;Inspector 路由不可用时为 `AB8113`。 + ## Playground 拥有自己的轨迹 只有在 Playground 中发起的操作才会加入它的持久有序轨迹。即使 Playground 会话处于打开状态,Hooks 与 diff --git a/website/docs/zh/reference/security.mdx b/website/docs/zh/reference/security.mdx index 62b443f2d..00fac827c 100644 --- a/website/docs/zh/reference/security.mdx +++ b/website/docs/zh/reference/security.mdx @@ -74,7 +74,8 @@ SHA-256,因此被修改过的产物会校验失败,而不是被悄悄装上 ## 未作出的声明 本包不发布示例 RSC provider,也不发布宿主凭据。原生 Claude 与 Codex 评测 harness(`runClaudeTrial`、 -`runCodexEvalTrial`、`--harness claude|codex`)驱动的是你已经安装并登录的 CLI,自身不携带任何凭据。第三方声明——包括 vendored MCP Inspector 快照 -的许可证与 provenance——随已发布的包一同交付。 +`runCodexEvalTrial`、`--harness claude|codex`)驱动的是你已经安装并登录的 CLI,自身不携带任何凭据。 +第三方声明——包括源自 MCP Inspector `AppRenderer` 的 MCP App 渲染器的 MIT 许可证与 provenance——随已发布 +的包一同交付。 各证据表面**不能**证明什么,见[已知限制](./limitations.mdx)。