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
10 changes: 6 additions & 4 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,10 +553,12 @@ self-contained HTML and their gzip size, what a compressing transport would
carry. `AB4772` is the size advisory, one **warning** per App. A view that
reaches its host through `agent-bundle/app` carries the framework's client
and nothing else from the protocol stack; one that imports
`@modelcontextprotocol/ext-apps` instead starts at about 437 kB (104 kB gzip),
`zod` v3 and v4, `@modelcontextprotocol/sdk`, `zod-to-json-schema`, and
`ext-apps` itself, so the advisory bound of 1 MiB (1,048,576 bytes) sits at
roughly 2.4× that floor and at half the 2 MiB (2,097,152 bytes) bound above
`@modelcontextprotocol/ext-apps` 2.x instead starts at about 249 kB (65 kB
gzip): `zod` 4, `@modelcontextprotocol/client`, `@modelcontextprotocol/core`,
and `ext-apps` itself. A 1.x view, which also carries `zod` 3,
`@modelcontextprotocol/sdk`, and `zod-to-json-schema`, starts at about 445 kB
(106 kB gzip). The advisory bound of 1 MiB (1,048,576 bytes) sits at roughly
2.4× the 1.x floor and at half the 2 MiB (2,097,152 bytes) bound above
which the Workbench and `serve-app` hosts refuse the resource and the Rstest
browser harness refuses to mount it. The advisory fires when a production
build emits 1 MiB or more, and in either compile mode when the document
Expand Down
9 changes: 5 additions & 4 deletions examples/rsc-agent-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
},
"dependencies": {
"@agent-bundle/runtime": "workspace:*",
"@modelcontextprotocol/ext-apps": "1.7.5",
"@modelcontextprotocol/sdk": "1.30.0",
"express": "5.2.1",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/ext-apps": "2.0.0",
"@modelcontextprotocol/node": "2.0.0",
"@modelcontextprotocol/server": "2.0.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-server-dom-rspack": "0.1.0",
Expand All @@ -28,7 +30,6 @@
"@rsbuild/core": "2.2.5",
"@rsbuild/plugin-react": "2.1.0",
"@rstest/core": "0.11.12",
"@types/express": "5.0.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"agent-bundle": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const requiredEntries = Object.freeze([
'mcp/stdio',
'rsc/index',
] as const);
const executableAsyncEntries = Object.freeze(['mcp/http', 'mcp/stdio'] as const);
const executableAsyncEntries = Object.freeze(['mcp/stdio'] as const);
const maximumDefinitionStdout = 1024 * 1024;
const maximumDefinitionStderr = 64 * 1024;
const definitionTimeoutMs = 5_000;
Expand Down
8 changes: 4 additions & 4 deletions examples/rsc-agent-runtime/src/mcp/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpServer, type ServerContext } from '@modelcontextprotocol/server';

import { runtimeDefinition } from '../definition.js';
import { projectName, projectVersion } from '../project-identity.js';
import { createMcpHandlers } from './handlers.js';
import { resourceMetadata } from './host-metadata.js';
import type { McpRequestExtra, ResolveStateOptions } from './resolve-state.js';
import type { ResolveStateOptions } from './resolve-state.js';

export interface CreateRuntimeMcpServerOptions extends ResolveStateOptions {
publicMcpUrl?: string;
Expand All @@ -30,12 +30,12 @@ export const createRuntimeMcpServer = (options: CreateRuntimeMcpServerOptions =
throw new Error(`No MCP handler registered for ${tool.handlerId}`);
}

const callback = (input: unknown, extra: McpRequestExtra) =>
const callback = (input: unknown, ctx: ServerContext) =>
handler(
input !== null && typeof input === 'object' && typeof (input as { limit?: unknown }).limit === 'number'
? { limit: (input as { limit: number }).limit }
: {},
extra,
ctx,
);
const config = {
_meta: tool._meta,
Expand Down
18 changes: 9 additions & 9 deletions examples/rsc-agent-runtime/src/mcp/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
import { documentToCallToolResult } from '@agent-bundle/runtime';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { CallToolResult, ServerContext } from '@modelcontextprotocol/server';

import { createFileRuntimeKernel } from '../runtime/state-file.js';
import { requestAgentDocument } from '../flight/request-render.js';

import { resolveStateFile, type McpRequestExtra, type ResolveStateOptions } from './resolve-state.js';
import { resolveStateFile, type ResolveStateOptions } from './resolve-state.js';

type ToolInput = { limit?: number };
type McpToolHandler = (input: ToolInput, extra: McpRequestExtra) => Promise<CallToolResult>;
type McpToolHandler = (input: ToolInput, ctx: ServerContext) => Promise<CallToolResult>;

const textSnapshot = (snapshot: { edits: unknown[]; stateVersion: number }): CallToolResult => ({
content: [{ text: JSON.stringify(snapshot), type: 'text' }],
structuredContent: snapshot,
});

export const createMcpHandlers = (options: ResolveStateOptions): Record<string, McpToolHandler> => ({
recent_edits: async (input, extra) => {
const stateFile = await resolveStateFile(options, extra);
recent_edits: async (input, ctx) => {
const stateFile = await resolveStateFile(options, ctx);
const snapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot({ limit: input.limit });
return textSnapshot(snapshot);
},
render_edit_timeline: async (input, extra) => {
const stateFile = await resolveStateFile(options, extra);
render_edit_timeline: async (input, ctx) => {
const stateFile = await resolveStateFile(options, ctx);
const snapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot({ limit: input.limit });
return documentToCallToolResult(
await requestAgentDocument({ snapshot, stateFile, type: 'mcp/render-timeline' }),
);
},
runtime_status: async (_input, extra) => {
const stateFile = await resolveStateFile(options, extra);
runtime_status: async (_input, ctx) => {
const stateFile = await resolveStateFile(options, ctx);
return documentToCallToolResult(await requestAgentDocument({ stateFile, type: 'mcp/runtime-status' }));
},
});
105 changes: 77 additions & 28 deletions examples/rsc-agent-runtime/src/mcp/http.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,102 @@
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';

import { hostHeaderValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, isJsonContentType } from '@modelcontextprotocol/server';

import { createRuntimeMcpServer } from './create-server.js';
import { allowsOrigin, resolveHttpSecurityConfig } from './http-security.js';

const port = Number.parseInt(process.env.PORT ?? '3000', 10);
const security = resolveHttpSecurityConfig();
const app = createMcpExpressApp({ allowedHosts: security.allowedHosts });
const allowsHost = hostHeaderValidation(security.allowedHosts);
const mcpHandler = createMcpHandler(
() => createRuntimeMcpServer({ publicMcpUrl: process.env.AGENT_RUNTIME_PUBLIC_MCP_URL }),
{ legacy: 'stateless' },
);
const handleMcp = toNodeHandler(mcpHandler);

app.use((request, response, next) => {
if (allowsOrigin(security, request.get('host'), request.get('origin'))) {
next();
return;
const maximumRequestBodyBytes = 100 * 1024;

const writeJson = (response: ServerResponse, status: number, body: unknown): void => {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
};

const writeJsonRpcError = (response: ServerResponse, status: number, code: number, message: string): void => {
writeJson(response, status, { error: { code, message }, id: null, jsonrpc: '2.0' });
};

const readRequestBody = async (request: IncomingMessage): Promise<Buffer | undefined> => {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request as AsyncIterable<Buffer>) {
size += chunk.length;
if (size <= maximumRequestBodyBytes) {
chunks.push(chunk);
}
}
return size > maximumRequestBodyBytes ? undefined : Buffer.concat(chunks);
};

response.status(403).json({
error: { code: -32000, message: `Invalid Origin header: ${request.get('origin')}` },
id: null,
jsonrpc: '2.0',
});
});
const serveMcp = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
if (request.method !== 'POST') {
await handleMcp(request, response);
return;
}

app.get('/health', (_request, response) => {
response.json({ ok: true, transport: 'streamable-http' });
});
const encoding = request.headers['content-encoding'];
if (encoding !== undefined && encoding.toLowerCase() !== 'identity') {
request.resume();
writeJsonRpcError(response, 415, -32000, `Unsupported Content-Encoding: ${encoding}`);
return;
}

app.post('/mcp', async (request, response) => {
const server = createRuntimeMcpServer({ publicMcpUrl: process.env.AGENT_RUNTIME_PUBLIC_MCP_URL });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const body = await readRequestBody(request);
if (body === undefined) {
writeJsonRpcError(response, 413, -32000, 'Request body too large');
return;
}

try {
await server.connect(transport);
await transport.handleRequest(request, response, request.body);
} catch (error) {
if (!response.headersSent) {
response.status(500).json({ error: error instanceof Error ? error.message : String(error) });
let parsedBody: unknown;
if (isJsonContentType(request.headers['content-type'])) {
try {
parsedBody = JSON.parse(body.toString('utf8'));
} catch {
writeJsonRpcError(response, 400, -32700, 'Parse error');
return;
}
} finally {
await server.close();
}
await handleMcp(request, response, parsedBody);
};

const httpServer = createServer((request, response) => {
if (!allowsHost(request, response)) {
return;
}

if (!allowsOrigin(security, request.headers.host, request.headers.origin)) {
writeJsonRpcError(response, 403, -32000, `Invalid Origin header: ${request.headers.origin}`);
return;
}

const { pathname } = new URL(request.url ?? '/', 'http://localhost');
if (request.method === 'GET' && pathname === '/health') {
writeJson(response, 200, { ok: true, transport: 'streamable-http' });
} else if (pathname === '/mcp') {
serveMcp(request, response).catch(() => response.destroy());
} else {
writeJson(response, 404, { error: 'Not found' });
}
});

const httpServer = app.listen(port, '127.0.0.1', () => {
httpServer.listen(port, '127.0.0.1', () => {
const address = httpServer.address();
const actualPort = typeof address === 'object' && address !== null ? address.port : port;
process.stderr.write(`${JSON.stringify({ port: actualPort, transport: 'streamable-http' })}\n`);
});

const close = (): void => {
void mcpHandler.close();
httpServer.close(() => process.exit(0));
};

Expand Down
17 changes: 7 additions & 10 deletions examples/rsc-agent-runtime/src/mcp/resolve-state.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';

import { ListRootsResultSchema, type ServerNotification, type ServerRequest } from '@modelcontextprotocol/sdk/types.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { ServerContext } from '@modelcontextprotocol/server';

import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js';

export type McpRequestExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;

export interface ResolveStateOptions {
stateFile?: string;
resolveStateFile?: (extra: McpRequestExtra) => string | undefined | Promise<string | undefined>;
resolveStateFile?: (ctx: ServerContext) => string | undefined | Promise<string | undefined>;
}

const usablePath = (value: string | undefined): string | undefined =>
value === undefined || value.trim() === '' ? undefined : resolve(value);

const stateFileFromRoots = async (extra: McpRequestExtra): Promise<string | undefined> => {
const stateFileFromRoots = async (ctx: ServerContext): Promise<string | undefined> => {
try {
const result = await extra.sendRequest({ method: 'roots/list' }, ListRootsResultSchema);
const result = await ctx.mcpReq.send({ method: 'roots/list' });
const root = result.roots[0];
if (root === undefined) {
return undefined;
Expand All @@ -30,8 +27,8 @@ const stateFileFromRoots = async (extra: McpRequestExtra): Promise<string | unde
}
};

export const resolveStateFile = async (options: ResolveStateOptions, extra: McpRequestExtra): Promise<string> => {
const resolvedByOption = options.resolveStateFile === undefined ? undefined : await options.resolveStateFile(extra);
export const resolveStateFile = async (options: ResolveStateOptions, ctx: ServerContext): Promise<string> => {
const resolvedByOption = options.resolveStateFile === undefined ? undefined : await options.resolveStateFile(ctx);
const explicit = usablePath(resolvedByOption) ?? usablePath(options.stateFile);
if (explicit !== undefined) {
return explicit;
Expand All @@ -42,7 +39,7 @@ export const resolveStateFile = async (options: ResolveStateOptions, extra: McpR
return fromEnvironment;
}

const fromRoots = await stateFileFromRoots(extra);
const fromRoots = await stateFileFromRoots(ctx);
if (fromRoots !== undefined) {
return fromRoots;
}
Expand Down
2 changes: 1 addition & 1 deletion examples/rsc-agent-runtime/src/mcp/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const main = async (): Promise<void> => {
const guard = redirectConsoleToStderr();
const { createRuntimeMcpServer } = await import('./create-server.js');
const server = createRuntimeMcpServer();
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
const { StdioServerTransport } = await import('@modelcontextprotocol/server/stdio');
guard.restoreProtocolStdout();
await runStdioServer({
server,
Expand Down
19 changes: 0 additions & 19 deletions examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -813,15 +813,15 @@ test('recaptures an identical cohort from its immutable checkpoints after an enq
expect(failed).toHaveLength(1);
});

test('requires every executable entry to declare its async cohort assets', async () => {
test('requires the lazily loading stdio entry to declare its async cohort assets', async () => {
const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-'));
const compilerRoot = join(storageRoot, 'compiler');
const store = createStore(storageRoot);
try {
await writeCompilerCohort(compilerRoot);
const manifestPath = join(compilerRoot, 'rsc', 'runtime-assets.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { entries: Record<string, { async?: unknown }> };
delete manifest.entries['mcp/http']?.async;
delete manifest.entries['mcp/stdio']?.async;
await writeFile(manifestPath, JSON.stringify(manifest), 'utf8');
const candidate = await store.begin({ id: 'missing-async', sourceRevision: 'source-missing-async' });
const snapshot = await captureCompilerCohort({
Expand Down
4 changes: 2 additions & 2 deletions examples/rsc-agent-runtime/tests/host-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Readable } from 'node:stream';

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { expect, test } from '@rstest/core';

import { ensureExampleBuilt } from './support/ensure-built.js';
Expand Down
Loading
Loading