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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-clients-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': minor
---

Generated MCP tool, resource, and prompt request scopes now observe native client, session, and authenticated actor identity alongside a derived process workspace, then forward those axes into the Flight worker while preserving typed unavailability when a transport omits them.
9 changes: 7 additions & 2 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,13 @@ Conventional route components receive only their surface props, such as
invocation plus `host`, `session`, `actor`, and `workspace` identity axes.
Each identity axis is `Observed`: transports publish an `available` value and
source when they know it, or `unavailable` with a typed reason when they do
not. Generated event scopes currently mount no actor principal, so event
routes observe actor as unavailable rather than receiving a fabricated value.
not. Generated MCP request scopes observe the negotiated client identity as a
native host, derive workspace from the server process working directory, and
use native transport session and HTTP authentication data when supplied. Bare
stdio supplies neither a session id nor HTTP actor authentication, so those
axes remain honestly unavailable. Generated event scopes currently mount no
actor principal, so event routes observe actor as unavailable rather than
receiving a fabricated value.

Handlers authored with `defineOperation` receive the same handle as optional
`context.request` in the second `execute` argument:
Expand Down
54 changes: 47 additions & 7 deletions packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import { canonicalAgentEvents, type CanonicalAgentEvent } from './routes/public.
import type {
AgentActorIdentity,
AgentDocument,
AgentHostIdentity,
AgentProgressReporter,
AgentRenderDispatch,
AgentRenderDispatcher,
AgentSessionIdentity,
AgentWorkspaceIdentity,
McpProgressNotificationParams,
McpProgressToken,
Observed,
Expand Down Expand Up @@ -94,17 +96,26 @@ export interface RenderedGeneratedRoute {

interface GeneratedRouteIdentity {
readonly actor?: Observed<AgentActorIdentity>;
readonly host?: Observed<AgentHostIdentity>;
readonly session?: Observed<AgentSessionIdentity>;
readonly workspace: Observed<AgentWorkspaceIdentity>;
}

/** Identity the server derives from the transport's own request context. */
const requestIdentity = (context: GeneratedRouteRequestContext): GeneratedRouteIdentity => ({
const requestIdentity = (
context: GeneratedRouteRequestContext,
clientName: string | undefined,
): GeneratedRouteIdentity => ({
...(context.http?.authInfo?.clientId === undefined
? {}
: { actor: available({ id: context.http.authInfo.clientId }, 'native') }),
...(typeof clientName === 'string' && clientName.trim() !== ''
? { host: available({ name: clientName }, 'native') }
: {}),
...(typeof context.sessionId === 'string' && context.sessionId.trim() !== ''
? { session: available({ sessionId: context.sessionId }, 'native') }
: {}),
workspace: available({ root: process.cwd() }, 'derived'),
});

/**
Expand Down Expand Up @@ -135,15 +146,18 @@ const projectorOptions = (context: GeneratedRouteRequestContext): {
* Renders one route inside a request scope, projects its render-event stream
* into an MCP result, and validates the document value against the route's
* own `resultSchema` — exactly what the generated server does per request.
* The host scope establishes the full transport-observed identity and the
* warm host's `agent()` probe forwards it into the Flight worker.
*/
export const renderGeneratedRoute = async (
dispatcher: AgentRenderDispatcher,
artifactEpoch: string,
route: GeneratedRouteRecord,
input: unknown,
context: GeneratedRouteRequestContext,
identity?: { readonly clientName?: string },
): Promise<RenderedGeneratedRoute> => runAgentRequest({
...requestIdentity(context),
...requestIdentity(context, identity?.clientName),
invocation: { artifactEpoch, kind: 'tool', operationId: route.id, surface: route.name },
signal: context.mcpReq.signal,
}, async () => {
Expand Down Expand Up @@ -183,7 +197,15 @@ export const registerGeneratedRoutes = (
inputSchema: route.module.inputSchema,
outputSchema: route.module.resultSchema,
} as never, (async (input: unknown, context: GeneratedRouteRequestContext) => {
const rendered = await renderGeneratedRoute(dispatcher, artifactEpoch, route, input, context);
const clientName = server.server.getClientVersion()?.name;
const rendered = await renderGeneratedRoute(
dispatcher,
artifactEpoch,
route,
input,
context,
{ clientName },
);
return attachMcpStructuredContent(rendered.toolResult, rendered.result);
}) as never);
break;
Expand All @@ -196,17 +218,35 @@ export const registerGeneratedRoutes = (
route.name,
uri,
selectedConfig(route.config, ['_meta', 'description', 'icons', 'mimeType', 'title']) as never,
(async (resourceUri: URL, context: GeneratedRouteRequestContext) =>
(await renderGeneratedRoute(dispatcher, artifactEpoch, route, { uri: resourceUri.href }, context)).result) as never,
(async (resourceUri: URL, context: GeneratedRouteRequestContext) => {
const clientName = server.server.getClientVersion()?.name;
return (await renderGeneratedRoute(
dispatcher,
artifactEpoch,
route,
{ uri: resourceUri.href },
context,
{ clientName },
)).result;
}) as never,
);
break;
}
case 'prompt':
server.registerPrompt(route.name, {
...selectedConfig(route.config, ['_meta', 'description', 'icons', 'title']),
argsSchema: route.module.inputSchema,
} as never, (async (input: unknown, context: GeneratedRouteRequestContext) =>
(await renderGeneratedRoute(dispatcher, artifactEpoch, route, input, context)).result) as never);
} as never, (async (input: unknown, context: GeneratedRouteRequestContext) => {
const clientName = server.server.getClientVersion()?.name;
return (await renderGeneratedRoute(
dispatcher,
artifactEpoch,
route,
input,
context,
{ clientName },
)).result;
}) as never);
break;
default: {
const unreachable: never = route.kind;
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-bundle/src/test/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ interface ServerRuntime {
}

interface Renderer {
readonly agent: typeof import('@agent-bundle/runtime').agent;
readonly createElement: typeof import('react').createElement;
readonly createGeneratedRuntimeState: typeof createGeneratedRuntimeState;
readonly createWarmFlightHost: typeof import('@agent-bundle/runtime').createWarmFlightHost;
Expand Down Expand Up @@ -185,6 +186,7 @@ const loadDependencies = async (): Promise<ServerRuntime & Renderer & Sdk> => {
return {
Client: client.Client,
InMemoryTransport: client.InMemoryTransport,
agent: runtime.agent,
createElement: react.createElement,
createGeneratedRuntimeState: mount.createGeneratedRuntimeState,
createGeneratedRouteMcpServer: serverRuntime.createGeneratedRouteMcpServer,
Expand Down Expand Up @@ -306,6 +308,7 @@ export const openInMemoryMcpServer = async <
artifactEpoch,
host: {
execute: async (request): Promise<ReadableStream<Uint8Array>> => {
const transport = await dependencies.agent();
// The generated server dispatches every MCP route kind as a tool
// invocation, so the operation id is the compiled route id.
const props = request.invocation.props as { readonly input?: unknown; readonly operationId?: string };
Expand All @@ -318,6 +321,12 @@ export const openInMemoryMcpServer = async <
const bindings = await runtimeState?.requestBindings({ signal: request.signal });
try {
return streamOf(await dependencies.runAgentRequest({
// Mirror the Flight worker boundary while allowing the documented
// harness context seam to override forwarded transport identity.
actor: transport.actor,
host: transport.host,
session: transport.session,
workspace: transport.workspace,
...context,
invocation: {
kind: 'tool' as const,
Expand Down
12 changes: 10 additions & 2 deletions packages/agent-bundle/tests/generated-route-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,17 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re
});
expect(inspected.structuredContent).toMatchObject({
actor: { reason: 'not-provided', state: 'unavailable' },
host: { reason: 'not-provided', state: 'unavailable' },
host: {
source: 'native',
state: 'available',
value: { name: 'generated-route-test' },
},
session: { reason: 'not-provided', state: 'unavailable' },
workspace: { reason: 'not-provided', state: 'unavailable' },
workspace: {
source: 'derived',
state: 'available',
value: { root: process.cwd() },
},
});
const resources = await client.listResources();
expect(resources).toMatchObject({ resources: [
Expand Down
19 changes: 19 additions & 0 deletions packages/agent-bundle/tests/packed-stdio-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@
const tools = await firstSession.client.listTools();
expect(tools.tools.map((tool) => tool.name).sort()).toEqual([
'catalog',
'context',
'echo',
'journal',
'mutation-probe',
'publish-notice',
'strict-report',
'ticket',
Expand Down Expand Up @@ -156,6 +158,23 @@
content: [{ text: '# Echo\n\npacked', type: 'text' }, { text: expect.stringContaining('workspace:'), type: 'text' }],
structuredContent: { message: 'packed', operationId: 'tool:harness/echo' },
});
await expect(firstSession.client.callTool({ arguments: {}, name: 'context' }))
.resolves.toMatchObject({
structuredContent: {
actor: { reason: 'not-provided', state: 'unavailable' },
host: {
source: 'native',
state: 'available',
value: { name: 'agent-bundle-packed-proof' },
},
session: { reason: 'not-provided', state: 'unavailable' },
workspace: {
source: 'derived',
state: 'available',
value: { root: project },
},
},
});
await expect(firstSession.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' }))
.resolves.toMatchObject({
content: [
Expand Down Expand Up @@ -247,7 +266,7 @@
timeoutMs: 10_000,
});
} catch (error) {
throw new Error(`Packed event route failed.\nserver stderr:\n${secondSession.stderr()}`, { cause: error });

Check failure on line 269 in packages/agent-bundle/tests/packed-stdio-projection.test.ts

View workflow job for this annotation

GitHub Actions / Release gates (Node 22.19)

packages/agent-bundle/tests/packed-stdio-projection.test.ts > serves compiled routes and durable state across packed process restarts

Packed event route failed. server stderr%3A [harness] stdio heartbeat (activity) pid=3919 uptime=0s idle=0s (node%3A3919) ExperimentalWarning%3A SQLite is an experimental feature and might change at any time (Use `node --trace-warnings ...` to show where the warning was created)
}
expect(JSON.stringify(eventResponse)).toContain(noticeId);
expect(JSON.stringify(eventResponse)).toContain('cross-process notice');
Expand Down
18 changes: 14 additions & 4 deletions packages/agent-bundle/tests/projection/mcp-in-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,24 @@ describe('the in-memory MCP projection level', () => {
expect(invocation.provenance.proofLevel).toBe('mcp-in-memory');
});

it('reports the identity axes the in-memory projection actually installs', async () => {
const invocation = await invokeMcpTool('context');
it('reports transport identity without accepting lookalike input fields', async () => {
const invocation = await invokeMcpTool('context', {
input: { host: 'spoofed-host', session: 'spoofed-session' },
});

expect(invocation.structuredContent).toEqual({
actor: { reason: 'not-provided', state: 'unavailable' },
host: { reason: 'not-provided', state: 'unavailable' },
host: {
source: 'native',
state: 'available',
value: { name: 'agent-bundle-in-memory-projection' },
},
session: { reason: 'not-provided', state: 'unavailable' },
workspace: { reason: 'not-provided', state: 'unavailable' },
workspace: {
source: 'derived',
state: 'available',
value: { root: process.cwd() },
},
});
});

Expand Down
Loading