diff --git a/.changeset/sweep-resource-uri-collision-stage-sweep.md b/.changeset/sweep-resource-uri-collision-stage-sweep.md new file mode 100644 index 000000000..f29597592 --- /dev/null +++ b/.changeset/sweep-resource-uri-collision-stage-sweep.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Reject two MCP App routes of one generated server that declare the same static `config.resourceUri` with the new `AB4829` diagnostic, naming both route files and the server, instead of registering whichever route was discovered first; the same URI on App routes of different servers still passes, since each generated server registers only its own Apps. Sweep staging files (`..stage--`) that an exited native Playground catalog publisher left orphaned on the next catalog publication: only singly linked entries of another epoch in the publisher's own directory are removed, a live winner's hard-linked staging entry, a running publisher's file, and foreign files are kept, and the sweep is bounded per publish (#430) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 768b4c3fb..2b9881d39 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -662,6 +662,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4826` | error | A route's static `config` calls `appResourceUri('')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `''`, `'/'`, `'app:/'`, or a relative module path. | | `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./.html'` beside the route. | | `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers..targets`. | +| `AB4829` | error | Two distinct MCP App routes of one generated server declare the same static `config.resourceUri`. The message names both route files and the server; a generated server registers one App per resource URI and never picks a side. The same URI on App routes of *different* servers is not a collision — each server registers only its own Apps. Give each App route of the server a distinct `config.resourceUri`, or remove the duplicate module. | | `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp//layout.*`) does not satisfy the layout contract: its default export is not a function component, it exports the route-only `config`/`inputSchema`/`resultSchema`, or it exports `execute`/`render`. Default-export one component receiving `{ children, route, signal }` that renders `Agent.Result` around `children`. | | `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. | | `AB4832` | error | A server layout (`src/mcp//layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e4efba35c..2fda3e233 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -76,7 +76,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/index.ts` | Library output with declarations. | `lib: false` | | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | -| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | +| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | diff --git a/packages/agent-bundle/src/dev/playground/native-playground-service.ts b/packages/agent-bundle/src/dev/playground/native-playground-service.ts index a1c01f424..16bb4cd9c 100644 --- a/packages/agent-bundle/src/dev/playground/native-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/native-playground-service.ts @@ -225,6 +225,17 @@ const stagingPublisherExited = (stagingEntry: string): boolean => { return isErrno(error, 'ESRCH'); } }; +/** + * A staging entry this publisher would have written: `..stage--` + * (the nonce is hex, or `guard-` for a recovery guard). + */ +const stagingEntryPattern = /^\.(?[a-z0-9][a-z0-9._-]*)\.stage-(?\d+)-[a-z0-9-]+$/iu; +/** + * @internal Bounds of one publication's orphan-staging sweep: at most + * `candidates` matching directory entries are examined and at most `removals` + * removed per publish; anything beyond waits for the next publication. + */ +export const nativePlaygroundStagingSweepLimits = Object.freeze({ candidates: 64, removals: 16 }); const maximumCatalogSnapshotNodes = 65_536; const maximumFixtureEntries = 4_096; const maximumSnapshotDepth = 16; @@ -1284,6 +1295,7 @@ export class NativePlaygroundService { { cause: cleanupFailures[0] }, ); } + await this.#sweepAbandonedStaging(directory, reference.epoch.id); const receipt = created && publicationIdentity !== undefined ? this.#publicationReceipt(path, publicationIdentity, true) : await this.#snapshotReceipt(reference, false); @@ -1300,6 +1312,53 @@ export class NativePlaygroundService { } } + /** + * A publisher that dies before its staging cleanup leaves its + * `..stage--` entry behind (#377). One that still aliases + * its epoch's sidecar is a publication the next reader of that epoch adopts + * (`#awaitStagedPublication`); one that aliases nothing — the publisher died + * before `link()`, lost the `EEXIST` race, or its sidecar was withdrawn + * later — is never read again and would otherwise accumulate forever. + * + * Each successful publication therefore sweeps its own catalog directory + * once: no recursion, at most `candidates` matching entries examined and + * `removals` removed. An entry goes only when it carries this publisher's + * naming pattern for an epoch other than the one being published, its + * embedded publisher pid has exited, and it is a singly linked regular file + * that does not alias its epoch's sidecar inode — a live winner's staging + * link, a guard, a foreign file, or anything ambiguous stays. The sweep is + * hygiene: it never changes the outcome of the publication that ran it, so + * any filesystem failure ends the sweep quietly instead of failing the epoch. + */ + async #sweepAbandonedStaging(directory: string, publishingEpochId: string): Promise { + let entries: readonly string[]; + try { entries = await readdir(directory); } + catch { return; } + const candidates: { readonly entry: string; readonly epochId: string }[] = []; + for (const entry of [...entries].sort()) { + if (candidates.length >= nativePlaygroundStagingSweepLimits.candidates) break; + const epochId = stagingEntryPattern.exec(entry)?.groups?.['epoch']; + if (epochId === undefined || epochId === publishingEpochId) continue; + candidates.push({ entry, epochId }); + } + let removed = 0; + for (const { entry, epochId } of candidates) { + if (removed >= nativePlaygroundStagingSweepLimits.removals) return; + // Our own entries and those of any still-running publisher are theirs. + if (!stagingPublisherExited(entry)) continue; + const stagingPath = join(directory, entry); + try { + const staged = await lstat(stagingPath); + if (!staged.isFile() || staged.nlink !== 1) continue; + if (await this.#sidecarStillLinked(join(directory, `${epochId}.json`), staged)) continue; + await this.#catalogStorage.remove(stagingPath, { force: true }); + removed += 1; + } catch (error) { + if (!isErrno(error, 'ENOENT')) return; + } + } + } + async #acceptedPublicationReceipt(reference: NativePlaygroundEpochReference): Promise { return this.#snapshotReceipt(reference, false); } diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 31166422c..1d7f114b9 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -838,6 +838,11 @@ export const compileRouteGraph = async ( )); } if (mode === 'generated') { + // A generated server registers each App under its resourceUri, so two + // App routes of one server claiming the same URI would otherwise + // resolve first-wins (AB4829). The same URI on another server is a + // different registry and never collides here. + const appRoutesByResourceUri = new Map(); for (const route of routes) { if (route.kind === 'app') { const resourceUri = route.config['resourceUri']; @@ -848,6 +853,18 @@ export const compileRouteGraph = async ( 'Export const config with the App resourceUri, then inspect again.', route.source, )); + } else { + const claimed = appRoutesByResourceUri.get(resourceUri); + if (claimed === undefined) { + appRoutesByResourceUri.set(resourceUri, route); + } else { + diagnostics.push(routeError( + 'AB4829', + `MCP App routes ${claimed.provenance.relativePath} and ${route.provenance.relativePath} of MCP server ${JSON.stringify(name)} both declare config.resourceUri ${JSON.stringify(resourceUri)}; a generated server registers one App per resource URI and never chooses silently.`, + 'Give each App route of the server a distinct config.resourceUri, or remove the duplicate route module, then inspect again.', + route.source, + )); + } } const template = route.config['template']; if (typeof template === 'string') { diff --git a/packages/agent-bundle/tests/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index c97d1a4c4..d3de973c5 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -8,6 +8,7 @@ import { expectExitCode } from '../src/eval/assertions.ts'; import { digest } from '../src/core/digest.ts'; import { NativePlaygroundService, + nativePlaygroundStagingSweepLimits, publishNativePlaygroundCatalogSnapshot, type NativePlaygroundCatalogStorage, type NativePlaygroundEpochReference, @@ -1912,6 +1913,159 @@ it('still rejects a persisted catalog aliased by a hard link that is not an epoc } }); +const sweepFixture = async (): Promise<{ + readonly catalogDirectory: string; + readonly root: string; + readonly service: NativePlaygroundService; + readonly staging: (epochId: string, pid: number, nonce: string) => string; + readonly stagingEntries: () => Promise; +}> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-staging-sweep-')); + const catalogDirectory = join(root, 'catalog'); + await mkdir(catalogDirectory, { recursive: true }); + const service = new NativePlaygroundService({ + catalogDirectory, + discover: async () => suite(), + planFixture: async () => fixturePlan, + projectRoot: '/project', + }); + return { + catalogDirectory, + root, + service, + staging: (epochId, pid, nonce) => join(catalogDirectory, `.${epochId}.stage-${String(pid)}-${nonce}`), + stagingEntries: async () => (await readdir(catalogDirectory)).filter((name) => name.includes('.stage-')).sort(), + }; +}; + +it('sweeps staging files orphaned by exited publishers of other epochs on the next publish', async () => { + const { catalogDirectory, root, service, staging, stagingEntries } = await sweepFixture(); + const exited = exitedPid(); + try { + // A publisher that died before link(), or that lost the EEXIST race, left + // a singly linked staging file; a recovery guard whose sidecar was later + // withdrawn is in the same position. + const orphan = staging('epoch-old', exited, 'deadbeef'); + const guard = staging('epoch-old', exited, 'guard-cafe'); + await writeFile(orphan, '{"stale":true}\n'); + await writeFile(guard, '{"stale":true}\n'); + // The epoch being published is never swept by its own publication, even + // when its orphan's publisher has exited: same-epoch links belong to the + // reader protocol of that epoch. + const sameEpoch = staging('epoch-new', exited, 'abandoned'); + await writeFile(sameEpoch, '{"stale":true}\n'); + expect(await stagingEntries()).toHaveLength(3); + + await service.catalog(epoch('epoch-new', join(root, 'artifact-new'))); + expect(await stagingEntries()).toEqual([`.epoch-new.stage-${String(exited)}-abandoned`]); + await expect(readFile(join(catalogDirectory, 'epoch-new.json'), 'utf8')).resolves.toContain('"epochId":"epoch-new"'); + + // The next epoch's publication sweeps what the previous one left alone. + await service.catalog(epoch('epoch-next', join(root, 'artifact-next'))); + expect(await stagingEntries()).toEqual([]); + await expect(readFile(join(catalogDirectory, 'epoch-new.json'), 'utf8')).resolves.toContain('"epochId":"epoch-new"'); + await expect(readFile(join(catalogDirectory, 'epoch-next.json'), 'utf8')).resolves.toContain('"epochId":"epoch-next"'); + } finally { + await service.close(); + await rm(root, { force: true, recursive: true }); + } +}); + +it('keeps a live winner\'s staging link, live-publisher and foreign entries, and never leaves the catalog directory while sweeping', async () => { + const { catalogDirectory, root, service, staging, stagingEntries } = await sweepFixture(); + const exited = exitedPid(); + try { + // An older epoch whose publisher exited between link() and cleanup: its + // staging entry aliases the winner sidecar and stays for reader recovery. + await service.catalog(epoch('epoch-old', join(root, 'artifact-old'))); + const oldSidecar = join(catalogDirectory, 'epoch-old.json'); + const linked = staging('epoch-old', exited, 'linked'); + await link(oldSidecar, linked); + // A publisher that is still running owns its staging file. + const live = staging('epoch-old', process.pid, 'live'); + await writeFile(live, '{"live":true}\n'); + // Foreign files that merely resemble the pattern are not this publisher's. + const foreign = [ + join(catalogDirectory, `epoch-old.stage-${String(exited)}-nodot`), + join(catalogDirectory, `.epoch-old.staged-${String(exited)}-suffix`), + join(catalogDirectory, `.epoch-old.stage-${String(exited)}`), + join(catalogDirectory, '.epoch-old.stage-notapid-nonce'), + join(catalogDirectory, `.epoch-old.stage-${String(exited)}-bad nonce`), + ]; + for (const path of foreign) await writeFile(path, '{"foreign":true}\n'); + // A directory and a symlink under the pattern are not regular files; the + // symlink's target outside the catalog directory is never followed. + const nestedDirectory = staging('epoch-old', exited, 'directory'); + await mkdir(nestedDirectory); + await writeFile(join(nestedDirectory, `.epoch-old.stage-${String(exited)}-nested`), '{"nested":true}\n'); + const outside = join(root, 'outside.json'); + await writeFile(outside, '{"outside":true}\n'); + const symlinked = staging('epoch-old', exited, 'symlink'); + await symlink(outside, symlinked); + // A staging entry whose sidecar exists but is a different inode aliases + // something unknown once doubly linked; it is left alone too. + const aliasedElsewhere = staging('epoch-old', exited, 'aliased'); + await link(outside, aliasedElsewhere); + const before = await stagingEntries(); + + await service.catalog(epoch('epoch-new', join(root, 'artifact-new'))); + + expect(await stagingEntries()).toEqual(before); + expect((await stat(linked)).ino).toBe((await stat(oldSidecar)).ino); + expect((await stat(oldSidecar)).nlink).toBe(2); + await expect(readFile(live, 'utf8')).resolves.toBe('{"live":true}\n'); + for (const path of foreign) await expect(readFile(path, 'utf8')).resolves.toBe('{"foreign":true}\n'); + await expect(readFile(join(nestedDirectory, `.epoch-old.stage-${String(exited)}-nested`), 'utf8')).resolves.toBe('{"nested":true}\n'); + await expect(readFile(outside, 'utf8')).resolves.toBe('{"outside":true}\n'); + expect((await stat(outside)).nlink).toBe(2); + await expect(readFile(symlinked, 'utf8')).resolves.toBe('{"outside":true}\n'); + // The kept, still-linked older epoch reads back through the recovery path. + const reader = new NativePlaygroundService({ + catalogDirectory, + catalogStagingSettleDeadlineMs: 50, + discover: async () => { throw new Error('A recovered catalog must not fall back to discovery.'); }, + planFixture: async () => fixturePlan, + projectRoot: '/project', + }); + await expect(reader.catalog(epoch('epoch-old', join(root, 'artifact-old')))).resolves.toMatchObject({ epochId: 'epoch-old' }); + await reader.close(); + } finally { + await service.close(); + await rm(root, { force: true, recursive: true }); + } +}); + +it('bounds the orphan sweep per publish and finishes on later publications', async () => { + const { root, service, staging, stagingEntries } = await sweepFixture(); + const exited = exitedPid(); + const { candidates, removals } = nativePlaygroundStagingSweepLimits; + try { + // More orphans than one publish removes: the cap's worth goes now, the rest next time. + const orphans = Array.from({ length: removals + 4 }, (_, index) => staging('epoch-bulk', exited, `orphan${String(index).padStart(2, '0')}`)); + for (const path of orphans) await writeFile(path, '{"stale":true}\n'); + await service.catalog(epoch('epoch-first', join(root, 'artifact-first'))); + expect(await stagingEntries()).toHaveLength(4); + await service.catalog(epoch('epoch-second', join(root, 'artifact-second'))); + expect(await stagingEntries()).toEqual([]); + + // Examination is bounded too: when the cap's worth of candidates all belong + // to a live publisher, an orphan sorting after them waits for a later sweep. + const held = Array.from({ length: candidates }, (_, index) => staging('epoch-aaaa', process.pid, `held${String(index).padStart(3, '0')}`)); + for (const path of held) await writeFile(path, '{"live":true}\n'); + const trailing = staging('epoch-zzzz', exited, 'trailing'); + await writeFile(trailing, '{"stale":true}\n'); + await service.catalog(epoch('epoch-third', join(root, 'artifact-third'))); + expect(await stagingEntries()).toHaveLength(candidates + 1); + await expect(readFile(trailing, 'utf8')).resolves.toBe('{"stale":true}\n'); + for (const path of held) await rm(path); + await service.catalog(epoch('epoch-fourth', join(root, 'artifact-fourth'))); + expect(await stagingEntries()).toEqual([]); + } finally { + await service.close(); + await rm(root, { force: true, recursive: true }); + } +}); + it('drains a gated catalog discovery before close and never publishes it after close begins', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-catalog-close-')); try { diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 0415bc0b2..b6bf8f50d 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1172,6 +1172,78 @@ it('rejects a route that advertises an App the server does not build for every t expect(configAppErrors[0]!.message).toContain('config App "panel"'); }); +it('rejects two App routes of one server that declare the same resourceUri with AB4829, but not the same URI across servers', async () => { + const app = (resourceUri: string): string => `export const config = { resourceUri: '${resourceUri}' };\n${moduleSource}`; + + // Same server, two distinct route modules, one URI: the compiler names both + // files and the server instead of registering whichever route came first. + const sameServer = await createRoot(); + await writeTree(sameServer, { + 'src/mcp/curator/apps/dashboard.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/apps/panel.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const sameServerGraph = await compileRouteGraph(sameServer, fixtureConfig()); + expect(codesOf(sameServerGraph.diagnostics)).toEqual(['AB4829']); + expect(sameServerGraph.diagnostics[0]).toMatchObject({ + severity: 'error', + sourcePath: join(sameServer, 'src/mcp/curator/apps/panel.tsx'), + }); + expect(sameServerGraph.diagnostics[0]!.message).toContain('src/mcp/curator/apps/dashboard.tsx'); + expect(sameServerGraph.diagnostics[0]!.message).toContain('src/mcp/curator/apps/panel.tsx'); + expect(sameServerGraph.diagnostics[0]!.message).toContain('"curator"'); + expect(sameServerGraph.diagnostics[0]!.message).toContain('"ui://curator/dashboard.html"'); + expect(sameServerGraph.diagnostics[0]!.recovery).toContain('distinct config.resourceUri'); + // Both routes stay visible in the IR beside the error; only the build is refused. + expect(sameServerGraph.servers[0]!.routes.filter((route) => route.kind === 'app').map((route) => route.id)) + .toEqual(['app:curator/dashboard', 'app:curator/panel']); + + // A third claimant is reported against the first, once per extra route. + const threeWay = await createRoot(); + await writeTree(threeWay, { + 'src/mcp/curator/apps/dashboard.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/apps/panel.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/apps/sidebar.tsx': app('ui://curator/dashboard.html'), + }); + const threeWayGraph = await compileRouteGraph(threeWay, fixtureConfig()); + expect(codesOf(threeWayGraph.diagnostics)).toEqual(['AB4829', 'AB4829']); + expect(threeWayGraph.diagnostics.map((diagnostic) => diagnostic.sourcePath)).toEqual([ + join(threeWay, 'src/mcp/curator/apps/panel.tsx'), + join(threeWay, 'src/mcp/curator/apps/sidebar.tsx'), + ]); + + // Two servers may legitimately serve the same App under one URI: each + // generated server registers only its own Apps, so nothing collides. + const acrossServers = await createRoot(); + await writeTree(acrossServers, { + 'src/mcp/archive/apps/dashboard.tsx': app('ui://shared/dashboard.html'), + 'src/mcp/archive/tools/list.ts': moduleSource, + 'src/mcp/curator/apps/dashboard.tsx': app('ui://shared/dashboard.html'), + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const acrossServersGraph = await compileRouteGraph(acrossServers, fixtureConfig()); + expect(acrossServersGraph.diagnostics).toEqual([]); + expect(acrossServersGraph.servers.map((server) => server.name)).toEqual(['archive', 'curator']); + + // A server kept custom ships no Apps, so its duplicates are not reported either. + const custom = await createRoot(); + await writeTree(custom, { + 'src/mcp/curator/apps/dashboard.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/apps/panel.tsx': app('ui://curator/dashboard.html'), + }); + expect((await compileRouteGraph(custom, fixtureConfig({ routes: { servers: { curator: 'custom' } } }))).diagnostics).toEqual([]); + + // The collision fails validate/inspect like AB4812 does. + const project = await createInspectProject({ + 'src/mcp/curator/apps/dashboard.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/apps/panel.tsx': app('ui://curator/dashboard.html'), + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const validation = await validate({ root: project }); + expect(codesOf(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'))).toEqual(['AB4829']); + expect((await inspect({ root: project })).state).toBe('invalid'); +}); + it('normalizes the App route template to its resolved path for the build', async () => { const html = '\n'; const routeRelative = await createInspectProject({ diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 172626dc7..b279b7954 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -218,7 +218,10 @@ const readPluginRoot = (env: Record): string | undef An MCP App is a browser surface compiled to self-contained HTML and registered as a resource on the generated server. The conventional location is `src/mcp//apps/*.{ts,tsx}`, where a -static `config.resourceUri` is required. Prefix the file with `_` to opt out. +static `config.resourceUri` is required. Two App routes of one server that declare the same +`config.resourceUri` fail with `AB4829` naming both files; the same URI on App routes of different +servers is fine, because each generated server registers only its own Apps. Prefix the file with +`_` to opt out. Declaring an App in config gives it an explicit HTML template and target restriction: diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index fdcb446df..473f32c7f 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -197,8 +197,9 @@ const readPluginRoot = (env: Record): string | undef ## MCP App MCP App 是一个浏览器表面,编译为自包含 HTML 并作为资源注册到生成的服务器上。约定位置是 -`src/mcp//apps/*.{ts,tsx}`,其中必须提供静态 `config.resourceUri`。给文件名加 `_` 前缀即可 -退出。 +`src/mcp//apps/*.{ts,tsx}`,其中必须提供静态 `config.resourceUri`。同一服务器的两个 App 路由 +声明相同的 `config.resourceUri` 会以 `AB4829` 失败并点名两个文件;不同服务器的 App 路由使用相同 URI +则没有问题,因为每个生成的服务器只注册自己的 App。给文件名加 `_` 前缀即可退出。 在配置中声明 App 可以为它指定显式的 HTML 模板与 target 限制: