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
9 changes: 9 additions & 0 deletions .changeset/recipient-notice-inbox.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 31 additions & 2 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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();',
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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 [
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-bundle/src/test/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -172,10 +173,11 @@ let dependenciesPromise: Promise<ServerRuntime & Renderer & Sdk> | undefined;
*/
const loadDependencies = async (): Promise<ServerRuntime & Renderer & Sdk> => {
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'),
Expand All @@ -187,6 +189,7 @@ const loadDependencies = async (): Promise<ServerRuntime & Renderer & Sdk> => {
createGeneratedRuntimeState: mount.createGeneratedRuntimeState,
createGeneratedRouteMcpServer: serverRuntime.createGeneratedRouteMcpServer,
createWarmFlightHost: runtime.createWarmFlightHost,
noticeInboxRoute,
renderAgentFlight: flight.renderAgentFlight,
runAgentRequest: runtime.runAgentRequest,
};
Expand Down Expand Up @@ -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
Expand Down
69 changes: 68 additions & 1 deletion packages/agent-bundle/tests/entry-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion packages/agent-bundle/tests/generated-route-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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' }],
});
Expand Down Expand Up @@ -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';",
Expand Down Expand Up @@ -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();
}
Expand Down
65 changes: 65 additions & 0 deletions packages/agent-bundle/tests/projection/mcp-in-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>[];
};
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();

Expand Down
4 changes: 4 additions & 0 deletions packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions packages/rsc-runtime/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading