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
3 changes: 1 addition & 2 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<server>/<name>`
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.
26 changes: 15 additions & 11 deletions examples/mcp-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<server-id>.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
`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

Expand All @@ -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.
Expand All @@ -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
Expand Down
14 changes: 0 additions & 14 deletions examples/mcp-app/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions examples/mcp-app/evals/graders/status-result.ts
Original file line number Diff line number Diff line change
@@ -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' };
};
Expand Down
4 changes: 3 additions & 1 deletion examples/mcp-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
37 changes: 0 additions & 37 deletions examples/mcp-app/src/compiler-status-contract.ts

This file was deleted.

57 changes: 0 additions & 57 deletions examples/mcp-app/src/mcp/status.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<typeof showStatusRoute>['service'];
type ServiceStatus = AppRouteResult<typeof showStatusRoute>;

/** The service the host asked for, so refresh can retry a failed opening call. */
let currentService: Service | undefined;

const serviceHeading = document.querySelector<HTMLHeadingElement>('#service')!;
const statusIndicator = document.querySelector<HTMLElement>('#status-indicator')!;
const status = document.querySelector<HTMLElement>('#status')!;
Expand All @@ -9,51 +25,11 @@ const checks = document.querySelector<HTMLUListElement>('#checks')!;
const bridgeOutcome = document.querySelector<HTMLParagraphElement>('#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<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
Expand All @@ -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;
Expand All @@ -92,15 +57,18 @@ 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) => {
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;
}));
};
Expand All @@ -109,28 +77,23 @@ 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;
Comment thread
ScriptedAlchemy marked this conversation as resolved.
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
// service in the heading, exit `checking`, and say why there is no status.
client.onToolError(showStatusRoute, (error) => {
setStatus('unavailable');
summary.textContent = `Readiness is unavailable: ${toolErrorDetail(error)}`;
renderChecks([]);
checks.replaceChildren();
});

document.querySelector('#toggle-details')!.addEventListener('click', () => {
Expand All @@ -146,11 +109,11 @@ document.querySelector('#read-policy')!.addEventListener('click', async () => {
}
});

// Refresh re-runs the opening tool for the service on screen.
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.';
Expand Down
Loading
Loading