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
13 changes: 13 additions & 0 deletions .changeset/lower-mcp-undefined-wire-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@agent-bundle/rsc-runtime": patch
---

`lowerMcpResult` now follows MCP SDK wire semantics for `undefined` inside
`structuredContent` and `_meta`: object properties whose value is `undefined`
are dropped and `undefined` array elements lower to `null`, exactly as
`JSON.stringify` serializes them (#44). Handlers written against SDK
serialization no longer fail at runtime when an optional field stays
`undefined` on some input path. Every other strict rejection — cycles,
accessors, sparse arrays, non-finite numbers, non-plain objects — is
preserved, and the JSON-boundary error now names the offending key path
instead of a fixed message.
12 changes: 12 additions & 0 deletions .changeset/mcp-apps-shared-across-servers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"agent-bundle": minor
---

One MCP App can now be served by several local servers (#42): declaring the
same app name with an identical definition (`entry`, `resourceUri`,
`template`, `_meta`; per-server `targets` may differ) under multiple servers
compiles the view once into one `mcp-apps/<name>.html` output and includes
it in every declaring server's `agent-bundle/mcp-apps` registry, instead of
failing as a duplicate compiled destination. Validation now flags only
conflicting redeclarations of an app name (AB4325) and resource URIs spread
across different app names (AB4330); identical shared declarations pass.
14 changes: 14 additions & 0 deletions .changeset/rsc-mcp-app-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@agent-bundle/rsc-runtime": minor
---

`defineRscAgentBundle` element trees can declare MCP Apps first-class:
`<McpApp>` children of `<McpServer>` lower into the owning server's
`mcp.servers[<name>].apps` record (#42), so `application.config` stays the
single source of truth for widget-bearing plugins instead of a config-side
splice. App names, entries, templates, `ui://` resource URIs, target
subsets, and JSON `_meta` are validated during lowering; app `targets`
default to the owning server's targets. The same `<McpApp>` may be declared
on several servers when the definitions are identical — the shared-app case
the compiler now supports — while conflicting redeclarations and resource
URIs spread across different app names are rejected.
13 changes: 13 additions & 0 deletions .changeset/rsc-mcp-listing-title-meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@agent-bundle/rsc-runtime": minor
---

`RscMcpDefinition` gains optional listing-level `title` and `_meta` slots;
`defineOperation` preserves them (with the same JSON wire-boundary
validation as result lowering, deep-frozen) and `createRscMcpServer`
forwards both verbatim into tool registration, so MCP Apps hosts can bind
widgets through `_meta.ui.resourceUri` (#43). The server factory also stops
synthesizing annotation defaults: it emits exactly the hints an operation
declares (`readOnly`, plus `destructive` / `idempotent` / `openWorld` when
present), because an absent hint carries MCP-spec default semantics on the
wire that a synthesized `false` silently rewrote.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export default defineConfig({

`scripts` is a record: its key is the stable output name and each value is either an entry path or `{ entry, targets? }`. JavaScript-family entries (`.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts`) are bundled. Shell and Python entries (`.sh`, `.bash`, `.py`) are copied byte-for-byte and keep the source permission mode. Every target receives selected scripts at `scripts/<name>.mjs` for bundled entries or `scripts/<name><source-extension>` for copied entries.

Skills follow the Agent Skills directory layout and may contain references and binary assets. Local MCP server entries and hook handlers are bundled. A local MCP App may import its generated browser resource list with `import apps from 'agent-bundle/mcp-apps'`; the generated resource uses the configured `resourceUri` and metadata.
Skills follow the Agent Skills directory layout and may contain references and binary assets. Local MCP server entries and hook handlers are bundled. A local MCP App may import its generated browser resource list with `import apps from 'agent-bundle/mcp-apps'`; the generated resource uses the configured `resourceUri` and metadata. Several local servers may serve one shared app by declaring the same app name with an identical definition (`entry`, `resourceUri`, `template`, `_meta`; `targets` may differ per server): the view compiles into one `mcp-apps/<name>.html` output and every declaring server's registry includes it. Conflicting redeclarations of an app name, or one `resourceUri` spread across different app names, stay rejected.

The compiler rejects unsafe output names, unsupported extensions, nonexistent or escaping source paths, unknown targets, and output collisions before it stages an artifact. It does not call Codex, Claude, or another host CLI, and it does not require API keys.

Expand Down
15 changes: 13 additions & 2 deletions examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,33 @@ test('rejects malformed or nested MCP protocol result trees', () => {
).toThrow('mcp-result structuredContent must be JSON-serializable');
});

test('follows JSON wire semantics for undefined instead of rejecting it', () => {
// Matches MCP SDK serialization: JSON.stringify drops undefined-valued
// properties and lowers undefined array elements to null.
const result = lowerMcpResult(
<Mcp.Result structuredContent={{ edits: [undefined, 'recorded'], note: undefined, stateVersion: 2 }}>
<Mcp.Text>ok</Mcp.Text>
</Mcp.Result>,
);

expect(result.structuredContent).toEqual({ edits: [null, 'recorded'], stateVersion: 2 });
expect(Object.hasOwn(result.structuredContent as object, 'note')).toBe(false);
});

test('rejects non-JSON structured content instead of normalizing it', () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const sparse = new Array<unknown>(2);
sparse[1] = 'present';

for (const value of [
undefined,
() => undefined,
Symbol('value'),
Number.NaN,
Number.POSITIVE_INFINITY,
new Date('2026-08-14T00:00:00.000Z'),
new Map(),
sparse,
[undefined],
cyclic,
]) {
expect(() =>
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export const compileMcpEntries = async (
const compiled = planCompiledMcpEntries(servers, options);
const virtualSources = await Promise.all(compiled.map(async (entry) => {
const records = await Promise.all((options.apps ?? [])
.filter((app) => app.serverId === entry.id)
.filter((app) => app.serverIds.includes(entry.id))
.map(async (app) => ({
...(app._meta === undefined ? {} : { _meta: app._meta }),
html: await readFile(app.output, 'utf8'),
Expand All @@ -176,7 +176,7 @@ export const compileMcpEntries = async (
sourceInputs: Object.freeze([
...sourceInputs,
...(options.apps ?? [])
.filter((app) => app.serverId === id)
.filter((app) => app.serverIds.includes(id))
.flatMap((app) => app.sourceInputs),
]),
virtualModules: [{
Expand Down
71 changes: 46 additions & 25 deletions packages/agent-bundle/src/build/mcp-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import { extname, resolve } from 'node:path';

import type { NormalizedMcpApp } from '../core/types.ts';
import { stableJson } from '../core/digest.ts';
import { listArtifactFiles, resolveArtifactDestination } from './emit.ts';
import { collectBundledOutputEvidence } from './provenance.ts';

Expand All @@ -16,7 +17,8 @@ export interface CompiledMcpApp {
readonly name: string;
readonly output: string;
readonly resourceUri: string;
readonly serverId: string;
/** Every server serving this compiled app; several when servers share one identical declaration. */
readonly serverIds: readonly string[];
readonly source: string;
readonly sourceInputs: readonly string[];
readonly target: string;
Expand Down Expand Up @@ -74,35 +76,54 @@ const assertSelfContainedViews = async (
}
};

/**
* The compile-relevant identity of an app declaration. Server declarations
* that agree on it describe one shared app compiled into one output; targets
* may differ because each server selects its own hosts.
*/
const appIdentity = (app: NormalizedMcpApp): string => stableJson({
...(app._meta === undefined ? {} : { _meta: app._meta }),
resourceUri: app.resourceUri,
source: app.source,
...(app.template === undefined ? {} : { template: app.template }),
});

export const planCompiledMcpApps = (
apps: readonly NormalizedMcpApp[],
options: { readonly outDir: string; readonly target: string },
): readonly CompiledMcpApp[] => {
const names = new Set<string>();
return Object.freeze(apps
.filter((app) => app.targets.includes(options.target))
.map((app) => {
if (names.has(app.name)) {
throw new Error(`Duplicate compiled MCP App destination ${JSON.stringify(`mcp-apps/${app.name}.html`)}.`);
const planned = new Map<string, { identity: string; serverIds: string[]; app: NormalizedMcpApp }>();
for (const app of apps.filter((candidate) => candidate.targets.includes(options.target))) {
const identity = appIdentity(app);
const existing = planned.get(app.name);
if (existing !== undefined) {
if (existing.identity !== identity) {
throw new Error(
`Duplicate compiled MCP App destination ${JSON.stringify(`mcp-apps/${app.name}.html`)}; `
+ 'servers may share an app name only with an identical declaration.',
);
}
names.add(app.name);
return Object.freeze({
...(app._meta === undefined ? {} : { _meta: app._meta }),
id: app.id,
mimeType: mcpAppMimeType,
name: app.name,
output: resolveArtifactDestination(resolve(options.outDir, 'mcp-apps'), `${app.name}.html`),
resourceUri: app.resourceUri,
serverId: app.serverId,
source: app.source,
sourceInputs: Object.freeze([
app.provenance.sourcePath,
app.source,
...(app.template === undefined ? [] : [app.template]),
]),
target: options.target,
});
}));
if (!existing.serverIds.includes(app.serverId)) existing.serverIds.push(app.serverId);
continue;
}
planned.set(app.name, { app, identity, serverIds: [app.serverId] });
}
return Object.freeze([...planned.values()].map(({ app, serverIds }) => Object.freeze({
...(app._meta === undefined ? {} : { _meta: app._meta }),
id: app.id,
mimeType: mcpAppMimeType,
name: app.name,
output: resolveArtifactDestination(resolve(options.outDir, 'mcp-apps'), `${app.name}.html`),
resourceUri: app.resourceUri,
serverIds: Object.freeze([...serverIds].sort((left, right) => left.localeCompare(right))),
source: app.source,
sourceInputs: Object.freeze([
app.provenance.sourcePath,
app.source,
...(app.template === undefined ? [] : [app.template]),
]),
target: options.target,
})));
};

export const compileMcpApps = async (
Expand Down
44 changes: 34 additions & 10 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, realpathSync, statSync } from 'node:fs';
import { basename, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path';

import type { Diagnostic } from '../core/diagnostics.ts';
import { stableJson } from '../core/digest.ts';
import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts';
import {
defaultGeneratedRuntime,
Expand Down Expand Up @@ -382,12 +383,31 @@ const validUiUri = (value: string): boolean => {
}
};

/**
* The declaration identity that lets several servers share one app name as
* one compiled app. Targets stay out of it: each declaring server selects
* its own hosts for the shared output.
*/
const mcpAppIdentity = (app: AgentBundleMcpApp): string | undefined => {
try {
return stableJson({
...(app._meta === undefined ? {} : { _meta: app._meta }),
entry: app.entry,
resourceUri: app.resourceUri,
...(app.template === undefined ? {} : { template: app.template }),
});
} catch {
// Non-JSON _meta is separately rejected by AB4338; never treat it as shareable.
return undefined;
}
};

const validateMcpApps = (
name: string,
server: AgentBundleMcpServer,
loaded: LoadedConfig,
seenNames: Set<string>,
seenUris: Set<string>,
seenApps: Map<string, string | undefined>,
seenUris: Map<string, string>,
): Diagnostic[] => {
if (server.apps === undefined) return [];
const diagnostics: Diagnostic[] = [];
Expand All @@ -408,20 +428,22 @@ const validateMcpApps = (
return diagnostics;
}
for (const [appName, value] of Object.entries(server.apps)) {
const identity = isRecord(value) ? mcpAppIdentity(value as AgentBundleMcpApp) : undefined;
if (!/^[a-z][a-z0-9-]*$/u.test(appName)) {
diagnostics.push(sourceDiagnostic(
'AB4324',
`MCP App name ${JSON.stringify(appName)} must use stable lowercase kebab-case.`,
loaded.configPath,
));
} else if (seenNames.has(appName)) {
} else if (seenApps.has(appName) && (identity === undefined || seenApps.get(appName) !== identity)) {
diagnostics.push(sourceDiagnostic(
'AB4325',
`MCP App name ${JSON.stringify(appName)} is duplicated.`,
`MCP App name ${JSON.stringify(appName)} is duplicated with a conflicting definition; `
+ 'servers may share an app name only with an identical declaration.',
loaded.configPath,
));
}
seenNames.add(appName);
if (!seenApps.has(appName)) seenApps.set(appName, identity);
if (!isRecord(value)) {
diagnostics.push(sourceDiagnostic(
'AB4326',
Expand Down Expand Up @@ -450,14 +472,16 @@ const validateMcpApps = (
`MCP App ${JSON.stringify(appName)} resourceUri must use ui:// with a nonempty host.`,
loaded.configPath,
));
} else if (seenUris.has(app.resourceUri)) {
} else if (seenUris.has(app.resourceUri) && seenUris.get(app.resourceUri) !== appName) {
diagnostics.push(sourceDiagnostic(
'AB4330',
`MCP App resourceUri ${JSON.stringify(app.resourceUri)} is duplicated.`,
`MCP App resourceUri ${JSON.stringify(app.resourceUri)} is declared by more than one app name.`,
loaded.configPath,
));
}
if (typeof app.resourceUri === 'string') seenUris.add(app.resourceUri);
if (typeof app.resourceUri === 'string' && !seenUris.has(app.resourceUri)) {
seenUris.set(app.resourceUri, appName);
}
if (app.template !== undefined) {
if (!nonemptyString(app.template)) {
diagnostics.push(sourceDiagnostic(
Expand Down Expand Up @@ -649,8 +673,8 @@ const validateMcp = (loaded: LoadedConfig): Diagnostic[] => {
if (!isRecord(mcp.servers)) {
return [sourceDiagnostic('AB4301', 'MCP configuration must define a servers object.', loaded.configPath)];
}
const names = new Set<string>();
const uris = new Set<string>();
const names = new Map<string, string | undefined>();
const uris = new Map<string, string>();
return Object.entries(mcp.servers).flatMap(([name, server]) => {
const diagnostics = validateMcpServer(name, server, loaded);
return isRecord(server)
Expand Down
Loading
Loading