From ef1bcdf581560ea31b23e91372ace9667c9b7fde Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 02:50:09 +0000 Subject: [PATCH] feat(notices): expose the recipient-scoped MCP inbox resource (#99 stage 3) --- .changeset/recipient-notice-inbox.md | 9 + .../agent-bundle/src/build/entry-shell.ts | 33 ++- packages/agent-bundle/src/test/mcp.ts | 9 +- .../agent-bundle/tests/entry-shell.test.ts | 69 ++++- .../tests/generated-route-server.test.ts | 29 +- .../tests/projection/mcp-in-memory.test.ts | 65 ++++ packages/rsc-runtime/package.json | 4 + packages/rsc-runtime/rslib.config.ts | 15 + packages/rsc-runtime/src/agent-request.ts | 5 +- packages/rsc-runtime/src/mount/index.ts | 1 + packages/rsc-runtime/src/notices/contract.ts | 12 +- .../rsc-runtime/src/notices/inbox-route.ts | 75 +++++ packages/rsc-runtime/src/notices/index.ts | 1 + packages/rsc-runtime/src/notices/ledger.ts | 49 +++ packages/rsc-runtime/src/notices/state.ts | 59 ++++ .../rsc-runtime/tests/notices-ledger.test.ts | 278 +++++++++++++++++- 16 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 .changeset/recipient-notice-inbox.md create mode 100644 packages/rsc-runtime/src/notices/inbox-route.ts diff --git a/.changeset/recipient-notice-inbox.md b/.changeset/recipient-notice-inbox.md new file mode 100644 index 000000000..737f0e00d --- /dev/null +++ b/.changeset/recipient-notice-inbox.md @@ -0,0 +1,9 @@ +--- +"@agent-bundle/runtime": minor +"agent-bundle": minor +--- + +Expose a recipient-scoped, read-only notice inbox through generated stateful +MCP servers. Inbox reads record bounded availability and observed re-read +evidence without acknowledging notices or marking delivery attempted; stateless +projects emit no inbox resource or related runtime imports. diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 8f7769c0b..cb10db308 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -24,6 +24,8 @@ export const mcpEntryRuntimeSpecifier = 'agent-bundle/mcp-entry'; */ export const mcpServerRuntimeSpecifier = 'agent-bundle/mcp-server-runtime'; +const noticeInboxRuntimeSpecifier = '@agent-bundle/runtime/notices/inbox-route'; + /** * The on-disk location of one runtime module used as a bundler alias, so * generated entries inline it instead of leaving an `agent-bundle` import in @@ -478,6 +480,16 @@ const routeRecords = (routes: readonly CompiledAgentRoute[]): readonly string[] routes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ config: ${stableJson(route.config)}, id: ${JSON.stringify(route.id)}, kind: ${JSON.stringify(route.kind)}, module: route${String(index)}, name: ${JSON.stringify(routeProtocolName(route))} }),`); +const noticeInboxImport = (state: NormalizedStateDefinition | undefined): readonly string[] => + state === undefined + ? [] + : [`import * as noticeInboxRoute from ${JSON.stringify(noticeInboxRuntimeSpecifier)};`]; + +const noticeInboxRecord = (state: NormalizedStateDefinition | undefined): readonly string[] => + state === undefined + ? [] + : [' [noticeInboxRoute.AGENT_NOTICE_INBOX_ROUTE_ID]: noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute),']; + const eventRouteImports = ( routes: readonly NormalizedHook[], offset: number, @@ -515,6 +527,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { runAgentRequest } from '@agent-bundle/runtime';", ...generatedStateImports(options.state, 'artifact'), + ...noticeInboxImport(options.state), ...routeImports(routes), ...eventRouteImports(eventRoutes, routes.length), ...providerImports(providers), @@ -535,6 +548,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ]), 'const routes = Object.freeze({', ...routeRecords(routes), + ...noticeInboxRecord(options.state), ...eventRouteRecords(eventRoutes, routes.length), '});', 'const requests = new Map();', @@ -615,8 +629,21 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo * `config.uri` and a non-MCP route inside an MCP server are compile-time * defects: they must fail the build, not the first request. */ -const assertRegistrableMcpRoutes = (routes: readonly CompiledAgentRoute[]): void => { +const assertRegistrableMcpRoutes = ( + routes: readonly CompiledAgentRoute[], + injectNoticeInbox: boolean, +): void => { for (const route of routes) { + if (injectNoticeInbox && routeProtocolName(route) === 'notice-inbox') { + throw new Error( + `Generated MCP route ${JSON.stringify(route.id)} uses the reserved protocol name "notice-inbox".`, + ); + } + if (injectNoticeInbox && route.config['uri'] === 'agent-bundle://notices/inbox') { + throw new Error( + `Generated MCP route ${JSON.stringify(route.id)} uses the reserved URI "agent-bundle://notices/inbox".`, + ); + } switch (route.kind) { case 'tool': case 'prompt': @@ -653,7 +680,7 @@ const assertRegistrableMcpRoutes = (routes: readonly CompiledAgentRoute[]): void */ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOptions): string => { const routes = executableMcpRoutes(options.routes); - assertRegistrableMcpRoutes(routes); + assertRegistrableMcpRoutes(routes, options.state !== undefined); const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; return [ @@ -666,11 +693,13 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ] : []), "import mcpApps from 'agent-bundle/mcp-apps';", + ...noticeInboxImport(options.state), ...routeImports(routes), '', `const ARTIFACT_EPOCH = ${JSON.stringify(artifactEpoch)};`, 'const routes = Object.freeze({', ...routeRecords(routes), + ...noticeInboxRecord(options.state), '});', '', ...(hasEvents diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 4b4630a49..cf25443b5 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -151,6 +151,7 @@ interface Renderer { readonly createElement: typeof import('react').createElement; readonly createGeneratedRuntimeState: typeof createGeneratedRuntimeState; readonly createWarmFlightHost: typeof import('@agent-bundle/runtime').createWarmFlightHost; + readonly noticeInboxRoute: typeof import('@agent-bundle/runtime/notices/inbox-route'); readonly renderAgentFlight: typeof import('@agent-bundle/runtime/flight/server').renderAgentFlight; readonly runAgentRequest: typeof import('@agent-bundle/runtime').runAgentRequest; } @@ -172,10 +173,11 @@ let dependenciesPromise: Promise | undefined; */ const loadDependencies = async (): Promise => { dependenciesPromise ??= (async () => { - const [serverRuntime, runtime, mount, flight, react, client] = await Promise.all([ + const [serverRuntime, runtime, mount, noticeInboxRoute, flight, react, client] = await Promise.all([ import('../mcp-server-runtime.ts'), import('@agent-bundle/runtime'), import('@agent-bundle/runtime/mount'), + import('@agent-bundle/runtime/notices/inbox-route'), import('@agent-bundle/runtime/flight/server'), import('react'), import('@modelcontextprotocol/client'), @@ -187,6 +189,7 @@ const loadDependencies = async (): Promise => { createGeneratedRuntimeState: mount.createGeneratedRuntimeState, createGeneratedRouteMcpServer: serverRuntime.createGeneratedRouteMcpServer, createWarmFlightHost: runtime.createWarmFlightHost, + noticeInboxRoute, renderAgentFlight: flight.renderAgentFlight, runAgentRequest: runtime.runAgentRequest, }; @@ -286,6 +289,10 @@ export const openInMemoryMcpServer = async < name: descriptor.id.slice(descriptor.id.lastIndexOf('/') + 1), }; } + if (options.state !== undefined) { + const record = dependencies.noticeInboxRoute.noticeInboxRouteRecord(dependencies.noticeInboxRoute); + routes[record.id] = record as never; + } // The in-process stand-in for the artifact's Flight worker: same request // scope, same Flight encode, same bytes handed back to the dispatcher, and diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 8bebf29de..9f123d491 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -267,6 +267,42 @@ it('fails the build on an MCP route the generated server cannot register', () => kind: 'cli', source: '/project/src/cli/migrate.tsx', }])).toThrow('non-MCP route'); + expect(() => generate({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [{ + config: {}, + id: 'tool:curator/notice-inbox', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/notice-inbox.tsx' }, + source: '/project/src/mcp/curator/tools/notice-inbox.tsx', + }], + serverName: 'curator', + state: { + id: 'project/tasks', + lifetime: 'process', + provenance: { kind: 'conventional', sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', + }, + workerFile: 'mcp-curator-flight.mjs', + })).toThrow('reserved protocol name'); + expect(() => generate({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [{ + config: { uri: 'agent-bundle://notices/inbox' }, + id: 'resource:curator/other', + kind: 'resource', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/resources/other.tsx' }, + source: '/project/src/mcp/curator/resources/other.tsx', + }], + serverName: 'curator', + state: { + id: 'project/tasks', + lifetime: 'process', + provenance: { kind: 'conventional', sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', + }, + workerFile: 'mcp-curator-flight.mjs', + })).toThrow('reserved URI'); }); @@ -407,8 +443,30 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(volatile).toContain("createGeneratedRuntimeState"); expect(volatile).toContain('createMemoryStateDriver({ lifetime: "process" })'); expect(volatile).toContain('noticeLedger'); + expect(volatile).toContain('import * as noticeInboxRoute from "@agent-bundle/runtime/notices/inbox-route"'); + expect(volatile).toContain('noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute)'); expect(volatile).not.toContain('@agent-bundle/runtime/state/sqlite'); expect(volatile).not.toContain('createSqliteStateDriver'); + expect(stateless).not.toContain('@agent-bundle/runtime/notices/inbox-route'); + expect(stateless).not.toContain('agent-bundle:notice-inbox'); + + const statelessEntry = entryShellModule.generatedRouteMcpEntrySource({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + workerFile: 'mcp-curator-flight.mjs', + }); + expect(statelessEntry).not.toContain('@agent-bundle/runtime/notices/inbox-route'); + expect(statelessEntry).not.toContain('agent-bundle:notice-inbox'); + const volatileEntry = entryShellModule.generatedRouteMcpEntrySource({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + state: state('process'), + workerFile: 'mcp-curator-flight.mjs', + }); + expect(volatileEntry).toContain('import * as noticeInboxRoute from "@agent-bundle/runtime/notices/inbox-route"'); + expect(volatileEntry).toContain('noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute)'); const durable = entryShellModule.generatedRouteFlightWorkerSource({ ...base, @@ -452,7 +510,16 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(volatileCli).not.toContain('@agent-bundle/runtime/state/sqlite'); expect(volatileCli).toContain('await bindings.close()'); - for (const generated of [stateless, volatile, durable, renderedWorker, statelessCli, volatileCli]) { + for (const generated of [ + stateless, + volatile, + statelessEntry, + volatileEntry, + durable, + renderedWorker, + statelessCli, + volatileCli, + ]) { const transpiled = ts.transpileModule(generated, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, reportDiagnostics: true, diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 65bc818ae..936bf81cc 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -143,6 +143,8 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re 'node:sqlite', 'createSqliteStateDriver', 'noticeLedger: bindings.noticeLedger', + '@agent-bundle/runtime/notices/inbox-route', + 'agent-bundle:notice-inbox', ]) { expect(source).not.toContain(forbidden); } @@ -164,10 +166,12 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re content: [{ text: 'Inspected **library**.', type: 'text' }], structuredContent: { invocationKind: 'tool', source: 'library' }, }); - await expect(client.listResources()).resolves.toMatchObject({ resources: [ + const resources = await client.listResources(); + expect(resources).toMatchObject({ resources: [ expect.objectContaining({ uri: 'catalog://books' }), expect.objectContaining({ uri: 'ui://curator/dashboard.html' }), ] }); + expect(resources.resources.map((resource) => resource.uri)).not.toContain('agent-bundle://notices/inbox'); await expect(client.readResource({ uri: 'catalog://books' })).resolves.toEqual({ contents: [{ mimeType: 'application/json', text: '{"books":1}', uri: 'catalog://books' }], }); @@ -258,6 +262,19 @@ it('observes one process-lifetime provider across consecutive generated tool cal const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-warm-')); roots.push(root); await writeGeneratedProject(root, { + 'src/state.ts': [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + "export default defineState({", + " events: { changed: z.object({ value: z.string() }).strict() },", + " id: 'generated-routes/process-state',", + ' initial: { value: "" },', + " lifetime: 'process',", + ' reduce: (_state, event) => ({ value: event.payload.value }),', + ' schema: z.object({ value: z.string() }).strict(),', + '});', + '', + ].join('\n'), 'src/mcp/curator/tools/warmth.tsx': [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -293,6 +310,16 @@ it('observes one process-lifetime provider across consecutive generated tool cal const secondContent = second.structuredContent as { instanceId: string; pid: number }; expect(secondContent.instanceId).toBe(firstId); expect(secondContent.pid).toBe((first.structuredContent as { pid: number }).pid); + await expect(session.client.listResources()).resolves.toMatchObject({ + resources: [expect.objectContaining({ uri: 'agent-bundle://notices/inbox' })], + }); + await expect(session.client.readResource({ uri: 'agent-bundle://notices/inbox' })).resolves.toEqual({ + contents: [{ + mimeType: 'application/json', + text: '{"notices":[]}', + uri: 'agent-bundle://notices/inbox', + }], + }); } finally { await session.close(); } 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 ef493b0d2..00bf4f249 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -189,6 +189,71 @@ describe('the in-memory MCP projection level', () => { } }); + it('injects the recipient-scoped notice inbox only for stateful servers', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-inbox-')); + const sessionIdentity = (sessionId: string) => ({ + source: 'native' as const, + state: 'available' as const, + value: { sessionId }, + }); + try { + const first = await openInMemoryMcpServer({ + context: { session: sessionIdentity('s1') }, + state: { + definition: stateDefinition, + driver: createSqliteStateDriver({ root }), + }, + }); + try { + await first.client.callTool({ + arguments: { message: 'recipient notice', recipientSession: 's1' }, + name: 'publish-notice', + }); + const resources = await first.client.listResources(); + expect(resources.resources.map((resource) => resource.uri)).toContain('agent-bundle://notices/inbox'); + const read = await first.client.readResource({ uri: 'agent-bundle://notices/inbox' }); + const content = read.contents[0]; + if (content === undefined || !('text' in content)) throw new TypeError('Expected text inbox content'); + const projection = JSON.parse(content.text) as { + notices: readonly Readonly>[]; + }; + expect(projection.notices).toEqual([expect.objectContaining({ + content: { + root: { kind: 'text', text: 'recipient notice' }, + status: 'success', + version: 1, + }, + exposure: expect.objectContaining({ channel: 'mcp-inbox', count: 1 }), + state: 'pending', + })]); + } finally { + await first.close(); + } + + for (const context of [{ session: sessionIdentity('s2') }, {}]) { + const other = await openInMemoryMcpServer({ + context, + state: { + definition: stateDefinition, + driver: createSqliteStateDriver({ root }), + }, + }); + try { + const read = await other.client.readResource({ uri: 'agent-bundle://notices/inbox' }); + const content = read.contents[0]; + if (content === undefined || !('text' in content)) throw new TypeError('Expected text inbox content'); + expect(JSON.parse(content.text)).toEqual({ notices: [] }); + } finally { + await other.close(); + } + } + } finally { + await rm(root, { force: true, recursive: true }); + } + + expect((await listMcpSurface()).resources).not.toContain('agent-bundle://notices/inbox'); + }); + it('leaves the browser App surface off the in-memory server', async () => { const surface = await listMcpSurface(); diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index c2b292cc6..873ded83b 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -54,6 +54,10 @@ "types": "./dist/notices/index.d.ts", "import": "./dist/notices.js" }, + "./notices/inbox-route": { + "types": "./dist/notices/inbox-route.d.ts", + "import": "./dist/notices/inbox-route.js" + }, "./mount": { "types": "./dist/mount/index.d.ts", "import": "./dist/mount.js" diff --git a/packages/rsc-runtime/rslib.config.ts b/packages/rsc-runtime/rslib.config.ts index 7191880c2..8219579b3 100644 --- a/packages/rsc-runtime/rslib.config.ts +++ b/packages/rsc-runtime/rslib.config.ts @@ -48,6 +48,21 @@ export default defineConfig({ entry: { mount: './src/mount/index.ts' }, }, }, + { + ...sharedLib, + // The generated MCP inbox resource is React-bearing and therefore stays + // separate from the lean notice ledger entry. + output: { + cleanDistPath: false, + externals: { + '../index.js': '../index.js', + './index.js': '../notices.js', + }, + }, + source: { + entry: { 'notices/inbox-route': './src/notices/inbox-route.ts' }, + }, + }, { ...sharedLib, // The sqlite driver is its own entry so `node:sqlite` (and its diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 6e310428a..b3a301000 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -142,8 +142,9 @@ export interface AgentRequestContext { */ readonly state: AgentStateHandle | undefined; /** - * Request-bound recipient notice handle (#99 narrow core). `read()` exposes - * notices attempted on this admitted event; `publish()` persists a detached + * Request-bound recipient notice handle (#99). `inbox()` exposes authorized + * pending notices without acknowledging them, `read()` exposes notices + * attempted on this admitted event, and `publish()` persists a detached * Agent Document snapshot after publish-time authorization. */ readonly notices: AgentNoticesHandle | undefined; diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index e86f31be7..feab1cd95 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -70,6 +70,7 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { openRequest: async () => Object.freeze({ close: () => undefined, handle: Object.freeze({ + inbox: reject, publish: reject, read: reject, }), diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 6ce771088..ce4602494 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -42,6 +42,14 @@ export interface AgentNoticeAttemptReceipt { readonly invocationId: string; } +export interface AgentNoticeExposure { + readonly channel: 'mcp-inbox'; + readonly count: number; + readonly firstAt: string; + readonly lastAt: string; + readonly lastInvocationId: string; +} + export type AgentNoticeUnavailableReason = 'delivery-authorization-unavailable'; export interface AgentNotice { @@ -51,6 +59,7 @@ export interface AgentNotice { readonly dedupeKey?: string; readonly expiredAt?: string; readonly expiresAt?: string; + readonly exposure?: AgentNoticeExposure; readonly id: string; readonly priority: AgentNoticePriority; readonly recipient: AgentRecipient; @@ -100,7 +109,7 @@ export type AgentNoticeAuthorizationDecision = export interface AgentNoticeAuthorizationRequest { readonly noticeId?: string; - readonly phase: 'deliver' | 'publish'; + readonly phase: 'deliver' | 'publish' | 'read'; readonly principal: AgentNoticePrincipal; readonly recipient: AgentRecipient; } @@ -115,6 +124,7 @@ export interface AgentNoticeDelivery { } export interface AgentNoticesHandle { + inbox(): Promise; publish(input: AgentNoticePublishInput, options: AgentNoticePublishOptions): Promise; read(): Promise; } diff --git a/packages/rsc-runtime/src/notices/inbox-route.ts b/packages/rsc-runtime/src/notices/inbox-route.ts new file mode 100644 index 000000000..b3e2df136 --- /dev/null +++ b/packages/rsc-runtime/src/notices/inbox-route.ts @@ -0,0 +1,75 @@ +import { createElement } from 'react'; +import { z } from 'zod'; + +import { Agent, agent, type JsonValue } from '../index.js'; +import { AgentNoticeError, type AgentNotice } from './index.js'; + +export const AGENT_NOTICE_INBOX_ROUTE_ID = 'agent-bundle:notice-inbox'; +export const AGENT_NOTICE_INBOX_ROUTE_NAME = 'notice-inbox'; +export const AGENT_NOTICE_INBOX_URI = 'agent-bundle://notices/inbox'; + +export const config = Object.freeze({ + description: 'Read recipient-scoped pending notices without acknowledging them or marking delivery attempted.', + mimeType: 'application/json', + uri: AGENT_NOTICE_INBOX_URI, +}); + +export const inputSchema = z.object({ uri: z.string() }).strict(); + +export const resultSchema = z.object({ + contents: z.array(z.object({ + mimeType: z.literal('application/json'), + text: z.string(), + uri: z.string(), + }).strict()), +}).strict(); + +const projectNotice = (notice: AgentNotice) => Object.freeze({ + content: notice.content, + createdAt: notice.createdAt, + ...(notice.expiresAt === undefined ? {} : { expiresAt: notice.expiresAt }), + exposure: notice.exposure, + id: notice.id, + priority: notice.priority, + state: notice.state, +}); + +export function noticeInboxRouteRecord(module: TModule) { + return Object.freeze({ + config, + id: AGENT_NOTICE_INBOX_ROUTE_ID, + kind: 'resource' as const, + module, + name: AGENT_NOTICE_INBOX_ROUTE_NAME, + }); +} + +export default async function NoticeInboxRoute({ + input, +}: { + readonly input: z.infer; +}) { + const context = await agent(); + if (context.notices === undefined) { + throw new AgentNoticeError( + 'unauthorized', + 'Notice inbox is unavailable without a generated state-backed request scope', + ); + } + const notices = await context.notices.inbox(); + const projection = Object.freeze({ + notices: Object.freeze(notices.map(projectNotice)), + }); + const result = Object.freeze({ + contents: Object.freeze([Object.freeze({ + mimeType: 'application/json' as const, + text: JSON.stringify(projection), + uri: input.uri, + })]), + }); + return createElement( + Agent.Result, + { value: result as JsonValue }, + createElement(Agent.Text, null, 'Recipient notice inbox read without acknowledgement.'), + ); +} diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 43ea1bea7..7ff2ba26a 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -19,6 +19,7 @@ export type { AgentNoticeAuthorizer, AgentNoticeDelivery, AgentNoticeErrorCode, + AgentNoticeExposure, AgentNoticeExpiryOptions, AgentNoticeLedger, AgentNoticeLedgerSnapshot, diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index b86657e1f..ecb7bd567 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -248,6 +248,49 @@ const deliveryFor = ( return receipt === undefined ? undefined : Object.freeze({ notice, receipt }); }; +const inboxProgram = ( + store: NoticeStore, + authorize: AgentNoticeAuthorizer, + request: AgentNoticeRequest, +): Effect.Effect => Effect.gen(function*() { + const before = yield* storeEffect(() => store.read({ signal: request.signal })); + const readTime = Date.parse(request.invocation.startedAt); + const candidates = before.state.notices.filter((notice) => + notice.state === 'pending' + && Date.parse(notice.createdAt) <= readTime + // Inbox reads do not own durable expiry; event admission remains the expiry boundary. + && (notice.expiresAt === undefined || Date.parse(notice.expiresAt) > readTime) + && recipientMatchesPrincipal(notice.recipient, request.principal)); + const decisions = yield* Effect.forEach(candidates, (notice) => + authorizeEffect(authorize, { + noticeId: notice.id, + phase: 'read', + principal: request.principal, + recipient: notice.recipient, + }).pipe(Effect.map((decision) => ({ decision, id: notice.id })))); + const noticeIds = decisions + .filter(({ decision }) => decision.state === 'authorized') + .map(({ id }) => id); + if (noticeIds.length === 0) return Object.freeze([]); + const committed = yield* storeEffect(() => store.dispatch( + 'exposed', + { + at: request.invocation.startedAt, + channel: 'mcp-inbox', + invocationId: request.invocation.id, + noticeIds, + }, + { + idempotencyKey: `agent-notices:expose:${request.invocation.id}`, + signal: request.signal, + }, + )); + const returnedIds = new Set(noticeIds); + return Object.freeze(committed.state.notices + .filter((notice) => notice.state === 'pending' && returnedIds.has(notice.id)) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))); +}); + export const createAgentNoticeLedger = ( store: NoticeStore, options: CreateAgentNoticeLedgerOptions, @@ -319,6 +362,12 @@ export const createAgentNoticeLedger = ( let closed = false; const handle: AgentNoticesHandle = Object.freeze({ + inbox() { + return runPromise(Effect.gen(function*() { + yield* noticeEffect(() => assertOpen(closed, request.signal)); + return yield* inboxProgram(store, options.authorize, request); + })); + }, publish(input: AgentNoticePublishInput, publishOptions: AgentNoticePublishOptions) { return runPromise(Effect.gen(function*() { yield* noticeEffect(() => assertOpen(closed, request.signal)); diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 6abc7cbb9..e42035031 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -61,6 +61,14 @@ const attemptSchema = z.object({ invocationId: z.string().min(1), }).strict().readonly(); +const exposureSchema = z.object({ + channel: z.literal('mcp-inbox'), + count: z.number().int().positive(), + firstAt: z.string().min(1), + lastAt: z.string().min(1), + lastInvocationId: z.string().min(1), +}).strict().readonly(); + const noticeSchema = z.object({ attempts: z.array(attemptSchema).readonly(), content: documentSchema, @@ -68,6 +76,7 @@ const noticeSchema = z.object({ dedupeKey: z.string().min(1).optional(), expiredAt: z.string().min(1).optional(), expiresAt: z.string().min(1).optional(), + exposure: exposureSchema.optional(), id: z.string().min(1), priority: z.enum(['low', 'normal', 'high']), recipient: recipientSchema, @@ -89,6 +98,12 @@ export const agentNoticeEventSchemas = { principal: principalSchema, unavailableIds: z.array(z.string().min(1)), }).strict(), + exposed: z.object({ + at: z.string().min(1), + channel: z.literal('mcp-inbox'), + invocationId: z.string().min(1), + noticeIds: z.array(z.string().min(1)), + }).strict(), expired: z.object({ at: z.string().min(1), }).strict(), @@ -211,6 +226,40 @@ const transitionAdmission = ( } }; +const transitionExposure = ( + notice: AgentNotice, + input: { + readonly at: string; + readonly invocationId: string; + readonly noticeIds: ReadonlySet; + }, +): AgentNotice => { + switch (notice.state) { + case 'pending': { + if (!input.noticeIds.has(notice.id)) return notice; + return Object.freeze({ + ...notice, + exposure: Object.freeze({ + channel: 'mcp-inbox' as const, + count: (notice.exposure?.count ?? 0) + 1, + firstAt: notice.exposure?.firstAt ?? input.at, + lastAt: input.at, + lastInvocationId: input.invocationId, + }), + }); + } + case 'attempted': + case 'expired': + case 'unavailable': + case 'withdrawn': + return notice; + default: { + const exhaustive: never = notice.state; + return exhaustive; + } + } +}; + export const agentNoticeStateDefinition = ( lifetime: AgentStateLifetime = 'workspace-durable', ): AgentStateDefinition => @@ -252,6 +301,16 @@ export const agentNoticeStateDefinition = ( })), }; } + case 'exposed': { + const noticeIds = new Set(event.payload.noticeIds); + return { + notices: state.notices.map((notice) => transitionExposure(notice, { + at: event.payload.at, + invocationId: event.payload.invocationId, + noticeIds, + })), + }; + } default: { const exhaustive: never = event; return exhaustive; diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 5afff97af..e26d40492 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -35,7 +35,7 @@ const openLedger = async ( const driver = createMemoryStateDriver({ lifetime: 'process' }); const store = await driver.open(agentNoticeStateDefinition('process')); const ledger = createAgentNoticeLedger(store, { authorize }); - return { driver, ledger }; + return { driver, ledger, store }; }; const run = async ( @@ -198,6 +198,282 @@ describe('durable notice ledger', () => { }); }); +describe('recipient inbox', () => { + it('records bounded exposure evidence for an observed re-read', async () => { + const phases: string[] = []; + const { driver, ledger } = await openLedger((request) => { + phases.push(request.phase); + return { state: 'authorized' }; + }); + const published = await run(ledger, { + actorId: 'publisher', + id: 'publish-inbox', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('inbox'), + priority: 'high', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:inbox' })); + + const first = await run(ledger, { + actorId: 'recipient', + id: 'inbox-1', + kind: 'tool', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.inbox()); + const second = await run(ledger, { + actorId: 'recipient', + id: 'inbox-2', + kind: 'tool', + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.inbox()); + + expect(first).toEqual([expect.objectContaining({ + id: published.notice.id, + state: 'pending', + exposure: { + channel: 'mcp-inbox', + count: 1, + firstAt: '2026-09-01T19:01:00.000Z', + lastAt: '2026-09-01T19:01:00.000Z', + lastInvocationId: 'inbox-1', + }, + })]); + expect(second[0]?.exposure).toEqual({ + channel: 'mcp-inbox', + count: 2, + firstAt: '2026-09-01T19:01:00.000Z', + lastAt: '2026-09-01T19:02:00.000Z', + lastInvocationId: 'inbox-2', + }); + expect(phases).toEqual(['publish', 'read', 'read']); + expect((await ledger.read()).notices[0]).toEqual(second[0]); + await driver.close(); + }); + + it('is honestly empty for a non-matching principal', async () => { + const { driver, ledger } = await openLedger(); + await run(ledger, { + actorId: 'publisher', + id: 'publish-private', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('private'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:private' })); + + expect(await run(ledger, { + actorId: 'other', + id: 'inbox-other', + kind: 'tool', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.inbox())).toEqual([]); + expect((await ledger.read()).notices[0]).not.toHaveProperty('exposure'); + await driver.close(); + }); + + it('filters notices that are expired at read time without transitioning them', async () => { + const { driver, ledger } = await openLedger(); + const published = await run(ledger, { + actorId: 'publisher', + id: 'publish-expired-at-read', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('time bounded'), + expiresAt: '2026-09-01T19:01:00.000Z', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:expired-at-read' })); + + expect(await run(ledger, { + actorId: 'recipient', + id: 'inbox-after-expiry', + kind: 'tool', + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.inbox())).toEqual([]); + expect((await ledger.read()).notices).toEqual([ + expect.objectContaining({ id: published.notice.id, state: 'pending' }), + ]); + await driver.close(); + }); + + it('does not mark a read notice attempted before next-event admission', async () => { + const { driver, ledger } = await openLedger(); + const published = await run(ledger, { + actorId: 'publisher', + id: 'publish-read-then-event', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('still deliverable'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:read-then-event' })); + + const inbox = await run(ledger, { + actorId: 'recipient', + id: 'inbox-before-event', + kind: 'tool', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.inbox()); + expect(inbox[0]).toMatchObject({ id: published.notice.id, state: 'pending' }); + + const delivered = await run(ledger, { + actorId: 'recipient', + id: 'event-after-inbox', + kind: 'event', + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(delivered[0]).toMatchObject({ + notice: { id: published.notice.id, state: 'attempted' }, + receipt: { invocationId: 'event-after-inbox' }, + }); + await driver.close(); + }); + + it('omits read-authorized unavailable notices without exposure', async () => { + const { driver, ledger } = await openLedger((request) => ({ + state: request.phase === 'read' ? 'unavailable' : 'authorized', + })); + await run(ledger, { + actorId: 'publisher', + id: 'publish-read-unavailable', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('hidden'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:read-unavailable' })); + + expect(await run(ledger, { + actorId: 'recipient', + id: 'inbox-unavailable', + kind: 'tool', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.inbox())).toEqual([]); + expect((await ledger.read()).notices[0]).toMatchObject({ state: 'pending' }); + expect((await ledger.read()).notices[0]).not.toHaveProperty('exposure'); + await driver.close(); + }); + + it('fails a throwing read authorizer closed', async () => { + const { driver, ledger } = await openLedger((request) => { + if (request.phase === 'read') throw new Error('policy unavailable'); + return { state: 'authorized' }; + }); + await run(ledger, { + actorId: 'publisher', + id: 'publish-read-throws', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('fail closed'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:read-throws' })); + + await expect(run(ledger, { + actorId: 'recipient', + id: 'inbox-throws', + kind: 'tool', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.inbox())).rejects.toMatchObject({ + code: 'unauthorized', + }); + expect((await ledger.read()).notices[0]).not.toHaveProperty('exposure'); + await driver.close(); + }); + + it('excludes attempted, withdrawn, and durably expired notices', async () => { + const { driver, ledger } = await openLedger(); + await run(ledger, { + actorId: 'publisher', + id: 'publish-attempted', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('attempted'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:attempted' })); + await run(ledger, { + actorId: 'recipient', + id: 'event-attempted', + kind: 'event', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.read()); + const withdrawn = await run(ledger, { + actorId: 'publisher', + id: 'publish-inbox-withdrawn', + kind: 'tool', + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('withdrawn'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:inbox-withdrawn' })); + await ledger.withdraw(withdrawn.notice.id, { + at: '2026-09-01T19:03:00.000Z', + idempotencyKey: 'withdraw:inbox', + }); + await run(ledger, { + actorId: 'publisher', + id: 'publish-inbox-expired', + kind: 'tool', + startedAt: '2026-09-01T19:04:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('expired'), + expiresAt: '2026-09-01T19:05:00.000Z', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:inbox-expired' })); + await ledger.expire({ + at: '2026-09-01T19:06:00.000Z', + idempotencyKey: 'expire:inbox', + }); + + expect(await run(ledger, { + actorId: 'recipient', + id: 'inbox-terminal-states', + kind: 'tool', + startedAt: '2026-09-01T19:07:00.000Z', + }, async () => (await agent()).notices!.inbox())).toEqual([]); + expect((await ledger.read()).notices.map((notice) => notice.state)).toEqual([ + 'attempted', + 'withdrawn', + 'expired', + ]); + await driver.close(); + }); + + it('replays a published journal event without exposure', async () => { + const { driver, store } = await openLedger(); + await expect(store.dispatch('published', { + notice: { + attempts: [], + content: document('legacy journal'), + createdAt: '2026-09-01T19:00:00.000Z', + id: 'notice_legacy', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + state: 'pending', + }, + }, { + idempotencyKey: 'legacy:published', + })).resolves.toMatchObject({ + state: { + notices: [expect.not.objectContaining({ exposure: expect.anything() })], + }, + }); + await driver.close(); + }); +}); + describe('next-event delivery', () => { it('exposes only matching pending notices and records an attempted receipt', async () => { const phases: string[] = [];