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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workbench-inspector-new-tab.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 17 additions & 9 deletions packages/agent-bundle/src/dev/inspector-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
105 changes: 103 additions & 2 deletions packages/agent-bundle/tests/inspector-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<unknown>): 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({
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down
23 changes: 23 additions & 0 deletions packages/workbench/src/client-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ export const jsonEquivalent = (left: unknown, right: unknown): boolean => {
export const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
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<string> = 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.
Expand Down
10 changes: 9 additions & 1 deletion packages/workbench/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -679,12 +680,13 @@ const HostsScreen = ({ connectionError, discoveryClient, manifestDigest, onNavig
<DiscoveryPage client={discoveryClient} manifestDigest={manifestDigest} />
</WorkbenchScreen>;

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<typeof createMcpController>;
readonly initialToolPrefill?: McpToolPrefill;
readonly inspectorLaunch: McpInspectorLaunchController;
readonly mcpDepartureDiagnostic?: string;
readonly model: ReturnType<typeof createMcpController>['model'];
readonly onNavigate: (page: WorkbenchPage) => void;
Expand Down Expand Up @@ -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}
Expand All @@ -754,6 +757,7 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll
: <McpPage
controller={controller}
initialPreview={runtimeInitialPreview}
inspectorLaunch={inspectorLaunch}
onResetSession={onResetSession}
registerPreviewClose={registerPreviewClose}
runtimePreviewDependencies={runtimePreviewDependencies}
Expand Down Expand Up @@ -798,6 +802,7 @@ const Workbench = () => {
const client = useRef<ProjectClient | undefined>(undefined);
const foreground = useRef<ForegroundRouteClient | undefined>(undefined);
const mcpRoutes = useRef<WorkbenchMcpRouteClient | undefined>(undefined);
const inspectorLaunch = useRef<McpInspectorLaunchController | undefined>(undefined);
const mcpControllerRef = useRef<WorkbenchMcpController | undefined>(undefined);
const mcpAppClient = useRef<McpAppClient | undefined>(undefined);
const runtimeClient = useRef<RuntimeClient | undefined>(undefined);
Expand All @@ -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);
Expand Down Expand Up @@ -1366,6 +1373,7 @@ const Workbench = () => {
connectionError={connectionError}
controller={mcpController}
initialToolPrefill={mcpToolPrefillFromNavigationState(window.history.state)}
inspectorLaunch={inspectorLaunchController}
mcpDepartureDiagnostic={mcpDepartureError}
model={mcpModel}
onNavigate={navigate}
Expand Down
Loading
Loading