diff --git a/.changeset/typed-handler-request-context.md b/.changeset/typed-handler-request-context.md new file mode 100644 index 000000000..922c39bba --- /dev/null +++ b/.changeset/typed-handler-request-context.md @@ -0,0 +1,13 @@ +--- +"@agent-bundle/runtime": minor +"agent-bundle": minor +--- + +Expose the transport-installed `AgentRequestContext` as optional +`context.request` to `defineOperation` handlers while preserving the same +request handle returned by `agent()`. Identity axes remain honest `Observed` +values with typed unavailable reasons when a transport cannot know them. + +Document `await agent()` as the route-component context contract and the +`renderRoute(..., { context })` identity-injection seam for tests. Business +input cannot override host, session, actor, workspace, or capability context. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 5e8a88c6d..43f70a9a8 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -131,6 +131,54 @@ unavailable-shaped value instead of throwing. `processLifetime` is reserved for the framework-owned process identity and hit counter, so provider filenames must not derive that key. +### Handler request context + +Conventional route components receive only their surface props, such as +`{ input, signal }`. They read transport-owned request context with +`await agent()` from `@agent-bundle/runtime`. The handle exposes the +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. + +Handlers authored with `defineOperation` receive the same handle as optional +`context.request` in the second `execute` argument: + +```ts +const status = defineOperation({ + // ... + execute: async (input, context) => { + const request = context.request; + // request is the identical handle returned by await agent() in this invocation. + return inspect(input, request); + }, +}); +``` + +The runtime supplies `context.request` inside `runAgentRequest`; direct +operation calls outside a request scope leave it absent. Transport context is +separate from validated business input, so fields named `host`, `session`, or +similar inside `input` cannot override request identity. + +Route-unit tests inject identity through the harness context seam: + +```ts +import { available } from '@agent-bundle/runtime'; +import { renderRoute } from 'agent-bundle/test'; + +await renderRoute('tool:curator/status', { + context: { + host: available({ name: 'test-host' }, 'native'), + session: available({ sessionId: 'test-session' }, 'native'), + }, + input: { subject: 'library' }, +}); +``` + +The same seam accepts `actor`, `workspace`, and `capabilities`; tests can use +`unavailable(...)` to pin a transport's honest absence semantics. + ### Migration nudges Source validation reports **informational** nudges (never errors — migrations diff --git a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx index a42c75b19..ea0ef5c2b 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx @@ -1,4 +1,4 @@ -import { Agent, agent } from '@agent-bundle/runtime'; +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; import type { AgentEventRouteProps } from 'agent-bundle'; export default async function AfterTool({ canonical }: AgentEventRouteProps) { @@ -8,8 +8,11 @@ export default async function AfterTool({ canonical }: AgentEventRouteProps) { id: notice.id, message: notice.content.root.kind === 'text' ? notice.content.root.text : '', })); + const actor: JsonValue = context.actor.state === 'available' + ? { source: context.actor.source, state: context.actor.state, value: { id: context.actor.value.id } } + : { reason: context.actor.reason, state: context.actor.state }; return ( - + {`Observed ${canonical.event} from ${canonical.provenance.host}.`} {notices.map((notice) => ( {`notice ${notice.id}: ${notice.message}`} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx new file mode 100644 index 000000000..5baa8a6bc --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx @@ -0,0 +1,46 @@ +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + description: 'Returns the request identity axes observed by this route.', + title: 'Context', +}; + +export const inputSchema = z.object({ + host: z.string().optional(), + session: z.string().optional(), +}).strict(); + +export const resultSchema = z.object({ + actor: z.unknown(), + host: z.unknown(), + session: z.unknown(), + workspace: z.unknown(), +}).strict(); + +export default async function Context() { + const context = await agent(); + const actor: JsonValue = context.actor.state === 'available' + ? { source: context.actor.source, state: context.actor.state, value: { id: context.actor.value.id } } + : { reason: context.actor.reason, state: context.actor.state }; + const host: JsonValue = context.host.state === 'available' + ? { source: context.host.source, state: context.host.state, value: { name: context.host.value.name } } + : { reason: context.host.reason, state: context.host.state }; + const session: JsonValue = context.session.state === 'available' + ? { source: context.session.source, state: context.session.state, value: { sessionId: context.session.value.sessionId } } + : { reason: context.session.reason, state: context.session.state }; + const workspace: JsonValue = context.workspace.state === 'available' + ? { source: context.workspace.source, state: context.workspace.state, value: { root: context.workspace.value.root } } + : { reason: context.workspace.reason, state: context.workspace.state }; + const result = { + actor, + host, + session, + workspace, + }; + return ( + + Request context observed. + + ); +} diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 2e9a4ec1e..6b00f7645 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -39,7 +39,15 @@ export interface AgentEventCanonicalIdentity { /** Complete host envelope after the adapter's schema and byte-bound validation. */ export type AgentEventNativePayload = Readonly>; -/** Props received by an event route's async default Server Component. */ +/** + * Props received by an event route's async default Server Component. + * + * Read transport-owned request identity with `await agent()` from + * `@agent-bundle/runtime`. The invocation, host, session, actor, and workspace + * axes are `Observed`, including typed unavailable reasons when the host + * cannot know an axis. Business payload fields cannot override them. + * Generated event scopes currently expose actor as unavailable. + */ export interface AgentEventRouteProps { readonly canonical: AgentEventCanonicalIdentity; readonly native: AgentEventNativePayload; @@ -71,7 +79,14 @@ export interface AgentEventRouteConfig { readonly tools?: readonly string[]; } -/** Props received by every executable MCP route's async default Server Component. */ +/** + * Props received by every executable MCP route's async default Server Component. + * + * Read transport-owned invocation, host, session, actor, and workspace axes + * with `await agent()` from `@agent-bundle/runtime`. Every identity axis is + * `Observed`; unavailable axes carry a typed reason, and `input` cannot + * override request identity. + */ export interface ToolRouteProps { readonly input: RouteSchemaOutput; readonly signal: AbortSignal; @@ -127,7 +142,14 @@ export interface CliRouteConfig { readonly positionals?: readonly string[]; } -/** Props received by every routed CLI command's async default function. */ +/** + * Props received by every routed CLI command's async default function. + * + * Read transport-owned invocation, host, session, actor, and workspace axes + * with `await agent()` from `@agent-bundle/runtime`. Every identity axis is + * `Observed`; unavailable axes carry a typed reason, and parsed command input + * cannot override request identity. + */ export interface CliRouteProps { readonly input: RouteSchemaOutput; readonly signal: AbortSignal; diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 8a2afbc65..12405e266 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -41,7 +41,13 @@ import type { TestableRouteDescriptor, } from './types.ts'; -/** Request-scoped overrides for one rendered route, over the runtime's own request contract. */ +/** + * Request-scoped overrides for one rendered route, over the runtime's own + * request contract. `host`, `session`, `actor`, `workspace`, and + * `capabilities` are the identity-injection seam for context-dependent route + * tests; construct observed values with `available` or `unavailable` from + * `@agent-bundle/runtime`. + */ export type RenderRouteContext = Omit & { readonly invocation?: Omit; readonly progress?: AgentProgressReporter; diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 936bf81cc..c4a7c9213 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -110,11 +110,11 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re "import { z } from 'zod';", "export const config = { annotations: { readOnlyHint: true }, description: 'Inspect one source.' };", "export const inputSchema = z.object({ source: z.string() }).strict();", - "export const resultSchema = z.object({ invocationKind: z.literal('tool'), source: z.string() }).strict();", + "export const resultSchema = z.object({ actor: z.unknown(), host: z.unknown(), invocationKind: z.literal('tool'), session: z.unknown(), source: z.string(), workspace: z.unknown() }).strict();", 'export default async function Inspect({ input, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", ' const context = await agent();', - ' const result = { invocationKind: context.invocation.kind, source: input.source };', + ' const result = { actor: context.actor, host: context.host, invocationKind: context.invocation.kind, session: context.session, source: input.source, workspace: context.workspace };', ' return (', ' ', ' {`Inspected **${input.source}**.`}', @@ -162,10 +162,17 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re await expect(client.listTools()).resolves.toMatchObject({ tools: [{ annotations: { readOnlyHint: true }, description: 'Inspect one source.', name: 'inspect' }], }); - await expect(client.callTool({ arguments: { source: 'library' }, name: 'inspect' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ + const inspected = await client.callTool({ arguments: { source: 'library' }, name: 'inspect' }, { signal: AbortSignal.timeout(10_000) }); + expect(inspected).toMatchObject({ content: [{ text: 'Inspected **library**.', type: 'text' }], structuredContent: { invocationKind: 'tool', source: 'library' }, }); + expect(inspected.structuredContent).toMatchObject({ + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { reason: 'not-provided', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + workspace: { reason: 'not-provided', state: 'unavailable' }, + }); const resources = await client.listResources(); expect(resources).toMatchObject({ resources: [ expect.objectContaining({ uri: 'catalog://books' }), @@ -524,7 +531,11 @@ it('renders one tool/after event route through two native thin clients', { retry ' const context = await agent();', ' const requestValue = context.providers.requestValue as { kind: string };', ' const tool = typeof native.tool_name === "string" ? native.tool_name : "unknown";', - ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${requestValue.kind}:${String(Object.isFrozen(context.providers))}`));', + " const actor = context.actor.state === 'unavailable' ? `unavailable:${context.actor.reason}` : `available:${context.actor.value.id}`;", + " const host = context.host.state === 'unavailable' ? `unavailable:${context.host.reason}` : `available:${context.host.source}:${context.host.value.name}`;", + " const session = context.session.state === 'unavailable' ? `unavailable:${context.session.reason}` : `available:${context.session.source}:${context.session.value.sessionId}`;", + " const workspace = context.workspace.state === 'unavailable' ? `unavailable:${context.workspace.reason}` : `available:${context.workspace.source}:${context.workspace.value.root}`;", + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${requestValue.kind}:${String(Object.isFrozen(context.providers))}:host:${host}:session:${session}:workspace:${workspace}:actor:${actor}`));', '}', '', ].join('\n')), @@ -576,10 +587,10 @@ it('renders one tool/after event route through two native thin clients', { retry }; const response = await runHook(hook.output, native); expect(response).toEqual(target === 'cursor' - ? { additional_context: 'cursor:Write:event:true' } + ? { additional_context: `cursor:Write:event:true:host:available:native:cursor:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided` } : { hookSpecificOutput: { - additionalContext: 'claude:Write:event:true', + additionalContext: `claude:Write:event:true:host:available:native:claude:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided`, hookEventName: 'PostToolUse', }, }); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 29476019a..649dc3fb5 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -30,7 +30,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'echo', 'journal', 'publish-notice', 'strict-report', 'ticket', 'unavailable', 'wait']); + expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'publish-notice', 'strict-report', 'ticket', 'unavailable', 'wait']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -39,6 +39,7 @@ describe('the in-memory MCP projection level', () => { 'prompt:harness/summarize', 'resource:harness/notes', 'tool:harness/catalog', + 'tool:harness/context', 'tool:harness/echo', 'tool:harness/journal', 'tool:harness/publish-notice', @@ -70,6 +71,17 @@ 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'); + + expect(invocation.structuredContent).toEqual({ + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { reason: 'not-provided', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + workspace: { reason: 'not-provided', state: 'unavailable' }, + }); + }); + it('carries a represented error to the protocol as isError rather than a transport failure', async () => { const invocation = await invokeMcpTool('unavailable'); diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index 70c62b8d6..49a491c53 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -7,6 +7,7 @@ import { renderRoute } from '../../src/test/render.ts'; import { testManifest } from '../../src/test/registry.ts'; const workspace = { source: 'native', state: 'available', value: { root: '/tmp/harness-library' } } as never; +const notProvided = { reason: 'not-provided', state: 'unavailable' }; /** The harness error one render rejected with; a resolved render is itself a failure. */ const rejection = async (render: Promise): Promise => { @@ -66,6 +67,48 @@ describe('renderRoute through the real renderer', () => { }); }); + it('reports typed unavailable identity axes when the harness receives no context injection', async () => { + const rendered = await renderRoute('tool:harness/context'); + + expect(rendered.result).toEqual({ + actor: notProvided, + host: notProvided, + session: notProvided, + workspace: notProvided, + }); + }); + + it('preserves injected identity values and their observation sources', async () => { + const rendered = await renderRoute('tool:harness/context', { + context: { + actor: { source: 'receipt', state: 'available', value: { id: 'actor-route-unit' } }, + host: { source: 'native', state: 'available', value: { name: 'route-unit-host' } }, + session: { source: 'native', state: 'available', value: { sessionId: 'route-unit-session' } }, + workspace: { source: 'derived', state: 'available', value: { root: '/tmp/route-unit' } }, + }, + }); + + expect(rendered.result).toEqual({ + actor: { source: 'receipt', state: 'available', value: { id: 'actor-route-unit' } }, + host: { source: 'native', state: 'available', value: { name: 'route-unit-host' } }, + session: { source: 'native', state: 'available', value: { sessionId: 'route-unit-session' } }, + workspace: { source: 'derived', state: 'available', value: { root: '/tmp/route-unit' } }, + }); + }); + + it('does not treat lookalike business input as request identity', async () => { + const rendered = await renderRoute('tool:harness/context', { + input: { host: 'spoofed-host', session: 'spoofed-session' }, + }); + + expect(rendered.result).toEqual({ + actor: notProvided, + host: notProvided, + session: notProvided, + workspace: notProvided, + }); + }); + it('auto-mounts isolated declared state into each route-unit render', async () => { const first = await renderRoute('tool:harness/journal', { input: { note: 'route-unit proof' } }); const second = await renderRoute('tool:harness/journal'); @@ -214,7 +257,7 @@ describe('renderRoute through the real renderer', () => { expectDocument(rendered) .toHaveStatus('success') .toContainMarkdown('Observed tool/after from claude.') - .toHaveValue(undefined); + .toHaveValue({ actor: notProvided }); }); it('renders a route module handed in directly, without the compiled manifest', async () => { diff --git a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts index b660c8077..365757745 100644 --- a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts +++ b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts @@ -5,6 +5,7 @@ export const routeHarnessContractFixtures = (): Record { 'prompt:harness/summarize', 'resource:harness/notes', 'tool:harness/catalog', + 'tool:harness/context', 'tool:harness/echo', 'tool:harness/journal', 'tool:harness/publish-notice', @@ -237,6 +238,7 @@ describe('the generated route registry', () => { const loaders = /loaders: \{\n(?[\s\S]*?)\n {2}\},/u.exec(source)?.groups?.body ?? ''; expect(loaders).toContain('"event:tool/after": () => import('); + expect(loaders).toContain('"tool:harness/context": () => import('); expect(loaders).toContain('"tool:harness/echo": () => import('); expect(loaders).toContain('"tool:harness/journal": () => import('); expect(loaders).toContain('"tool:harness/unavailable": () => import('); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index b3a301000..8a5cef38b 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -350,6 +350,11 @@ const currentLease = (): Lease => { return lease; }; +export const currentAgentRequest = (): AgentRequestContext | undefined => { + const lease = getStore().storage.getStore(); + return lease === undefined || lease.closed ? undefined : lease.handle; +}; + export const agent = async (): Promise => { const lease = currentLease(); open(lease); diff --git a/packages/rsc-runtime/src/mcp-server.ts b/packages/rsc-runtime/src/mcp-server.ts index a04cc5b03..7b61ff852 100644 --- a/packages/rsc-runtime/src/mcp-server.ts +++ b/packages/rsc-runtime/src/mcp-server.ts @@ -35,23 +35,29 @@ export const createRscMcpServer = ( description: mcp.description, inputSchema: operation.inputSchema, ...(mcp.title === undefined ? {} : { title: mcp.title }), - }, async (input, context) => runAgentRequest({ - ...(context.http?.authInfo?.clientId === undefined - ? {} - : { actor: available({ id: context.http.authInfo.clientId }, 'native') }), - invocation: { - kind: 'tool', - operationId: operation.id, - surface: mcp.name, - }, - ...(typeof context.sessionId === 'string' && context.sessionId.trim() !== '' - ? { session: available({ sessionId: context.sessionId }, 'native') } - : {}), - signal: context.mcpReq.signal, - }, async () => { - const result = await operation.execute(input, { signal: context.mcpReq.signal }); - return lowerMcpResult(operation.render(result)); - })); + }, async (input, context) => { + const clientName = server.server.getClientVersion()?.name; + return runAgentRequest({ + ...(context.http?.authInfo?.clientId === undefined + ? {} + : { actor: available({ id: context.http.authInfo.clientId }, 'native') }), + ...(typeof clientName === 'string' && clientName.trim() !== '' + ? { host: available({ name: clientName }, 'native') } + : {}), + invocation: { + kind: 'tool', + operationId: operation.id, + surface: mcp.name, + }, + ...(typeof context.sessionId === 'string' && context.sessionId.trim() !== '' + ? { session: available({ sessionId: context.sessionId }, 'native') } + : {}), + signal: context.mcpReq.signal, + }, async () => { + const result = await operation.execute(input, { signal: context.mcpReq.signal }); + return lowerMcpResult(operation.render(result)); + }); + }); } return server; }; diff --git a/packages/rsc-runtime/src/operation.ts b/packages/rsc-runtime/src/operation.ts index 1c2fe80e8..76dd4f277 100644 --- a/packages/rsc-runtime/src/operation.ts +++ b/packages/rsc-runtime/src/operation.ts @@ -1,9 +1,19 @@ import type { ReactNode } from 'react'; import type { ZodType } from 'zod'; +import { currentAgentRequest, type AgentRequestContext } from './agent-request.js'; import { frozenJsonRecord } from './lower-mcp.js'; +/** + * Per-invocation values supplied independently of business input. + * + * `request` is the transport-installed request handle when execution occurs + * inside `runAgentRequest`. Its identity axes are `Observed`, so an axis the + * transport cannot know is unavailable with a typed reason rather than + * fabricated. + */ export interface RscOperationContext { + readonly request?: AgentRequestContext; readonly signal: AbortSignal; } @@ -31,6 +41,10 @@ export interface RscMcpDefinition { export interface RscOperationInput { readonly cli?: RscCliDefinition; + /** + * Receives validated business input separately from transport-owned request + * identity. Fields in `input` cannot override `context.request`. + */ readonly execute: (input: TInput, context: RscOperationContext) => Promise; readonly id: string; readonly inputSchema: ZodType; @@ -100,9 +114,15 @@ export const defineOperation = ( return Object.freeze({ ...(cli === undefined ? {} : { cli }), - execute: async (value, context) => input.resultSchema.parse( - await input.execute(input.inputSchema.parse(value), context), - ), + execute: async (value, context) => { + const request = context.request ?? currentAgentRequest(); + const operationContext: RscOperationContext = request === undefined + ? context + : { ...context, request }; + return input.resultSchema.parse( + await input.execute(input.inputSchema.parse(value), operationContext), + ); + }, id, inputSchema: input.inputSchema, ...(mcp === undefined ? {} : { mcp }), diff --git a/packages/rsc-runtime/tests/mcp-server-wire.test.ts b/packages/rsc-runtime/tests/mcp-server-wire.test.ts index feac1d243..50642abad 100644 --- a/packages/rsc-runtime/tests/mcp-server-wire.test.ts +++ b/packages/rsc-runtime/tests/mcp-server-wire.test.ts @@ -60,9 +60,39 @@ const removeOperation = defineOperation({ resultSchema: z.object({ removed: z.boolean() }).strict(), }); +const contextOperation = defineOperation({ + execute: async (_input, context) => { + if (context.request === undefined) throw new Error('request context was not installed'); + return { + actor: context.request.actor, + host: context.request.host, + session: context.request.session, + workspace: context.request.workspace, + }; + }, + id: 'context', + inputSchema: z.object({ + host: z.string().optional(), + session: z.string().optional(), + }).strict(), + mcp: { + description: 'Observe request context.', + name: 'context', + readOnly: true, + server: 'demo', + }, + render: (result) => createElement(Mcp.Result, { structuredContent: result }), + resultSchema: z.object({ + actor: z.unknown(), + host: z.unknown(), + session: z.unknown(), + workspace: z.unknown(), + }).strict(), +}); + const application = defineRscApplication({ name: 'wire-demo', - operations: [searchOperation, removeOperation], + operations: [searchOperation, removeOperation, contextOperation], version: '1.0.0', }); @@ -87,6 +117,7 @@ const connectClient = async (): Promise<{ }> => { const server = createRscMcpServer(application, 'demo'); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serverTransport.sessionId = 'wire-session'; const wireResults = new Map>(); const originalSend = serverTransport.send.bind(serverTransport); serverTransport.send = async (message, options) => { @@ -106,7 +137,7 @@ const connectClient = async (): Promise<{ const wireToolListing = async (): Promise> => { const { client, wireResults } = await connectClient(); const parsed = await client.listTools(); - expect(parsed.tools).toHaveLength(2); + expect(parsed.tools).toHaveLength(3); const listing = [...wireResults.values()].find((result) => Array.isArray(result.tools)); const tools = (listing?.tools ?? []) as readonly WireTool[]; return new Map(tools.map((tool) => [tool.name, tool])); @@ -158,4 +189,29 @@ describe('createRscMcpServer wire listing', () => { expect(wireCall?.structuredContent).toEqual({ count: 3 }); expect(Object.hasOwn(wireCall?.structuredContent as object, 'note')).toBe(false); }); + + it('exposes native client and session identity without accepting lookalikes from tool input', async () => { + const { client } = await connectClient(); + const result = await client.callTool({ + arguments: { host: 'spoofed-host', session: 'spoofed-session' }, + name: 'context', + }); + + expect(result.structuredContent).toMatchObject({ + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'native', state: 'available', value: { name: 'wire-listing-test' } }, + session: { + source: 'native', + state: 'available', + value: { sessionId: 'wire-session' }, + }, + workspace: { reason: 'not-provided', state: 'unavailable' }, + }); + expect(result.structuredContent).not.toEqual(expect.objectContaining({ + host: expect.objectContaining({ value: { name: 'spoofed-host' } }), + })); + expect(result.structuredContent).not.toEqual(expect.objectContaining({ + session: expect.objectContaining({ value: { sessionId: 'spoofed-session' } }), + })); + }); }); diff --git a/packages/rsc-runtime/tests/operation-context.test.ts b/packages/rsc-runtime/tests/operation-context.test.ts new file mode 100644 index 000000000..b60e925ed --- /dev/null +++ b/packages/rsc-runtime/tests/operation-context.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; +import { z } from 'zod'; + +import type { AgentRequestContext } from '../src/index.js'; +import { + agent, + available, + defineOperation, + defineRscApplication, + runAgentRequest, + runRscCli, +} from '../src/index.js'; + +const signal = new AbortController().signal; + +const requestProbe = defineOperation({ + execute: async (_input, context) => { + const direct = await agent(); + return { + actor: context.request?.actor, + host: context.request?.host, + sameHandle: context.request === direct, + session: context.request?.session, + workspace: context.request?.workspace, + }; + }, + id: 'request-probe', + inputSchema: z.object({}).strict(), + render: () => createElement('mcp-result'), + resultSchema: z.object({ + actor: z.unknown(), + host: z.unknown(), + sameHandle: z.boolean(), + session: z.unknown(), + workspace: z.unknown(), + }).strict(), +}); + +describe('defineOperation request context', () => { + it('passes the current request handle and injected identity axes to the handler', async () => { + const result = await runAgentRequest({ + actor: available({ id: 'actor-1' }, 'receipt'), + host: available({ name: 'cursor' }, 'native'), + invocation: { kind: 'tool' }, + session: available({ sessionId: 'session-1' }, 'native'), + workspace: available({ root: '/tmp/project' }, 'derived'), + }, async () => requestProbe.execute({}, { signal })); + + expect(result).toEqual({ + actor: { source: 'receipt', state: 'available', value: { id: 'actor-1' } }, + host: { source: 'native', state: 'available', value: { name: 'cursor' } }, + sameHandle: true, + session: { source: 'native', state: 'available', value: { sessionId: 'session-1' } }, + workspace: { source: 'derived', state: 'available', value: { root: '/tmp/project' } }, + }); + }); + + it('omits request outside an invocation and lets a supplied request win over storage', async () => { + let outside: AgentRequestContext | undefined; + const outsideProbe = defineOperation({ + execute: async (_input, context) => { + outside = context.request; + return { ok: true }; + }, + id: 'outside-probe', + inputSchema: z.object({}).strict(), + render: () => createElement('mcp-result'), + resultSchema: z.object({ ok: z.literal(true) }).strict(), + }); + + await outsideProbe.execute({}, { signal }); + expect(outside).toBeUndefined(); + + await runAgentRequest({ + host: available({ name: 'outer' }, 'native'), + invocation: { id: 'outer', kind: 'tool' }, + }, async () => { + const supplied = await agent(); + let observed: AgentRequestContext | undefined; + const suppliedProbe = defineOperation({ + execute: async (_input, context) => { + observed = context.request; + return { ok: true }; + }, + id: 'supplied-probe', + inputSchema: z.object({}).strict(), + render: () => createElement('mcp-result'), + resultSchema: z.object({ ok: z.literal(true) }).strict(), + }); + await runAgentRequest({ + host: available({ name: 'inner' }, 'native'), + invocation: { id: 'inner', kind: 'tool' }, + }, async () => suppliedProbe.execute({}, { request: supplied, signal })); + expect(observed).toBe(supplied); + expect(observed?.host).toEqual({ source: 'native', state: 'available', value: { name: 'outer' } }); + }); + }); + + it('keeps single-argument handlers source-compatible', async () => { + const singleArgument = defineOperation({ + execute: async (input: { readonly value: number }) => ({ doubled: input.value * 2 }), + id: 'single-argument', + inputSchema: z.object({ value: z.number() }).strict(), + render: () => createElement('mcp-result'), + resultSchema: z.object({ doubled: z.number() }).strict(), + }); + + await expect(singleArgument.execute({ value: 3 }, { signal })).resolves.toEqual({ doubled: 6 }); + }); + + it('exposes derived CLI workspace and an unavailable host through the second argument', async () => { + let observed: { + readonly host: unknown; + readonly workspace: unknown; + } | undefined; + const cliProbe = defineOperation({ + cli: { + name: 'context', + parse: () => ({}), + summary: 'Read request context.', + usage: 'context', + }, + execute: async (_input, context) => { + observed = { + host: context.request?.host, + workspace: context.request?.workspace, + }; + return { ok: true }; + }, + id: 'cli-context', + inputSchema: z.object({}).strict(), + render: () => createElement('mcp-result'), + resultSchema: z.object({ ok: z.literal(true) }).strict(), + }); + const application = defineRscApplication({ + name: 'cli-context', + operations: [cliProbe], + version: '1.0.0', + }); + + await runRscCli(application, ['context'], { write: () => undefined }); + + expect(observed?.workspace).toEqual({ + source: 'derived', + state: 'available', + value: { root: process.cwd() }, + }); + expect(observed?.host).toEqual({ reason: 'unsupported-surface', state: 'unavailable' }); + }); +}); diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts index 6d0c7fd67..7745586ee 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -84,6 +84,7 @@ describe.sequential('state kernel packaging boundaries', () => { './state', './state/sqlite', './notices', + './notices/inbox-route', './mount', ]); for (const subpath of Object.keys(packageJson.exports)) {