From 5c8fcd278fdea61d100a0944fbb04ad9c2130f82 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 05:38:03 +0000 Subject: [PATCH 1/3] examples(mcp-app): convention-first routes own the status server and App (#726) --- docs/entry-conventions.md | 3 +- examples/mcp-app/README.md | 26 ++-- examples/mcp-app/agent-bundle.config.ts | 14 --- .../mcp-app/evals/graders/status-result.ts | 5 +- examples/mcp-app/package.json | 4 +- .../mcp-app/src/compiler-status-contract.ts | 37 ------ examples/mcp-app/src/mcp/status.ts | 57 --------- .../mcp/status/apps/status.html} | 0 .../mcp/status/apps/status.ts} | 117 ++++++------------ .../mcp/status/resources/readiness-policy.tsx | 27 ++++ .../src/mcp/status/tools/show-status.tsx | 25 ++++ examples/mcp-app/src/readiness-policy.ts | 8 ++ .../src/scripts/check-service-fixture.ts | 5 +- examples/mcp-app/src/service-status.ts | 41 ++++++ .../browser-app/status-panel.browser.test.ts | 18 +-- examples/mcp-app/tsconfig.json | 3 +- .../tests/examples-contract.test.ts | 17 ++- .../workbench/tests/web-command.e2e.test.ts | 27 ++-- pnpm-lock.yaml | 12 +- website/docs/en/examples/mcp-app.mdx | 57 +++++---- website/docs/en/guide/authoring/mcp.mdx | 5 +- website/docs/zh/examples/mcp-app.mdx | 47 ++++--- website/docs/zh/guide/authoring/mcp.mdx | 4 +- 23 files changed, 282 insertions(+), 277 deletions(-) delete mode 100644 examples/mcp-app/src/compiler-status-contract.ts delete mode 100644 examples/mcp-app/src/mcp/status.ts rename examples/mcp-app/{views/status-panel.html => src/mcp/status/apps/status.html} (100%) rename examples/mcp-app/{views/status-panel.ts => src/mcp/status/apps/status.ts} (55%) create mode 100644 examples/mcp-app/src/mcp/status/resources/readiness-policy.tsx create mode 100644 examples/mcp-app/src/mcp/status/tools/show-status.tsx create mode 100644 examples/mcp-app/src/readiness-policy.ts create mode 100644 examples/mcp-app/src/service-status.ts diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index ff2cdb5c2..380355e0b 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1841,7 +1841,6 @@ and the host runs the bound server's `find`, or fails. Neither is a compiler diagnostic today. Without the augmentation (no tool routes, a handwritten server, a program that omits the file) the id is any `tool:/` string and input and result are `unknown`; a view may then augment -`AppRegister` itself with a structural map, as -`examples/mcp-app/views/status-panel.ts` does. There is no generated +`AppRegister` itself with a structural map. There is no generated `agent-bundle/routes/client` value module: the wire name is derivable from the id, so a runtime table would duplicate it. diff --git a/examples/mcp-app/README.md b/examples/mcp-app/README.md index e03e2bc7b..f942905f0 100644 --- a/examples/mcp-app/README.md +++ b/examples/mcp-app/README.md @@ -9,7 +9,7 @@ pnpm example:mcp-app This credential-free example turns one service-readiness workflow into a real local MCP server, typed tool, interactive MCP App resource, Skill, session-start Hook, fixture-check script, and deterministic eval. It builds portable, Codex, -and Claude artifacts; the App resource remains portable. +and Claude artifacts; every one of them carries the App resource. ## What is authored @@ -18,13 +18,16 @@ and Claude artifacts; the App resource remains portable. - `src/hooks/session-start.ts` adds the readiness workflow to compatible host sessions, while `check-service-fixture` validates the checked-in compiler fixture before a release walkthrough. -- `src/mcp/status.ts` default-exports the `status` server factory serving - immutable `compiler` and `payments-api` health records; `payments-api` - deliberately returns degraded latency evidence. The build discovers the - entry through the `src/mcp/.ts` convention — the config declares - no `entry` — and wraps the factory in the generated stdio lifecycle shell - (console-to-stderr guard, signal handling, stdin-EOF exit, bounded - shutdown, heartbeat). +- `src/mcp/status/**` is the `status` server, generated from three route + modules the config never names: `tools/show-status.tsx` reports one + service's health, `resources/readiness-policy.tsx` serves the release rule + at `policy://mcp-app-example/readiness`, and `apps/status.ts` + + `status.html` compile to the `ui://mcp-app-example/status.html` App the + tool opens. `src/service-status.ts` holds the schemas and the immutable + `compiler` and `payments-api` records; `payments-api` deliberately returns + degraded latency evidence. The build owns registration, the stdio lifecycle + shell, and `.agent-bundle/routes.d.ts`, which types the App's + `createAppClient` calls from the tool's `inputSchema` and `resultSchema`. ## Workbench walkthrough @@ -40,8 +43,8 @@ and Claude artifacts; the App resource remains portable. emitted checker resolves the packaged status fixture beside its emitted module, so it succeeds without depending on the shell working directory. 4. **Advanced → Raw logs** exposes the resulting producer records. In - **Advanced → Artifact**, select portable to inspect `mcp-apps/status.html`; - Codex and Claude retain their host artifacts but not this portable App resource. + **Advanced → Artifact**, `mcp-apps/status.html` appears whichever target + is selected. 5. Before recording two eval runs, **Advanced → Evals → Compare** deliberately displays: `At least two recorded runs are needed before a comparison can be aligned.` That is the precise empty state, not an error. @@ -50,7 +53,8 @@ and Claude artifacts; the App resource remains portable. Availability and P95 latency checks (the latter fails). Open the App preview: the rendered panel also shows `payments-api`, a text-labelled amber `degraded` indicator, the same summary, and passing/failing checks through - the MCP Apps bridge. Inspect the + the MCP Apps bridge; **Refresh status** calls `show-status` again and + **Read policy** reads the readiness-policy resource. Inspect the protocol trace in the route workspace; use **Advanced → Protocol** for session restart, reset, and lifecycle inspection. 7. **Advanced → Evals → Runs** defaults to the deterministic `mcp-app-status` suite. Run diff --git a/examples/mcp-app/agent-bundle.config.ts b/examples/mcp-app/agent-bundle.config.ts index 39fe987b6..9252527bd 100644 --- a/examples/mcp-app/agent-bundle.config.ts +++ b/examples/mcp-app/agent-bundle.config.ts @@ -5,20 +5,6 @@ export default defineConfig({ hooks: { sessionStart: { handler: './src/hooks/session-start.ts' }, }, - mcp: { - servers: { - status: { - apps: { - status: { - entry: './views/status-panel.ts', - resourceUri: 'ui://mcp-app-example/status.html', - targets: ['portable'], - template: './views/status-panel.html', - }, - }, - }, - }, - }, plugin: { description: 'A unified service-readiness assistant with MCP, Skills, Hooks, scripts, and evaluation.', name: 'mcp-app-example', diff --git a/examples/mcp-app/evals/graders/status-result.ts b/examples/mcp-app/evals/graders/status-result.ts index ec34e7a89..71951ee40 100644 --- a/examples/mcp-app/evals/graders/status-result.ts +++ b/examples/mcp-app/evals/graders/status-result.ts @@ -1,13 +1,14 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import type { EvalGraderFunction } from 'agent-bundle/eval'; -import { isHealthyCompilerFixture } from '../../src/compiler-status-contract.ts'; +import { healthyCompilerStatus } from '../../src/service-status.ts'; const grade: EvalGraderFunction = async ({ fixturePath }) => { const result = JSON.parse(await readFile(join(fixturePath, 'result.json'), 'utf8')) as unknown; - return isHealthyCompilerFixture(result) + return isDeepStrictEqual(result, healthyCompilerStatus) ? { detail: 'The compiler service is healthy.', outcome: 'pass' } : { detail: 'The compiler service did not report a healthy status.', outcome: 'fail' }; }; diff --git a/examples/mcp-app/package.json b/examples/mcp-app/package.json index c8815ee1f..e9e66eedd 100644 --- a/examples/mcp-app/package.json +++ b/examples/mcp-app/package.json @@ -12,12 +12,14 @@ "validate": "agent-bundle validate" }, "devDependencies": { - "@modelcontextprotocol/server": "2.0.0", + "@agent-bundle/runtime": "workspace:*", "@rstest/browser": "0.11.12", "@rstest/core": "0.11.12", "@rstest/playwright": "0.11.12", + "@types/react": "19.2.18", "agent-bundle": "workspace:*", "playwright": "1.62.1", + "react": "19.2.8", "zod": "4.5.4" } } diff --git a/examples/mcp-app/src/compiler-status-contract.ts b/examples/mcp-app/src/compiler-status-contract.ts deleted file mode 100644 index 609c16b2c..000000000 --- a/examples/mcp-app/src/compiler-status-contract.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const healthyCompilerStatus = Object.freeze({ - checks: Object.freeze([ - Object.freeze({ label: 'Availability', status: 'passing' }), - Object.freeze({ label: 'Build queue', status: 'passing' }), - ]), - service: 'compiler', - status: 'healthy', - summary: 'Compiler service is ready for release.', -}); - -const isRecord = (value: unknown): value is Readonly> => - value !== null && typeof value === 'object' && !Array.isArray(value); - -export const isHealthyCompilerFixture = (value: unknown): boolean => { - if ( - !isRecord(value) - || value.service !== healthyCompilerStatus.service - || value.status !== healthyCompilerStatus.status - || value.summary !== healthyCompilerStatus.summary - ) { - return false; - } - - // Bind the property once: a narrowing on `value.checks` does not survive - // into the `every` callback, so the guard and the indexing share one binding. - const checks: unknown = value.checks; - if (!Array.isArray(checks) || checks.length !== healthyCompilerStatus.checks.length) { - return false; - } - - return healthyCompilerStatus.checks.every((expected, index) => { - const received: unknown = checks[index]; - return isRecord(received) - && received.label === expected.label - && received.status === expected.status; - }); -}; diff --git a/examples/mcp-app/src/mcp/status.ts b/examples/mcp-app/src/mcp/status.ts deleted file mode 100644 index fe9f6e425..000000000 --- a/examples/mcp-app/src/mcp/status.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/server'; -import apps from 'agent-bundle/mcp-apps'; -import { name, version } from 'agent-bundle/meta'; -import { z } from 'zod'; - -import { healthyCompilerStatus } from '../compiler-status-contract.ts'; - -const app = apps[0]; -if (app === undefined) throw new Error('Expected the status MCP App.'); - -const serviceCatalog = Object.freeze({ - compiler: healthyCompilerStatus, - 'payments-api': Object.freeze({ - checks: Object.freeze([ - Object.freeze({ label: 'Availability', status: 'passing' }), - Object.freeze({ label: 'P95 latency', status: 'failing' }), - ]), - service: 'payments-api', - status: 'degraded', - summary: 'Payment latency is above the release threshold.', - }), -}); - -export const createStatusServer = (): McpServer => { - // The compiler stamps this project's identity into `agent-bundle/meta`, so - // the wire identity cannot drift from the config or package.json. - const server = new McpServer({ name, version }); - - server.registerResource(app.name, app.resourceUri, { - _meta: { ui: { resourceUri: app.resourceUri } }, - mimeType: app.mimeType, - }, async (uri) => ({ - contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }], - })); - - server.registerTool('show-status', { - _meta: { ui: { resourceUri: app.resourceUri } }, - description: 'Show the health of one example service.', - inputSchema: z.object({ service: z.enum(['compiler', 'payments-api']) }), - }, async ({ service }) => { - const result = serviceCatalog[service]; - return { - _meta: { ui: { resourceUri: app.resourceUri } }, - content: [{ text: result.summary, type: 'text' }], - structuredContent: result, - }; - }); - - return server; -}; - -/** - * Default-exported server factory: `agent-bundle build` detects it and wraps - * this entry in the framework stdio lifecycle shell (console-to-stderr guard, - * SIGINT/SIGTERM handling, stdin-EOF exit, bounded shutdown, heartbeat). - */ -export default createStatusServer; diff --git a/examples/mcp-app/views/status-panel.html b/examples/mcp-app/src/mcp/status/apps/status.html similarity index 100% rename from examples/mcp-app/views/status-panel.html rename to examples/mcp-app/src/mcp/status/apps/status.html diff --git a/examples/mcp-app/views/status-panel.ts b/examples/mcp-app/src/mcp/status/apps/status.ts similarity index 55% rename from examples/mcp-app/views/status-panel.ts rename to examples/mcp-app/src/mcp/status/apps/status.ts index c23ae4dff..ee3f9bbee 100644 --- a/examples/mcp-app/views/status-panel.ts +++ b/examples/mcp-app/src/mcp/status/apps/status.ts @@ -1,6 +1,22 @@ -import { type AppClientError, createAppClient } from 'agent-bundle/app'; +import type { AppRouteConfig } from 'agent-bundle'; +import { type AppClientError, type AppRouteInput, type AppRouteResult, createAppClient } from 'agent-bundle/app'; import { name, version } from 'agent-bundle/meta'; +import { readinessPolicyUri } from '../../../readiness-policy.ts'; + +export const config = { + resourceUri: 'ui://mcp-app-example/status.html', + template: './status.html', +} satisfies AppRouteConfig; + +const showStatusRoute = 'tool:status/show-status'; + +type Service = AppRouteInput['service']; +type ServiceStatus = AppRouteResult; + +/** The service on screen, for the refresh call. */ +let currentService: Service | undefined; + const serviceHeading = document.querySelector('#service')!; const statusIndicator = document.querySelector('#status-indicator')!; const status = document.querySelector('#status')!; @@ -9,51 +25,11 @@ const checks = document.querySelector('#checks')!; const bridgeOutcome = document.querySelector('#bridge-outcome')!; /** - * `checking`, `healthy`, and `degraded` come from the tool; `unavailable` is - * the panel's own verdict when the opening call fails to produce a status. - */ -type StatusState = 'checking' | 'healthy' | 'degraded' | 'unavailable' | 'unknown'; - -interface ServiceCheck { - readonly label?: string; - readonly status?: string; -} - -interface ServiceStatus { - readonly checks?: readonly ServiceCheck[]; - readonly service?: string; - readonly status?: string; - readonly summary?: string; -} - -interface StatusToolInput { - readonly service: string; -} - -/** - * Config-declared Apps have no generated `AgentBundleRoutes`. This structural - * map types `createAppClient` through the public `AppRegister` seam. + * `checking` while a call is in flight; `healthy`/`degraded` from the tool; + * `unavailable` is the panel's own verdict when the opening call fails to + * produce a status. */ -type StatusPanelRouteContracts = { - readonly 'tool:status/show-status': { - readonly input: StatusToolInput; - readonly result: ServiceStatus; - }; - readonly 'tool:status/refresh-status': { - readonly input: StatusToolInput; - readonly result: ServiceStatus; - }; -}; - -declare module 'agent-bundle/app' { - interface AppRegister { - readonly routes: StatusPanelRouteContracts; - } -} - -const showStatusRoute = 'tool:status/show-status'; -const refreshStatusRoute = 'tool:status/refresh-status'; -const readinessPolicyUri = 'ui://mcp-app-example/readiness-policy'; +type StatusState = 'checking' | ServiceStatus['status'] | 'unavailable'; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -64,17 +40,6 @@ const resourceText = (value: unknown): string | undefined => { return isRecord(content) && typeof content.text === 'string' ? content.text : undefined; }; -const statusState = (value: string | undefined): StatusState => { - if (value === 'checking' || value === 'healthy' || value === 'degraded') return value; - return 'unknown'; -}; - -const checkState = (value: string | undefined): StatusState => { - if (value === 'passing') return 'healthy'; - if (value === 'failing') return 'degraded'; - return 'unknown'; -}; - const setStatus = (state: StatusState) => { statusIndicator.dataset.state = state; status.textContent = state; @@ -92,15 +57,19 @@ const toolErrorDetail = (error: AppClientError): string => { return isRecord(block) && typeof block.text === 'string' ? block.text : error.message; }; -const renderChecks = (items: readonly ServiceCheck[]) => { - checks.replaceChildren(...items.map((check) => { +const renderStatus = (result: ServiceStatus) => { + currentService = result.service; + serviceHeading.textContent = result.service; + setStatus(result.status); + summary.textContent = result.summary; + checks.replaceChildren(...result.checks.map((check) => { const item = document.createElement('li'); const label = document.createElement('span'); - const result = document.createElement('strong'); - label.textContent = check.label ?? 'Unnamed check'; - result.textContent = check.status ?? 'unknown'; - item.dataset.state = checkState(check.status); - item.append(label, result); + const outcome = document.createElement('strong'); + label.textContent = check.label; + outcome.textContent = check.status; + item.dataset.state = check.status === 'passing' ? 'healthy' : 'degraded'; + item.append(label, outcome); return item; })); }; @@ -109,20 +78,15 @@ const client = createAppClient({ appInfo: { name, version }, }); -client.onToolInput(showStatusRoute, (input) => { - const service = typeof input.service === 'string' ? input.service : 'service'; +client.onToolInput(showStatusRoute, ({ service }) => { + currentService = service; serviceHeading.textContent = service; setStatus('checking'); summary.textContent = `Checking readiness for ${service}.`; - renderChecks([]); + checks.replaceChildren(); }); -client.onToolResult(showStatusRoute, (result) => { - serviceHeading.textContent = result.service ?? 'No service selected'; - setStatus(statusState(result.status)); - summary.textContent = result.summary ?? 'No readiness summary was returned.'; - renderChecks(result.checks ?? []); -}); +client.onToolResult(showStatusRoute, renderStatus); // A failed opening call — `isError: true`, a malformed result, or one without // structured content — never reaches `onToolResult`. Leave the requested @@ -130,7 +94,7 @@ client.onToolResult(showStatusRoute, (result) => { client.onToolError(showStatusRoute, (error) => { setStatus('unavailable'); summary.textContent = `Readiness is unavailable: ${toolErrorDetail(error)}`; - renderChecks([]); + checks.replaceChildren(); }); document.querySelector('#toggle-details')!.addEventListener('click', () => { @@ -146,11 +110,12 @@ document.querySelector('#read-policy')!.addEventListener('click', async () => { } }); +// Refresh re-runs the opening tool for the service on screen; the host asks +// for consent before the call reaches the server. document.querySelector('#refresh-status')!.addEventListener('click', async () => { + if (currentService === undefined) return; try { - await client.call(refreshStatusRoute, { - service: serviceHeading.textContent ?? 'service', - }); + renderStatus(await client.call(showStatusRoute, { service: currentService })); bridgeOutcome.textContent = 'Status refreshed.'; } catch { bridgeOutcome.textContent = 'Refresh unavailable.'; diff --git a/examples/mcp-app/src/mcp/status/resources/readiness-policy.tsx b/examples/mcp-app/src/mcp/status/resources/readiness-policy.tsx new file mode 100644 index 000000000..94f1c6d6c --- /dev/null +++ b/examples/mcp-app/src/mcp/status/resources/readiness-policy.tsx @@ -0,0 +1,27 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; +import type { ResourceConfig } from 'agent-bundle'; +import { z } from 'zod'; + +import { readinessPolicy, readinessPolicyUri } from '../../../readiness-policy.ts'; + +export const config = { + description: 'The release rule a service status is judged against.', + mimeType: 'text/plain', + title: 'Readiness policy', + uri: readinessPolicyUri, +} satisfies ResourceConfig; + +export const inputSchema = z.object({ uri: z.string() }); + +export const resultSchema = z.object({ + contents: z.array(z.object({ mimeType: z.string(), text: z.string(), uri: z.string() })), +}); + +export default async function ReadinessPolicy({ input }: { readonly input: z.infer }) { + return ( + + {readinessPolicy} + + ); +} diff --git a/examples/mcp-app/src/mcp/status/tools/show-status.tsx b/examples/mcp-app/src/mcp/status/tools/show-status.tsx new file mode 100644 index 000000000..163086a91 --- /dev/null +++ b/examples/mcp-app/src/mcp/status/tools/show-status.tsx @@ -0,0 +1,25 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; +import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; +import { appResourceUri } from 'agent-bundle/routes'; +import { z } from 'zod'; + +import { serviceSchema, serviceStatus, serviceStatusSchema } from '../../../service-status.ts'; + +export const config = { + _meta: { ui: { resourceUri: appResourceUri('status') } }, + annotations: { readOnlyHint: true }, + description: 'Show the health of one example service.', +} satisfies ToolConfig; + +export const inputSchema = z.object({ service: serviceSchema }); +export const resultSchema = serviceStatusSchema; + +export default async function ShowStatus({ input }: ToolRouteProps) { + const status = serviceStatus(input.service); + return ( + + {status.summary} + + ); +} diff --git a/examples/mcp-app/src/readiness-policy.ts b/examples/mcp-app/src/readiness-policy.ts new file mode 100644 index 000000000..3dba19992 --- /dev/null +++ b/examples/mcp-app/src/readiness-policy.ts @@ -0,0 +1,8 @@ +export const readinessPolicyUri = 'policy://mcp-app-example/readiness'; + +/** The release rule the App shows beside a status; the Skill's `status-policy.md` is the long form. */ +export const readinessPolicy = [ + 'Issue `ready` only for a healthy service with current evidence.', + 'A degraded service needs an explicit mitigation decision; a blocked service cannot pass;', + 'and missing evidence requires a new check rather than an assumption.', +].join(' '); diff --git a/examples/mcp-app/src/scripts/check-service-fixture.ts b/examples/mcp-app/src/scripts/check-service-fixture.ts index 298df92c3..0ecf2bea1 100644 --- a/examples/mcp-app/src/scripts/check-service-fixture.ts +++ b/examples/mcp-app/src/scripts/check-service-fixture.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; +import { isDeepStrictEqual } from 'node:util'; -import { isHealthyCompilerFixture } from '../compiler-status-contract.ts'; +import { healthyCompilerStatus } from '../service-status.ts'; const fixturePath = new URL('../assets/evals/fixtures/status/result.json', import.meta.url); @@ -11,7 +12,7 @@ const fixturePath = new URL('../assets/evals/fixtures/status/result.json', impor export const main = async (): Promise => { try { const fixture = JSON.parse(await readFile(fixturePath, 'utf8')) as unknown; - if (!isHealthyCompilerFixture(fixture)) { + if (!isDeepStrictEqual(fixture, healthyCompilerStatus)) { throw new Error('compiler fixture must contain the exact healthy compiler status'); } process.stdout.write('Compiler fixture is healthy.\n'); diff --git a/examples/mcp-app/src/service-status.ts b/examples/mcp-app/src/service-status.ts new file mode 100644 index 000000000..4dc43764f --- /dev/null +++ b/examples/mcp-app/src/service-status.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +export const serviceSchema = z.enum(['compiler', 'payments-api']).describe('The example service to inspect.'); + +export const serviceStatusSchema = z.object({ + checks: z.array(z.object({ label: z.string(), status: z.enum(['passing', 'failing']) })), + service: serviceSchema, + status: z.enum(['healthy', 'degraded']), + summary: z.string(), +}); + +export type Service = z.infer; +export type ServiceStatus = z.infer; + +/** The checked-in `evals/fixtures/status/result.json` must equal this record exactly. */ +export const healthyCompilerStatus: ServiceStatus = { + checks: [ + { label: 'Availability', status: 'passing' }, + { label: 'Build queue', status: 'passing' }, + ], + service: 'compiler', + status: 'healthy', + summary: 'Compiler service is ready for release.', +}; + +const catalog: Readonly> = { + compiler: healthyCompilerStatus, + 'payments-api': { + checks: [ + { label: 'Availability', status: 'passing' }, + { label: 'P95 latency', status: 'failing' }, + ], + service: 'payments-api', + status: 'degraded', + summary: 'Payment latency is above the release threshold.', + }, +}; + +/** The immutable health record of one example service; `payments-api` is deliberately degraded. */ +export const serviceStatus = (service: Service): ServiceStatus => catalog[service]; + diff --git a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts index 8a03649a5..8cfad3e68 100644 --- a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts +++ b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts @@ -11,7 +11,7 @@ type BindingOperations = MountBrowserAppOptions['operations']; type ToolCallResult = Awaited>; /** - * The opening tool as `src/mcp/status.ts` registers it. Framework hosts put + * The opening tool, `src/mcp/status/tools/show-status.tsx`. Framework hosts put * the leased tool definition in the initialize `hostContext.toolInfo`, and the * App client delivers `onToolInput`/`onToolResult` only to listeners on that * tool's route, so the harness has to open the panel with `show-status` for @@ -164,7 +164,8 @@ it('mounts the compiled panel, initializes the bridge, and renders the published 'Availabilitypassing', 'P95 latencyfailing', ]); - expect(app.provenance).toMatchObject({ proofLevel: 'browser-app', target: 'portable' }); + expect(app.provenance).toMatchObject({ proofLevel: 'browser-app' }); + expect(['claude', 'codex', 'portable']).toContain(app.provenance.target); expect(initializeResult(app)).toMatchObject({ hostContext: { toolInfo: { tool: { name: 'show-status' } } } }); expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-input')).toBe(true); expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-result')).toBe(true); @@ -191,17 +192,18 @@ it('round-trips a resource read from the real App through binding operations', a app.document.querySelector('#read-policy')!.click(); await waitFor(() => app.document.querySelector('#bridge-outcome')?.textContent?.includes('passing checks') === true); - expect(reads).toEqual(['ui://mcp-app-example/readiness-policy']); + expect(reads).toEqual(['policy://mcp-app-example/readiness']); expect(appToHostMethods(app)).toContain('resources/read'); expect(app.traffic.some(({ message }) => ( message.method === 'resources/read' - && messageParam(message, 'uri') === 'ui://mcp-app-example/readiness-policy' + && messageParam(message, 'uri') === 'policy://mcp-app-example/readiness' ))).toBe(true); }); it('holds a tool call for consent, resumes approval once, and denies without calling the binding', async () => { const approvedCalls: string[] = []; const approved = await mountStatus({ operations: operations({ calls: approvedCalls }) }); + await waitFor(() => approved.document.querySelector('#status')?.textContent === 'degraded'); approved.document.querySelector('#refresh-status')!.click(); await waitFor(() => approved.pendingConsentChallenges.length === 1); @@ -210,15 +212,16 @@ it('holds a tool call for consent, resumes approval once, and denies without cal expect(approvedCalls).toEqual([]); await expect(approved.decideConsent(challenge.id, true)).resolves.toBe(true); await waitFor(() => approved.document.querySelector('#bridge-outcome')?.textContent === 'Status refreshed.'); - expect(approvedCalls).toEqual(['refresh-status']); + expect(approvedCalls).toEqual(['show-status']); expect(appToHostMethods(approved)).toContain('tools/call'); expect(approved.traffic.some(({ message }) => ( message.method === 'tools/call' - && messageParam(message, 'name') === 'refresh-status' + && messageParam(message, 'name') === 'show-status' ))).toBe(true); const deniedCalls: string[] = []; const denied = await mountStatus({ operations: operations({ calls: deniedCalls }) }); + await waitFor(() => denied.document.querySelector('#status')?.textContent === 'degraded'); denied.document.querySelector('#refresh-status')!.click(); await waitFor(() => denied.pendingConsentChallenges.length === 1); await expect(denied.decideConsent(denied.pendingConsentChallenges[0]!.id, false)).resolves.toBe(true); @@ -238,12 +241,13 @@ it('fails closed when a consented binding operation is unavailable', async () => }, }), }); + await waitFor(() => app.document.querySelector('#status')?.textContent === 'degraded'); app.document.querySelector('#refresh-status')!.click(); await waitFor(() => app.pendingConsentChallenges.length === 1); await app.decideConsent(app.pendingConsentChallenges[0]!.id, true); await waitFor(() => app.document.querySelector('#bridge-outcome')?.textContent === 'Refresh unavailable.'); - expect(calls).toEqual(['refresh-status']); + expect(calls).toEqual(['show-status']); expect(app.traffic.some(({ message }) => message.error?.code === -32000)).toBe(true); expect(app.document.querySelector('#bridge-outcome')?.textContent).not.toBe('Status refreshed.'); }); diff --git a/examples/mcp-app/tsconfig.json b/examples/mcp-app/tsconfig.json index 6ec45a609..db3c2eb05 100644 --- a/examples/mcp-app/tsconfig.json +++ b/examples/mcp-app/tsconfig.json @@ -10,7 +10,6 @@ "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", - "tests/**/*.tsx", - "views/**/*.ts" + "tests/**/*.tsx" ] } diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index de3df9a29..74de3eec5 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -117,7 +117,19 @@ it('publishes the MCP App example service readiness across targets and returns d root, server: 'status', target: 'portable', - })).resolves.toMatchObject({ tools: [{ name: 'show-status' }] }); + })).resolves.toMatchObject({ + // The listing binds the tool to its App; the generated server stamps + // the route's `_meta.ui` there, where MCP Apps hosts read it. + tools: [{ _meta: { ui: { resourceUri: 'ui://mcp-app-example/status.html' } }, name: 'show-status' }], + }); + // One generated server, three conventional routes: the App, the readiness + // policy resource, and the tool that opens the App (#726). + const manifest = JSON.parse(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')) as { + readonly routes: { readonly servers: readonly { readonly mode: string; readonly routes: readonly { readonly id: string }[] }[] }; + }; + expect(manifest.routes.servers.map((server) => [server.mode, server.routes.map((route) => route.id)])).toEqual([ + ['generated', ['app:status/status', 'resource:status/readiness-policy', 'tool:status/show-status']], + ]); await expect(invokeMcp({ artifact: output, input: { service: 'payments-api' }, @@ -127,7 +139,6 @@ it('publishes the MCP App example service readiness across targets and returns d tool: 'show-status', })).resolves.toMatchObject({ result: { - _meta: { ui: { resourceUri: 'ui://mcp-app-example/status.html' } }, content: [{ text: 'Payment latency is above the release threshold.', type: 'text' }], structuredContent: { checks: [ @@ -143,7 +154,7 @@ it('publishes the MCP App example service readiness across targets and returns d expect(inspected).toMatchObject({ model: { hooks: [{ event: 'sessionStart', targets: ['claude', 'codex'] }], - mcpApps: [{ name: 'status', targets: ['portable'] }], + mcpApps: [{ name: 'status', targets: ['claude', 'codex', 'portable'] }], mcpServers: [{ name: 'status', targets: ['claude', 'codex', 'portable'] }], scripts: [{ name: 'check-service-fixture', targets: ['claude', 'codex', 'portable'] }], skills: [{ name: 'service-readiness', targets: ['claude', 'codex', 'portable'] }], diff --git a/packages/workbench/tests/web-command.e2e.test.ts b/packages/workbench/tests/web-command.e2e.test.ts index f05fd5d6e..44b21a126 100644 --- a/packages/workbench/tests/web-command.e2e.test.ts +++ b/packages/workbench/tests/web-command.e2e.test.ts @@ -1,5 +1,5 @@ import type { ChildProcess } from 'node:child_process'; -import { readdir, readFile, stat } from 'node:fs/promises'; +import { readdir, readFile, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { expect } from '@rstest/playwright'; @@ -30,6 +30,7 @@ const healthyCompilerStatus = { status: 'healthy', summary: 'Compiler service is ready for release.', } as const; +const readinessPolicy = 'Issue `ready` only for a healthy service with current evidence. A degraded service needs an explicit mitigation decision; a blocked service cannot pass; and missing evidence requires a new check rather than an assumption.'; const seedElementPattern = new RegExp(`]*\\btype="application/json")(?=[^>]*\\bid="${WEB_HOST_SEED_ELEMENT_ID}")[^>]*>`, 'u'); const agentBundleImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]agent-bundle(?:\/[^'"]*)?['"]/u; const effectImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]effect(?:\/[^'"]*)?['"]/u; @@ -119,15 +120,20 @@ e2e('serves examples/mcp-app through ` web` from its composite root and const artifactRoot = join(example.root, 'artifact'); const built = await build({ output: artifactRoot, root: example.root }); expect(built.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + // The artifact is the whole product: the bin serves the App with no source beside it. + await rm(join(example.root, 'src'), { force: true, recursive: true }); const bin = join(artifactRoot, 'bin', `${pluginName}.mjs`); await expect(stat(bin)).resolves.toMatchObject({}); const manifest = JSON.parse(await readFile(join(artifactRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly executables: { readonly mcpServers: readonly Readonly>[] }; readonly web?: unknown; }; + // The generated server: one entry plus its flight worker, both under mcp/. const mcpEntries = (await readdir(join(artifactRoot, 'mcp'))).filter((name) => name.endsWith('.mjs')).sort(); - expect(mcpEntries).toHaveLength(1); - expect(manifest.executables.mcpServers.map((server) => server['launch'])).toEqual([{ args: [], entry: `mcp/${mcpEntries[0]!}`, env: {} }]); + expect(mcpEntries).toHaveLength(2); + const [launch] = manifest.executables.mcpServers.map((server) => server['launch'] as { entry: string; worker: string }); + expect(launch).toMatchObject({ args: [], env: {} }); + expect(mcpEntries).toEqual([launch!.worker, launch!.entry].map((entry) => entry.slice('mcp/'.length)).sort()); expect(manifest.web).toEqual({ apps: [{ allow: ['call-tool'], @@ -229,22 +235,21 @@ e2e('serves examples/mcp-app through ` web` from its composite root and await expect(appFrame.locator('#checks li')).toHaveCount(healthyCompilerStatus.checks.length); expect(await appFrame.content()).not.toContain(seed.token); - // Bridge: `#read-policy` reads `ui://mcp-app-example/readiness-policy`, - // which examples/mcp-app/src/mcp/status.ts does not register, so the - // relayed error reply is what fills `#bridge-outcome` — a relay that never - // answered would leave it empty. `#refresh-status` calls `refresh-status` - // (also unregistered) under the pre-approved `call-tool` capability: the - // reply arrives without the consent panel ever showing. + // Bridge: `#read-policy` reads the generated server's readiness-policy + // resource route, and `#refresh-status` calls `show-status` again for the + // service on screen under the pre-approved `call-tool` capability: both + // replies arrive from the real server without the consent panel showing. const consent = page.getByLabel('MCP App consent'); await expect(consent).toBeHidden(); const bridgeOutcome = appFrame.locator('#bridge-outcome'); await expect(bridgeOutcome).toBeEmpty(); await appFrame.locator('#read-policy').click(); - await expect(bridgeOutcome).toHaveText('Readiness policy unavailable.', { timeout: browserTimeout }); + await expect(bridgeOutcome).toHaveText(readinessPolicy, { timeout: browserTimeout }); await expect.poll(() => relayed('resources/read'), { timeout: browserTimeout }).toBe(true); await appFrame.locator('#refresh-status').click(); - await expect(bridgeOutcome).toHaveText('Refresh unavailable.', { timeout: browserTimeout }); + await expect(bridgeOutcome).toHaveText('Status refreshed.', { timeout: browserTimeout }); await expect.poll(() => relayed('tools/call'), { timeout: browserTimeout }).toBe(true); + await expect(appFrame.locator('#status')).toHaveText(healthyCompilerStatus.status); await expect(consent).toBeHidden(); await expect(hostStatus).toHaveText(servingStatus); await expect(hostStatus).toHaveAttribute('data-tone', 'ok'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64e7405db..2b684f647 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,9 +138,9 @@ importers: examples/mcp-app: devDependencies: - '@modelcontextprotocol/server': - specifier: 2.0.0 - version: 2.0.0 + '@agent-bundle/runtime': + specifier: workspace:* + version: link:../../packages/rsc-runtime '@rstest/browser': specifier: 0.11.12 version: 0.11.12(@rstest/core@0.11.12)(playwright@1.62.1) @@ -150,12 +150,18 @@ importers: '@rstest/playwright': specifier: 0.11.12 version: 0.11.12(@rstest/core@0.11.12)(playwright@1.62.1) + '@types/react': + specifier: 19.2.18 + version: 19.2.18 agent-bundle: specifier: workspace:* version: link:../../packages/agent-bundle playwright: specifier: 1.62.1 version: 1.62.1 + react: + specifier: 19.2.8 + version: 19.2.8 zod: specifier: 4.5.4 version: 4.5.4 diff --git a/website/docs/en/examples/mcp-app.mdx b/website/docs/en/examples/mcp-app.mdx index e3d8db4b8..b749d0aef 100644 --- a/website/docs/en/examples/mcp-app.mdx +++ b/website/docs/en/examples/mcp-app.mdx @@ -11,43 +11,47 @@ to see how the surfaces fit together instead of studying one of them alone. - **Run from the repository root:** `pnpm example:mcp-app` - **Package:** `@agent-bundle-example/mcp-app` -- **Public dependencies:** `agent-bundle` (`workspace:*`), `@modelcontextprotocol/server`, +- **Public dependencies:** `agent-bundle` (`workspace:*`), `@agent-bundle/runtime`, `react`, `zod`; `@rstest/browser`, `@rstest/core`, `@rstest/playwright`, and `playwright` for the browser-app test -- **Targets:** `portable`, `codex`, `claude` — the MCP App is declared for `portable` +- **Targets:** `portable`, `codex`, `claude` — every target ships the `status` server and its App - **Credentials:** none — the eval and the fixture check read only checked-in data - **Source:** [`examples/mcp-app`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/mcp-app) ## What it proves -- **The MCP entry convention.** `src/mcp/status.ts` default-exports the `status` server factory, - and the config declares no server `entry` — the build discovers it through the - `src/mcp/.ts` convention. See +- **Routes are the registration.** The config names no server, tool, resource, or App. The + `status` server exists because `src/mcp/status/**` holds three route modules: + `tools/show-status.tsx` (one service's health), `resources/readiness-policy.tsx` (the release + rule at `policy://mcp-app-example/readiness`), and `apps/status.ts` + `status.html` (the App + the tool opens through `_meta.ui.resourceUri`, spelled once with `appResourceUri('status')`). + There is no handwritten `McpServer`, no `registerTool`, and no + `@modelcontextprotocol/server` dependency. See [MCP servers and MCP Apps](../guide/authoring/mcp.mdx). -- **The generated stdio lifecycle is not yours to write.** The build wraps that factory in the - lifecycle shell: the console-to-stderr guard, signal handling, stdin-EOF exit, bounded +- **The generated stdio lifecycle is not yours to write.** The build emits the server entry and + its lifecycle shell: the console-to-stderr guard, signal handling, stdin-EOF exit, bounded shutdown, and the heartbeat. -- **An MCP App is a compiled resource, not a served page.** The declared app is scoped to - `portable`, so it is compiled when that projection is selected — once, to - `mcp-apps/status.html` in the artifact root, with a stable `resourceUri` — and inlined into the - generated `status` server entry, which every selected host launches from that same root. -- **Degraded evidence is first-class.** The `status` server serves immutable `compiler` and - `payments-api` health records, and `payments-api` deliberately returns degraded latency. The - interesting output is a failing check that is reported honestly, not a green screenshot. +- **An MCP App is a compiled resource, not a served page.** The App compiles once, to + `mcp-apps/status.html` in the artifact root with a stable `resourceUri`, and is inlined into + the generated `status` server entry, which every selected host launches from that same root. +- **Degraded evidence is first-class.** `src/service-status.ts` holds the schemas and the + immutable `compiler` and `payments-api` records, and `payments-api` deliberately returns + degraded latency. The interesting output is a failing check that is reported honestly, not a + green screenshot. - **A browser surface is its own proof level.** `tests/browser-app/status-panel.browser.test.ts` mounts the production-compiled App HTML over the product bridge through `agent-bundle/test/browser` — the `browser-app` level in [Testing](../guide/development/testing.mdx). -- **The App is a client of the bridge, not a transport of its own.** `views/status-panel.ts` +- **The App is a client of the bridge, not a transport of its own.** `src/mcp/status/apps/status.ts` creates its client with `createAppClient` from `agent-bundle/app`, receives the opening `show-status` call's input and structured result through `onToolInput` / `onToolResult`, and - surfaces failed or unstructured opening results through `onToolError` - registered on `tool:status/show-status` (delivered when the host's initialize result names - `show-status` as the opening tool), refreshes through `call('tool:status/refresh-status', …)`, - and reads the readiness policy through `request('resources/read', …)`. The `status` server is - handwritten, so no generated route contracts exist; the view types its two tools itself with a - structural `AppRegister` augmentation. No JSON-RPC ids, pending maps, `postMessage('*')`, or - `structuredContent` unwrapping remain in the view. See + surfaces failed or unstructured opening results through `onToolError` registered on + `tool:status/show-status` (delivered when the host's initialize result names `show-status` as + the opening tool). **Refresh status** calls the same `show-status` tool again for the service + on screen, and **Read policy** reads the `readiness-policy` resource through + `request('resources/read', …)`. The route id, input, and result are typed by the generated + `.agent-bundle/routes.d.ts`, so the App declares no contract map of its own. No JSON-RPC ids, + pending maps, `postMessage('*')`, or `structuredContent` unwrapping remain in the view. See [The typed App client](../guide/authoring/mcp.mdx#the-typed-app-client). - **The `web` surface ships inside the artifact.** The example configures `web: { apps: [{ app: 'status/status', tool: 'show-status', allow: ['call-tool'] }] }`, @@ -66,8 +70,10 @@ to see how the surfaces fit together instead of studying one of them alone. | Path | What it is | | --- | --- | -| `src/mcp/status.ts` | The `status` server factory, discovered by convention, serving the two health records. | -| `views/status-panel.ts` / `views/status-panel.html` | The MCP App entry and template compiled to `mcp-apps/status.html`. | +| `src/service-status.ts` | The service schemas and the two immutable health records. | +| `src/mcp/status/tools/show-status.tsx` | The `show-status` tool route; its `_meta.ui.resourceUri` opens the App. | +| `src/mcp/status/resources/readiness-policy.tsx` | The `policy://mcp-app-example/readiness` resource route. | +| `src/mcp/status/apps/status.ts` / `status.html` | The MCP App entry and template compiled to `mcp-apps/status.html`. | | `src/skills/service-readiness/` | The evidence, checks, and report needed for a service-readiness decision. | | `src/hooks/session-start.ts` | Adds the readiness workflow to compatible host sessions. | | `src/scripts/check-service-fixture.ts` | Validates the checked-in compiler fixture before a release walkthrough. | @@ -93,7 +99,8 @@ to see how the surfaces fit together instead of studying one of them alone. select `show-status`, choose `payments-api`, and invoke it. Invocation history shows the degraded summary with labelled Availability and P95 latency checks, the latter failing. Open the App preview: the rendered panel shows the same record through the MCP Apps bridge, with a - text-labelled amber `degraded` indicator. Inspect the protocol trace, use + text-labelled amber `degraded` indicator; **Refresh status** and **Read policy** call the real + tool and resource. Inspect the protocol trace, use **Restart MCP session**, then close, reset, and reopen the session to exercise the lifecycle. Then open **Trace** to see the MCP request, response, notifications, and session activity joined by their session and JSON-RPC request ids. diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 0e5a1964d..7afc52c94 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -856,8 +856,9 @@ checked against the compiled tools, `input` is typed from that route, and the re route's structured object. The declaration imports the route modules type-only, so no route module, schema, or server code enters the App document. Without the augmentation — a handwritten server, or a program that omits the file — `call()` still works with `unknown` input and result, -and a view may declare its own contract map through the same `AppRegister` seam, as the -[MCP App example](../../examples/mcp-app.mdx) does for its handwritten `status` server. +and a view may declare its own contract map through the same `AppRegister` seam. The +[MCP App example](../../examples/mcp-app.mdx) relies on the generated file: its App calls +`tool:status/show-status` with the input and result the route's schemas declare. `AppRouteId`, `AppRouteInput`, and `AppRouteResult` name the same surface for wrappers. **Calls and results.** `call()` returns the structured result **directly**; the MCP `content`, diff --git a/website/docs/zh/examples/mcp-app.mdx b/website/docs/zh/examples/mcp-app.mdx index 995c9ea1e..c7d397db0 100644 --- a/website/docs/zh/examples/mcp-app.mdx +++ b/website/docs/zh/examples/mcp-app.mdx @@ -10,34 +10,38 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 - **在仓库根目录运行:** `pnpm example:mcp-app` - **包名:** `@agent-bundle-example/mcp-app` -- **公开依赖:** `agent-bundle`(`workspace:*`)、`@modelcontextprotocol/server`、`zod`;浏览器端 App +- **公开依赖:** `agent-bundle`(`workspace:*`)、`@agent-bundle/runtime`、`react`、`zod`;浏览器端 App 测试还用到 `@rstest/browser`、`@rstest/core`、`@rstest/playwright` 与 `playwright` -- **Target:** `portable`、`codex`、`claude`——MCP App 只为 `portable` 声明 +- **Target:** `portable`、`codex`、`claude`——每个 target 都带有 `status` 服务器及其 App - **凭据:** 不需要——eval 与夹具检查只读取签入的数据 - **源码:** [`examples/mcp-app`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/mcp-app) ## 它证明什么 -- **MCP 入口约定。** `src/mcp/status.ts` 默认导出 `status` 服务器工厂,而配置没有声明服务器 `entry`——构建 - 通过 `src/mcp/.ts` 约定发现它。见 - [MCP 服务器与 MCP App](../guide/authoring/mcp.mdx)。 -- **生成的 stdio 生命周期不由你来写。** 构建会把那个工厂包进生命周期外壳:console 重定向到 stderr 的 +- **路由就是注册。** 配置里没有写任何服务器、工具、资源或 App。`status` 服务器之所以存在,是因为 + `src/mcp/status/**` 下有三个路由模块:`tools/show-status.tsx`(一个服务的健康状况)、 + `resources/readiness-policy.tsx`(位于 `policy://mcp-app-example/readiness` 的发布规则),以及 + `apps/status.ts` + `status.html`(工具通过 `_meta.ui.resourceUri` 打开的 App,用 + `appResourceUri('status')` 只拼写一次)。没有手写的 `McpServer`、没有 `registerTool`,也不依赖 + `@modelcontextprotocol/server`。见[MCP 服务器与 MCP App](../guide/authoring/mcp.mdx)。 +- **生成的 stdio 生命周期不由你来写。** 构建输出服务器入口及其生命周期外壳:console 重定向到 stderr 的 守卫、信号处理、stdin EOF 退出、有界关停与心跳。 -- **MCP App 是编译出来的资源,不是被服务的页面。** 声明的 app 限定在 `portable`,因此只要选中了该投影 - 它就会被编译——只编译一次,位于产物根目录的 `mcp-apps/status.html`,带有稳定的 `resourceUri`——并被 - 内联进生成的 `status` 服务器入口,而所有选中宿主都从同一个根目录启动这个入口。 -- **降级证据是一等公民。** `status` 服务器提供不可变的 `compiler` 与 `payments-api` 健康记录,而 - `payments-api` 故意返回降级的延迟数据。有意思的输出是一项被如实报告的失败检查,而不是一张全绿截图。 +- **MCP App 是编译出来的资源,不是被服务的页面。** App 只编译一次,位于产物根目录的 + `mcp-apps/status.html`,带有稳定的 `resourceUri`,并被内联进生成的 `status` 服务器入口,而所有选中 + 宿主都从同一个根目录启动这个入口。 +- **降级证据是一等公民。** `src/service-status.ts` 持有 schema 以及不可变的 `compiler` 与 `payments-api` + 健康记录,而 `payments-api` 故意返回降级的延迟数据。有意思的输出是一项被如实报告的失败检查,而不是 + 一张全绿截图。 - **浏览器表面自成一个证明级别。** `tests/browser-app/status-panel.browser.test.ts` 通过 `agent-bundle/test/browser` 把生产编译出的 App HTML 挂载到产品桥接之上——也就是 [测试](../guide/development/testing.mdx)中的 `browser-app` 级别。 -- **App 是桥接的客户端,而不是自带 transport。** `views/status-panel.ts` 从 `agent-bundle/app` +- **App 是桥接的客户端,而不是自带 transport。** `src/mcp/status/apps/status.ts` 从 `agent-bundle/app` 创建客户端,通过 `onToolInput` / `onToolResult` 接收打开 App 的 `show-status` 输入与结构化结果,并用 - `onToolError` 展示失败或缺少结构化数据的打开结果, - 通过 `call('tool:status/refresh-status', …)` 刷新状态,并通过 - `request('resources/read', …)` 读取就绪度策略。`status` 服务器是手写的,因此不存在生成的路由 - 契约;视图通过结构化的 `AppRegister` augmentation 自行为两条工具声明类型。视图中不再保留 - JSON-RPC id、pending map、`postMessage('*')` 或 `structuredContent` 解包逻辑。详见 + 注册在 `tool:status/show-status` 上的 `onToolError` 展示失败或缺少结构化数据的打开结果(当宿主的 + initialize 结果指定 `show-status` 为打开工具时送达)。**Refresh status** 为屏幕上的服务再次调用同一个 + `show-status` 工具,**Read policy** 通过 `request('resources/read', …)` 读取 `readiness-policy` + 资源。路由 id、输入与结果由生成的 `.agent-bundle/routes.d.ts` 提供类型,因此 App 自己不声明任何契约 + 表。视图中不再保留 JSON-RPC id、pending map、`postMessage('*')` 或 `structuredContent` 解包逻辑。详见 [带类型的 App 客户端](../guide/authoring/mcp.mdx#带类型的-app-客户端)。 - **`web` 表面装在产物里。** 该示例配置了 `web: { apps: [{ app: 'status/status', tool: 'show-status', allow: ['call-tool'] }] }`, @@ -55,8 +59,10 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 | 路径 | 是什么 | | --- | --- | -| `src/mcp/status.ts` | 按约定被发现的 `status` 服务器工厂,提供那两条健康记录。 | -| `views/status-panel.ts` / `views/status-panel.html` | 编译为 `mcp-apps/status.html` 的 MCP App 入口与模板。 | +| `src/service-status.ts` | 服务 schema 与两条不可变的健康记录。 | +| `src/mcp/status/tools/show-status.tsx` | `show-status` 工具路由;其 `_meta.ui.resourceUri` 打开 App。 | +| `src/mcp/status/resources/readiness-policy.tsx` | `policy://mcp-app-example/readiness` 资源路由。 | +| `src/mcp/status/apps/status.ts` / `status.html` | 编译为 `mcp-apps/status.html` 的 MCP App 入口与模板。 | | `src/skills/service-readiness/` | 做出服务就绪度判断所需的证据、检查与报告。 | | `src/hooks/session-start.ts` | 把就绪度工作流加入兼容宿主的会话。 | | `src/scripts/check-service-fixture.ts` | 在发布演练之前校验签入的编译器夹具。 | @@ -79,7 +85,8 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 6. 打开 **Advanced → Protocol**,选择 portable 与 `status` 服务器,打开会话、列出工具、选择 `show-status`、选中 `payments-api` 并调用它。调用历史会显示降级摘要,以及标注了 Availability 与 P95 latency 的检查,其中后者失败。打开 App 预览:渲染出的面板通过 MCP Apps 桥接展示同一条记录, - 并带一个以文字标注的琥珀色 `degraded` 指示。检视协议轨迹、使用 **Restart MCP session**,然后关闭、 + 并带一个以文字标注的琥珀色 `degraded` 指示;**Refresh status** 与 **Read policy** 调用的是真实的工具 + 与资源。检视协议轨迹、使用 **Restart MCP session**,然后关闭、 重置并重新打开会话,以演练整个生命周期。 随后打开 **Trace**,查看通过会话 id 与 JSON-RPC 请求 id 关联起来的 MCP 请求、响应、通知与会话活动。 7. 在 **Advanced → Evals → Runs** 中选中 `mcp-app-status`,运行 `status-is-healthy`,查看归属于 diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index a9bc822af..450a8f473 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -758,8 +758,8 @@ declare module 'agent-bundle/app' { `resultSchema`。该声明以 type-only 方式导入路由模块,所以不会把路由模块、schema 或服务器代码带入 App 文档。模板已经在 `tsconfig.json` 的 `include` 中列出 `".agent-bundle/routes.d.ts"`;路由项目漏掉它时 会收到 `AB4834`。没有 augmentation 时——例如手写服务器——`call()` 仍能以 `unknown` 输入与结果工作,视图也可以 -通过同一个结构化 `AppRegister` seam 声明自己的本地契约 map;[MCP App 示例](../../examples/mcp-app.mdx) -就为手写的 `status` 服务器这样声明 `show-status` 与 `refresh-status`。`AppRouteId`、 +通过同一个结构化 `AppRegister` seam 声明自己的本地契约 map。[MCP App 示例](../../examples/mcp-app.mdx) +依赖的就是生成文件:它的 App 以路由 schema 声明的输入与结果调用 `tool:status/show-status`。`AppRouteId`、 `AppRouteInput` 与 `AppRouteResult` 为 wrapper 命名同一套类型表面。 **调用与结果。** `call()` 直接返回结构化结果对象;MCP 的 `content`、`_meta` 与 `isError` envelope From 7450177ba814c4643c482e87e47f38e8f907e0be Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 05:52:53 +0000 Subject: [PATCH 2/3] tests: Workbench surface, App preview e2e, and README URI scrape follow the conventional mcp-app --- .../mcp-app/src/mcp/status/apps/status.ts | 4 +-- examples/mcp-app/src/service-status.ts | 1 - packages/agent-bundle/tests/api.test.ts | 2 +- .../tests/workbench-surface.test.ts | 27 ++++++++++++++----- .../workbench/tests/examples-real.e2e.test.ts | 10 +++++-- website/docs/zh/examples/mcp-app.mdx | 5 ++-- 6 files changed, 33 insertions(+), 16 deletions(-) diff --git a/examples/mcp-app/src/mcp/status/apps/status.ts b/examples/mcp-app/src/mcp/status/apps/status.ts index ee3f9bbee..f255b3736 100644 --- a/examples/mcp-app/src/mcp/status/apps/status.ts +++ b/examples/mcp-app/src/mcp/status/apps/status.ts @@ -79,7 +79,6 @@ const client = createAppClient({ }); client.onToolInput(showStatusRoute, ({ service }) => { - currentService = service; serviceHeading.textContent = service; setStatus('checking'); summary.textContent = `Checking readiness for ${service}.`; @@ -110,8 +109,7 @@ document.querySelector('#read-policy')!.addEventListener('click', async () => { } }); -// Refresh re-runs the opening tool for the service on screen; the host asks -// for consent before the call reaches the server. +// Refresh re-runs the opening tool for the service on screen. document.querySelector('#refresh-status')!.addEventListener('click', async () => { if (currentService === undefined) return; try { diff --git a/examples/mcp-app/src/service-status.ts b/examples/mcp-app/src/service-status.ts index 4dc43764f..2bc76d176 100644 --- a/examples/mcp-app/src/service-status.ts +++ b/examples/mcp-app/src/service-status.ts @@ -38,4 +38,3 @@ const catalog: Readonly> = { /** The immutable health record of one example service; `payments-api` is deliberately degraded. */ export const serviceStatus = (service: Service): ServiceStatus => catalog[service]; - diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index c49a2bf37..07d78c87b 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -2080,7 +2080,7 @@ it('documents a versioned MCP App resource URI accepted by source validation', a const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-readme-uri-parent-')); const root = join(parent, 'project'); const documentedConfig = await readFile( - join(process.cwd(), 'examples', 'mcp-app', 'agent-bundle.config.ts'), + join(process.cwd(), 'examples', 'mcp-app', 'src', 'mcp', 'status', 'apps', 'status.ts'), 'utf8', ); const resourceUri = /resourceUri: '([^']+)'/u.exec(documentedConfig)?.[1]; diff --git a/packages/agent-bundle/tests/workbench-surface.test.ts b/packages/agent-bundle/tests/workbench-surface.test.ts index bba6b1843..9b2b5c5fb 100644 --- a/packages/agent-bundle/tests/workbench-surface.test.ts +++ b/packages/agent-bundle/tests/workbench-surface.test.ts @@ -179,20 +179,33 @@ describe('the Workbench surface of the audiobook curator', () => { }); /** - * Configured-only surfaces have no compiled route catalog, but authored - * hooks, scripts, and Skills still appear as application leaves. + * The smaller examples: a conventional graph of three routes, and a + * configured-only surface whose authored Skills and rules still appear as + * application leaves without any compiled route catalog. */ -describe('the Workbench surface of the configured-only examples', () => { - it('keeps configured leaves while reporting an empty compiled graph for the MCP App example', async () => { +describe('the Workbench surface of the smaller examples', () => { + it('projects the MCP App example as one generated server with a tool, a resource, and an App', async () => { const surface = await inspectWorkbenchSurface({ root: exampleRoot('mcp-app') }); - expect(surface.catalog.routeCount).toBe(0); - expect(surface.catalog.groups).toEqual([]); + expect(surface.catalog.routeCount).toBe(3); + expect(surface.catalog.groups.map((group) => [group.kind, group.entries.map((entry) => entry.route.id)])).toEqual([ + ['tool', ['tool:status/show-status']], + ['resource', ['resource:status/readiness-policy']], + ['app', ['app:status/status']], + ]); expect(surface.catalog.stateDefinition).toBeUndefined(); expect(surface.application.groups.map((group) => group.kind)).toEqual(['mcp', 'events', 'scripts', 'skills']); expect(applicationGroup(surface, 'mcp')).toMatchObject({ label: 'MCP', - servers: [expect.objectContaining({ label: 'status', mode: 'stdio', subgroups: [] })], + servers: [expect.objectContaining({ + label: 'status', + mode: 'generated', + subgroups: [ + expect.objectContaining({ label: 'Tools', leaves: [expect.objectContaining({ execution: 'invoke', label: 'show-status' })] }), + expect.objectContaining({ label: 'Resources', leaves: [expect.objectContaining({ execution: 'invoke', label: 'readiness-policy' })] }), + expect.objectContaining({ label: 'Apps', leaves: [expect.objectContaining({ execution: 'preview', label: 'status' })] }), + ], + })], }); expect(applicationGroup(surface, 'events')).toMatchObject({ label: 'Events / Hooks' }); expect(applicationGroup(surface, 'scripts')).toMatchObject({ label: 'Scripts' }); diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index 39853ed7c..864f491d6 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -324,9 +324,14 @@ e2e('drives every populated MCP App workflow surface in real Chrome', { timeout: await expect(page.getByRole('heading', { level: 1, name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'mcp-app', 'mcp-session-ready'); - const appLeaf = applicationLeaves(surface.application).find((leaf) => leaf.ref.kind === 'app' || leaf.execution === 'preview'); + // The App leaf opens a session against the published build and calls the + // bound `show-status` tool with the form input; the result feeds the preview. + const appLeaf = applicationLeaves(surface.application).find((leaf) => leaf.ref.kind === 'app'); + expect(appLeaf).toBeDefined(); if (appLeaf !== undefined) { await selectApplicationLeaf(page, server.url, appLeaf); + await page.getByLabel('service').selectOption('payments-api'); + await page.getByRole('button', { name: 'Call tool and preview' }).click(); const appText = async (selector: string): Promise => { for (const frame of page.frames()) { try { @@ -339,7 +344,8 @@ e2e('drives every populated MCP App workflow surface in real Chrome', { timeout: return undefined; }; try { - await waitForExampleValue(page, () => appText('#service'), (value) => value !== undefined, 'the App service'); + await waitForExampleValue(page, () => appText('#service'), (value) => value === 'payments-api', 'the App service'); + await waitForExampleValue(page, () => appText('#status'), (value) => value === 'degraded', 'the App status'); } catch (error) { await expectHealthyExamplePage(ledger); throw error; diff --git a/website/docs/zh/examples/mcp-app.mdx b/website/docs/zh/examples/mcp-app.mdx index c7d397db0..1605d9101 100644 --- a/website/docs/zh/examples/mcp-app.mdx +++ b/website/docs/zh/examples/mcp-app.mdx @@ -76,8 +76,9 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 以检视规范输入、返回的上下文,以及 Claude 投影。 3. 在 **Application → Scripts** 下选中 `check-service-fixture` 并运行。输出的检查器会解析自己输出模块 旁边打包好的状态夹具,因此它的成功与 shell 的工作目录无关。 -4. 在 **Application → MCP → status → Apps** 下选中 `status` App。它的实时预览占据路由工作区。 - **Advanced → Artifact** 显示复合插件根目录,无论选中哪个 target,`mcp-apps/status.html` 都出现在 +4. 在 **Application → MCP → status → Apps** 下选中 `status` App。工作区会针对已发布的构建打开一个会话, + 并把 `show-status` 绑定到该 App;选择一个服务并点击 **Call tool and preview**,面板就会渲染在工具结果 + 旁边。**Advanced → Artifact** 显示复合插件根目录,无论选中哪个 target,`mcp-apps/status.html` 都出现在 这里。 5. 在存在两次 eval 运行之前,**Advanced → Evals → Compare** 会刻意显示 `At least two recorded runs are needed before a comparison can be aligned.`——这是精确的空状态, From 77932441aacc9f92f241e83cf13495894a7d8f7a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 06:02:47 +0000 Subject: [PATCH 3/3] examples(mcp-app): refresh retries the requested service after a failed opening call --- examples/mcp-app/README.md | 2 +- examples/mcp-app/src/mcp/status/apps/status.ts | 4 ++-- examples/mcp-app/src/service-status.ts | 2 +- .../tests/browser-app/status-panel.browser.test.ts | 14 +++++++++++++- website/docs/en/examples/mcp-app.mdx | 12 +++++++----- website/docs/zh/examples/mcp-app.mdx | 4 ++-- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/examples/mcp-app/README.md b/examples/mcp-app/README.md index f942905f0..ee0051ca3 100644 --- a/examples/mcp-app/README.md +++ b/examples/mcp-app/README.md @@ -23,7 +23,7 @@ and Claude artifacts; every one of them carries the App resource. service's health, `resources/readiness-policy.tsx` serves the release rule at `policy://mcp-app-example/readiness`, and `apps/status.ts` + `status.html` compile to the `ui://mcp-app-example/status.html` App the - tool opens. `src/service-status.ts` holds the schemas and the immutable + tool opens. `src/service-status.ts` holds the schemas and the `compiler` and `payments-api` records; `payments-api` deliberately returns degraded latency evidence. The build owns registration, the stdio lifecycle shell, and `.agent-bundle/routes.d.ts`, which types the App's diff --git a/examples/mcp-app/src/mcp/status/apps/status.ts b/examples/mcp-app/src/mcp/status/apps/status.ts index f255b3736..a7ac2a128 100644 --- a/examples/mcp-app/src/mcp/status/apps/status.ts +++ b/examples/mcp-app/src/mcp/status/apps/status.ts @@ -14,7 +14,7 @@ const showStatusRoute = 'tool:status/show-status'; type Service = AppRouteInput['service']; type ServiceStatus = AppRouteResult; -/** The service on screen, for the refresh call. */ +/** The service the host asked for, so refresh can retry a failed opening call. */ let currentService: Service | undefined; const serviceHeading = document.querySelector('#service')!; @@ -58,7 +58,6 @@ const toolErrorDetail = (error: AppClientError): string => { }; const renderStatus = (result: ServiceStatus) => { - currentService = result.service; serviceHeading.textContent = result.service; setStatus(result.status); summary.textContent = result.summary; @@ -79,6 +78,7 @@ const client = createAppClient({ }); client.onToolInput(showStatusRoute, ({ service }) => { + currentService = service; serviceHeading.textContent = service; setStatus('checking'); summary.textContent = `Checking readiness for ${service}.`; diff --git a/examples/mcp-app/src/service-status.ts b/examples/mcp-app/src/service-status.ts index 2bc76d176..df6b376fe 100644 --- a/examples/mcp-app/src/service-status.ts +++ b/examples/mcp-app/src/service-status.ts @@ -36,5 +36,5 @@ const catalog: Readonly> = { }, }; -/** The immutable health record of one example service; `payments-api` is deliberately degraded. */ +/** The health record of one example service; `payments-api` is deliberately degraded. */ export const serviceStatus = (service: Service): ServiceStatus => catalog[service]; diff --git a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts index 8cfad3e68..2153bab30 100644 --- a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts +++ b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts @@ -258,7 +258,8 @@ const openingToolResults = (app: MountedBrowserApp): readonly BrowserAppTraffic[ )); it('exits checking and renders an unavailable outcome when the opening result is an error', async () => { - const app = await mountStatus({ toolResult: failedStatusResult }); + const calls: string[] = []; + const app = await mountStatus({ operations: operations({ calls }), toolResult: failedStatusResult }); await waitFor(() => app.document.querySelector('#status')?.textContent === 'unavailable'); expect(app.bridge.lifecycle).toBe('initialized'); @@ -280,6 +281,17 @@ it('exits checking and renders an unavailable outcome when the opening result is expect(app.document.querySelector('#status')?.textContent).not.toBe('healthy'); expect(app.document.querySelector('#summary')?.textContent).not.toBe('Every check is passing.'); expect(app.document.querySelectorAll('#checks li')).toHaveLength(0); + + // Refresh retries the requested service after a failed opening call. + app.document.querySelector('#refresh-status')!.click(); + await waitFor(() => app.pendingConsentChallenges.length === 1); + await expect(app.decideConsent(app.pendingConsentChallenges[0]!.id, true)).resolves.toBe(true); + await waitFor(() => app.document.querySelector('#status')?.textContent === 'degraded'); + expect(calls).toEqual(['show-status']); + expect(app.traffic.some(({ message }) => { + const args = messageParam(message, 'arguments'); + return message.method === 'tools/call' && isRecord(args) && args['service'] === 'payments-api'; + })).toBe(true); }); it('renders the unavailable outcome when the opening result has no structured content', async () => { diff --git a/website/docs/en/examples/mcp-app.mdx b/website/docs/en/examples/mcp-app.mdx index b749d0aef..f9f1592e4 100644 --- a/website/docs/en/examples/mcp-app.mdx +++ b/website/docs/en/examples/mcp-app.mdx @@ -35,7 +35,7 @@ to see how the surfaces fit together instead of studying one of them alone. `mcp-apps/status.html` in the artifact root with a stable `resourceUri`, and is inlined into the generated `status` server entry, which every selected host launches from that same root. - **Degraded evidence is first-class.** `src/service-status.ts` holds the schemas and the - immutable `compiler` and `payments-api` records, and `payments-api` deliberately returns + `compiler` and `payments-api` records, and `payments-api` deliberately returns degraded latency. The interesting output is a failing check that is reported honestly, not a green screenshot. - **A browser surface is its own proof level.** `tests/browser-app/status-panel.browser.test.ts` @@ -70,7 +70,7 @@ to see how the surfaces fit together instead of studying one of them alone. | Path | What it is | | --- | --- | -| `src/service-status.ts` | The service schemas and the two immutable health records. | +| `src/service-status.ts` | The service schemas and the two health records. | | `src/mcp/status/tools/show-status.tsx` | The `show-status` tool route; its `_meta.ui.resourceUri` opens the App. | | `src/mcp/status/resources/readiness-policy.tsx` | The `policy://mcp-app-example/readiness` resource route. | | `src/mcp/status/apps/status.ts` / `status.html` | The MCP App entry and template compiled to `mcp-apps/status.html`. | @@ -89,9 +89,11 @@ to see how the surfaces fit together instead of studying one of them alone. 3. Under **Application → Scripts**, select `check-service-fixture` and run it. The emitted checker resolves the packaged status fixture beside its emitted module, so it succeeds independently of the shell's working directory. -4. Select the `status` App under **Application → MCP → status → Apps**. Its live preview occupies - the route workspace. **Advanced → Artifact** shows the composite plugin root, where - `mcp-apps/status.html` appears whichever target is selected. +4. Select the `status` App under **Application → MCP → status → Apps**. The workspace opens a + session against the published build with `show-status` bound to the App; choose a service and + **Call tool and preview** to render the panel beside the tool result. **Advanced → Artifact** + shows the composite plugin root, where `mcp-apps/status.html` appears whichever target is + selected. 5. Before two eval runs exist, **Advanced → Evals → Compare** deliberately shows `At least two recorded runs are needed before a comparison can be aligned.` — the precise empty state, not an error. diff --git a/website/docs/zh/examples/mcp-app.mdx b/website/docs/zh/examples/mcp-app.mdx index 1605d9101..6c2fdc8f2 100644 --- a/website/docs/zh/examples/mcp-app.mdx +++ b/website/docs/zh/examples/mcp-app.mdx @@ -29,7 +29,7 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 - **MCP App 是编译出来的资源,不是被服务的页面。** App 只编译一次,位于产物根目录的 `mcp-apps/status.html`,带有稳定的 `resourceUri`,并被内联进生成的 `status` 服务器入口,而所有选中 宿主都从同一个根目录启动这个入口。 -- **降级证据是一等公民。** `src/service-status.ts` 持有 schema 以及不可变的 `compiler` 与 `payments-api` +- **降级证据是一等公民。** `src/service-status.ts` 持有 schema 以及 `compiler` 与 `payments-api` 健康记录,而 `payments-api` 故意返回降级的延迟数据。有意思的输出是一项被如实报告的失败检查,而不是 一张全绿截图。 - **浏览器表面自成一个证明级别。** `tests/browser-app/status-panel.browser.test.ts` 通过 @@ -59,7 +59,7 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 | 路径 | 是什么 | | --- | --- | -| `src/service-status.ts` | 服务 schema 与两条不可变的健康记录。 | +| `src/service-status.ts` | 服务 schema 与两条健康记录。 | | `src/mcp/status/tools/show-status.tsx` | `show-status` 工具路由;其 `_meta.ui.resourceUri` 打开 App。 | | `src/mcp/status/resources/readiness-policy.tsx` | `policy://mcp-app-example/readiness` 资源路由。 | | `src/mcp/status/apps/status.ts` / `status.html` | 编译为 `mcp-apps/status.html` 的 MCP App 入口与模板。 |