diff --git a/.changeset/remove-legacy-entry-conventions.md b/.changeset/remove-legacy-entry-conventions.md new file mode 100644 index 000000000..6260f6d31 --- /dev/null +++ b/.changeset/remove-legacy-entry-conventions.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"create-agent-bundle": patch +--- + +Require every local stdio MCP entry to default-export a server factory: self-connecting entries no longer build and AB4730 is now an error instead of an informational nudge; retire AB4736, so documents left in the top-level `skills/`, `commands/`, and `rules/` locations are ignored rather than reported (#839) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e6209886a..9d7d82af2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -24,7 +24,7 @@ even when no error diagnostic was reported. | `AB470x` | Package build `bin` configuration (`AB4700`–`AB4705`; `AB4706`: artifact output overlaps `dist`; `AB4707`: `output` shape plus `output.distPath` string and `output.sourceMap` boolean types; `AB4708`–`AB4709`: `output.distPath` root escape and reserved namespace); see below. | | `AB471x` | Package build `lib` configuration (`AB4710`–`AB4715`) and declaration generation (`AB4716`); see below. | | `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch (`AB4720`–`AB4723`: shape; `AB4724`: a framework-owned Rsbuild plugin re-added through `tools.rsbuild.plugins`; `AB4725`: `tools` externalizes a non-built-in; `AB4726`: a deprecated Rsbuild v2 configuration key; see below). | -| `AB473x` | Migration nudges (informational; see below). | +| `AB473x` | Entry conventions: `AB4730` and `AB4737`–`AB4738` are errors, `AB4731`–`AB4735` are informational shadowing nudges, and `AB4736` is retired (see below). | | `AB4740`–`AB4751` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB4765`–`AB4768` | Artifact-hosted routed CLI and npm lifecycle paths: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest; or a consumer lifecycle names an unsupported or absent Node path (see below). | @@ -617,18 +617,19 @@ development-only fallback can never produce a release artifact, so | `AB4014` | error | A `plugin.metadata` field is not the shape the shared descriptive layer accepts, or the block declares a field beyond `author`, `homepage`, `keywords`, `license`, and `repository`. The config declared it, so it is an error rather than a withheld value, a blank string or empty array included, where `null` is how a field is opted out. | | `AB4015` | warning | A `package.json` descriptive field cannot be shared with any host manifest, a `homepage`, `repository`, or `author.url` the pinned host schemas' `uri` format refuses, an `author.email` their `email` format refuses, or a `repository` in a form this compiler will not convert (`owner/repo` and `github:` shorthands, `git@`/`git://`/`git+ssh`/`git+http` URLs; only `http(s)` and the `git+https://…` URL npm writes, with or without a trailing `.git`, are read). An `author` with any malformed part is withheld whole. The field is withheld rather than guessed at; declare `plugin.metadata.` to share an explicit value. A field the config already overrides is not reported. | -## Migration nudges and convention claims (`AB4730`–`AB4738`) +## Entry conventions and convention claims (`AB4730`–`AB4738`) The entry conventions and the framework-owned stdio lifecycle shell (RFC #50) -replaced patterns consumers previously wrote by hand. When `validate`, -`inspect`, `build`, or `dev` prepares project source and finds one of those -pre-convention patterns, it reports a migration diagnostic. `AB4730`–`AB4735` -are **informational** nudges and never block anything. `AB4736`–`AB4738` are -errors: the removed top-level authored-document locations are no longer -discovered, and a conventional script whose `bin` entry would run an export -the artifact script ignores cannot ship on both surfaces, so the compiler -refuses to omit or misbuild them silently. The CLI prints these in -human `validate` output and includes them in every `--json` diagnostics array. +define how a project's modules reach an artifact. When `validate`, `inspect`, +`build`, or `dev` prepares project source, it reports what the conventions +refuse and what they silently shadow. `AB4730`, `AB4737`, and `AB4738` are +**errors**: an entry the framework cannot wrap, and a conventional script +whose `bin` entry would run an export the artifact script ignores, cannot +ship, so the compiler refuses to misbuild them silently. `AB4731`–`AB4735` +are **informational** nudges for a confusable state where explicit +configuration shadows a conventional file on disk, and never block anything. +`AB4736` is retired. The CLI prints these in human `validate` output and +includes them in every `--json` diagnostics array. Which explicit config keys *claim* a conventional module out of discovery is tabulated in `docs/entry-conventions.md` ("Which config keys claim a @@ -639,17 +640,19 @@ keeps shipping as an artifact script beside the bin because the two outputs are disjoint and both envelopes run the same `main`. That dual-surface shape is intentional and raises no diagnostic. -### `AB4730` self-connecting stdio MCP entry +### `AB4730` stdio MCP entry without a server factory A local MCP server entry module (explicit `entry:` or the conventional -`src/mcp/.ts`) has no default export, so the build bundles it -byte-for-byte instead of wrapping it in the framework stdio lifecycle shell -(console-to-stderr guard, SIGINT/SIGTERM, stdin-EOF exit, bounded shutdown, -heartbeat). The detection is the same static default-export scan the build -uses, so the nudge and the build always agree. +`src/mcp/.ts`) has no default export. Every local entry is wrapped +in the framework stdio lifecycle shell (console-to-stderr guard, +SIGINT/SIGTERM, stdin-EOF exit, bounded shutdown, heartbeat), and the shell +calls the module's default export to build the server, so a module without +one cannot be built. The detection is the same static default-export scan the +build uses, so the diagnostic and the build always agree. -Adopt: default-export a server factory from the entry module. Silence: keep -the self-connecting entry, its behavior is preserved exactly. +Recover: default-export the server factory from the entry module, or declare +a prebuilt server with `command` or `url`, which the framework launches +as-is and never wraps. ### `AB4731` `src/cli.ts` shadowed by explicit `bin` config @@ -696,18 +699,15 @@ document beats a generated one, so the component module never compiles. Adopt: remove `SKILL.md` so the rendered skill compiles at build. Silence: remove the component module. -### `AB4736` legacy top-level authored document location +### `AB4736` retired -A document still matches a removed top-level convention: -`skills//SKILL.md` (or rendered `SKILL.tsx`/`SKILL.ts`), -`commands/*.md`, or `rules/*.mdc`. These locations are no longer discovered, -and every unignored legacy document is reported as an error. A top-level -skill covered by explicit `skills` configuration is claimed and stays valid; -commands and rules have no equivalent override. - -Recover: move the document under `src/skills/`, `src/commands/`, or -`src/rules/`. Explicit `skills` paths remain valid anywhere. Published -artifact paths remain `skills/`, `commands/`, and `rules/`. +The report of documents left in the removed top-level locations +(`skills//SKILL.md`, `commands/*.md`, `rules/*.mdc`). Discovery reads +`src/skills//SKILL.md`, `src/commands/*.md`, and `src/rules/*.mdc` +only, so a document at the top level is now ignored without a diagnostic. An +explicit `skills` path still names a skill directory anywhere in the project, +including the top level. Published artifact paths remain `skills/`, +`commands/`, and `rules/`. The code is never reused. ### `AB4737` rendered script claimed as a package bin entry lacks `main` or the component diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 47363bec9..d2f88371a 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -709,15 +709,16 @@ forwards the real server's `none`, and a plain script's `main` receives the real child process's probe (two pipes). A test that wants other values injects `context.terminal` through the same seam as every identity axis. -### Migration nudges +### Convention diagnostics -Source validation reports **informational** nudges (never errors, migrations -stay optional) when a project exhibits a pre-convention pattern: `AB4730` for -a self-connecting stdio entry that a default-exported factory would upgrade -to the framework lifecycle shell, and `AB4731`/`AB4732`/`AB4733` when -`src/cli.ts`, `src/index.ts`, or `src/mcp/.ts` exists but explicit -configuration shadows it. `bin: false` / `lib: false` opt-outs stay silent. -See `docs/diagnostics.md` for each trigger and how to adopt or silence it. +Source validation reports `AB4730` as an **error** when a local stdio MCP +entry has no default export: the framework lifecycle shell calls that export +to build the server, so such a module cannot be built. It reports +**informational** nudges (never errors) when `src/cli.ts`, `src/index.ts`, or +`src/mcp/.ts` exists but explicit configuration shadows it +(`AB4731`/`AB4732`/`AB4733`). `bin: false` / `lib: false` opt-outs stay +silent. See `docs/diagnostics.md` for each trigger and how to recover from or +silence it. ## Generated entry shells @@ -1051,8 +1052,8 @@ server still passes `kind: 'tool'`. ### The stdio MCP lifecycle shell -An MCP server entry that **default-exports a server factory** is served under -the framework lifecycle: +Every local MCP server entry **default-exports a server factory** and is +served under the framework lifecycle: ```ts // src/mcp/curator.ts — the whole stdio entry a consumer writes @@ -1071,15 +1072,15 @@ race against wedged transports, and heartbeat/activity logging on stderr (5-minute interval, 60-second activity throttle, labeled with the server name). -Self-connecting entries, modules that construct and connect a transport at -top level without a default export, keep today's behavior byte for byte: no -lifecycle shell and no operator `.env` layer (#469); an entry that wants the -layer calls `applyOperatorEnv` from `agent-bundle/launch-env` itself, -passing its own declared `env` block as `manifestEnv` if a passed-through -manifest default should yield to the file as it does in the generated shell. -That module is aliased into every stdio entry, shell or not, so the import is -inlined from this package rather than resolved through the plugin's own -`node_modules`, and a `tools` hatch can never externalize it. +A module that constructs and connects a transport at top level without a +default export cannot be built: source validation reports `AB4730` as an +error. A server the framework should launch as-is instead of compiling is +declared with `command` or `url`, or as a `{ prebuilt: ... }` entry the +consumer's own build produced. The operator `.env` layer (#469) comes from +`agent-bundle/launch-env`, which the shell's prelude applies; that module is +aliased into every stdio entry, so the import is inlined from this package +rather than resolved through the plugin's own `node_modules`, and a `tools` +hatch can never externalize it. Every served tool call is one ordinary `tools/call`: optional `notifications/progress` while the caller's progress token is live, then one @@ -1456,8 +1457,8 @@ canonical precedence order (highest wins): | 1 (lowest) | Manifest env | Entries declared in the server config plus the injected plugin-root anchor, path tokens expanded. | Installed packs get the same layer and the same order without `mcp run` -(#469): every artifact shell that runs plugin code, the stdio MCP entry of a -factory-exporting server (a self-connecting entry has no shell), the hook +(#469): every artifact shell that runs plugin code, the stdio MCP entry of +every local server, the hook wrappers that execute handlers or render standalone, and the artifact CLI `bin/.mjs`, applies `agent-bundle/launch-env` (`src/launch-env.ts`, plain Node, inlined into the bundle) at startup. It reads `/SKILL.md` ships with no declaration. Config wins, conventions fill: declaring `skills:` replaces the directory convention entirely, and validation reports `AB4734` for any conventional skill directory -the explicit list leaves uncovered. Skills at the removed top-level -`skills//` location are an `AB4736` error unless explicit `skills` -config claims them. +the explicit list leaves uncovered. Discovery reads `src/skills/` only, so a +directory at the removed top-level `skills//` location is ignored +unless an explicit `skills` path names it. A skill whose document is generated (power tier, never required) puts `SKILL.tsx` (or `SKILL.ts`) in the skill directory instead of `SKILL.md`. The diff --git a/fixtures/integration/comprehensive/src/mcp-server.ts b/fixtures/integration/comprehensive/src/mcp-server.ts index fd72c8c23..324f6d09b 100644 --- a/fixtures/integration/comprehensive/src/mcp-server.ts +++ b/fixtures/integration/comprehensive/src/mcp-server.ts @@ -1,5 +1,4 @@ import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import apps from 'agent-bundle/mcp-apps'; @@ -8,31 +7,33 @@ import { localMcpMessage } from './mcp-local.ts'; const app = apps[0]; if (app === undefined) throw new Error('Expected an integration MCP App.'); -const server = new McpServer({ name: 'integration-mcp', version: '1.0.0' }); +export default function createIntegrationServer(): McpServer { + const server = new McpServer({ name: 'integration-mcp', version: '1.0.0' }); -server.registerResource(app.name, app.resourceUri, { - _meta: { - ui: { - ...(typeof app._meta?.ui === 'object' && app._meta.ui !== null ? app._meta.ui : {}), - resourceUri: app.resourceUri, + server.registerResource(app.name, app.resourceUri, { + _meta: { + ui: { + ...(typeof app._meta?.ui === 'object' && app._meta.ui !== null ? app._meta.ui : {}), + resourceUri: app.resourceUri, + }, }, - }, - mimeType: app.mimeType, -}, async (uri) => ({ - contents: [{ mimeType: app.mimeType, - text: app.html, - uri: uri.href, - }], -})); + }, async (uri) => ({ + contents: [{ + mimeType: app.mimeType, + text: app.html, + uri: uri.href, + }], + })); -server.registerTool('show-dashboard', { - _meta: { ui: { resourceUri: app.resourceUri } }, - description: 'Returns the integration MCP App.', -}, async () => ({ - _meta: { ui: { resourceUri: app.resourceUri } }, - content: [{ text: `dashboard ready: ${localMcpMessage}`, type: 'text' }], - structuredContent: { resourceUri: app.resourceUri, view: app.name }, -})); + server.registerTool('show-dashboard', { + _meta: { ui: { resourceUri: app.resourceUri } }, + description: 'Returns the integration MCP App.', + }, async () => ({ + _meta: { ui: { resourceUri: app.resourceUri } }, + content: [{ text: `dashboard ready: ${localMcpMessage}`, type: 'text' }], + structuredContent: { resourceUri: app.resourceUri, view: app.name }, + })); -await server.connect(new StdioServerTransport()); + return server; +} diff --git a/fixtures/integration/packed-release/src/mcp-server.ts b/fixtures/integration/packed-release/src/mcp-server.ts index 3ea8f1968..da58f41ae 100644 --- a/fixtures/integration/packed-release/src/mcp-server.ts +++ b/fixtures/integration/packed-release/src/mcp-server.ts @@ -1,27 +1,28 @@ import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import apps from 'agent-bundle/mcp-apps'; const app = apps[0]; if (app === undefined) throw new Error('Expected the packed-release MCP App.'); -const server = new McpServer({ name: 'packed-release-mcp', version: '1.0.0' }); +export default function createPackedReleaseServer(): McpServer { + const server = new McpServer({ name: 'packed-release-mcp', version: '1.0.0' }); -server.registerResource(app.name, app.resourceUri, { - _meta: { ui: { resourceUri: app.resourceUri } }, - mimeType: app.mimeType, -}, async (uri) => ({ - contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }], -})); + server.registerResource(app.name, app.resourceUri, { + _meta: { ui: { resourceUri: app.resourceUri } }, + mimeType: app.mimeType, + }, async (uri) => ({ + contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }], + })); -server.registerTool('show-dashboard', { - _meta: { ui: { resourceUri: app.resourceUri } }, - description: 'Returns the packed-release MCP App.', -}, async () => ({ - _meta: { ui: { resourceUri: app.resourceUri } }, - content: [{ text: 'packed dashboard ready', type: 'text' }], - structuredContent: { resourceUri: app.resourceUri, view: app.name }, -})); + server.registerTool('show-dashboard', { + _meta: { ui: { resourceUri: app.resourceUri } }, + description: 'Returns the packed-release MCP App.', + }, async () => ({ + _meta: { ui: { resourceUri: app.resourceUri } }, + content: [{ text: 'packed dashboard ready', type: 'text' }], + structuredContent: { resourceUri: app.resourceUri, view: app.name }, + })); -await server.connect(new StdioServerTransport()); + return server; +} diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 758ce780f..851df53d8 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -459,27 +459,23 @@ export const planMcpEntriesSurface = async ( ...(options.state === undefined ? {} : { state: options.state }), }); }); - // Factory-exporting entries (default export) are wrapped in the framework - // stdio lifecycle shell; self-connecting entries keep today's behavior byte - // for byte. The shell is aliased onto the local runtime module so emitted - // bundles stay self-contained (no residual `agent-bundle` import). - const entryShells = await Promise.all(compiled.map(async (entry, index) => { + // Every local entry default-exports a server factory (AB4730) and is wrapped + // in the framework stdio lifecycle shell. The shell is aliased onto the local + // runtime module so emitted bundles stay self-contained (no residual + // `agent-bundle` import). + const entryShells = compiled.map((entry, index) => { const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name; - if (generatedRouteSources[index] !== undefined) { - return generatedStdioMcpEntrySource({ entrySource: routeModuleSpecifier, serverName }); - } - return (await scanEntryExports(entry.source)).hasDefaultExport - ? generatedStdioMcpEntrySource({ entrySource: entry.source, serverName }) - : undefined; - })); - const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; + return generatedStdioMcpEntrySource({ + entrySource: generatedRouteSources[index] === undefined ? entry.source : routeModuleSpecifier, + serverName, + }); + }); + const runtimeShell = mcpEntryRuntimePath(); // The operator `.env` layer (#469) is public API for every stdio entry: the - // shell's prelude applies it ahead of the server module, and a - // self-connecting entry — which has no shell — imports - // `agent-bundle/launch-env` and calls `applyOperatorEnv` itself. The alias - // is unconditional so that import resolves to this package's plain-Node - // module and can never be externalized; the bundler inlines it only where - // an import reaches it, so an entry that never imports it is unchanged. + // shell's prelude applies it ahead of the server module. The alias is + // unconditional so that import resolves to this package's plain-Node module + // and can never be externalized; the bundler inlines it only where an import + // reaches it, so an entry that never imports it is unchanged. const launchEnvRuntime = launchEnvRuntimePath(); const eventIpcRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('ipc'); const eventProjectRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('project'); @@ -489,22 +485,18 @@ export const planMcpEntriesSurface = async ( const mainEntries = compiled.map(({ id, name, source, sourceInputs }, index) => ({ aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntime, - ...(entryShells[index] === undefined || runtimeShell === undefined + [mcpEntryRuntimeSpecifier]: runtimeShell, + ...(!hostsRuntime(id) || eventIpcRuntime === undefined || eventProjectRuntime === undefined ? {} : { - [mcpEntryRuntimeSpecifier]: runtimeShell, - ...(!hostsRuntime(id) || eventIpcRuntime === undefined || eventProjectRuntime === undefined - ? {} - : { - [eventIpcRuntimeSpecifier]: eventIpcRuntime, - [eventProjectRuntimeSpecifier]: eventProjectRuntime, - }), - ...(generatedRouteSources[index] === undefined || serverRuntime === undefined - ? {} - : { [mcpServerRuntimeSpecifier]: serverRuntime }), + [eventIpcRuntimeSpecifier]: eventIpcRuntime, + [eventProjectRuntimeSpecifier]: eventProjectRuntime, }), + ...(generatedRouteSources[index] === undefined || serverRuntime === undefined + ? {} + : { [mcpServerRuntimeSpecifier]: serverRuntime }), }, - ...(entryShells[index] === undefined ? {} : { virtualSource: entryShells[index] }), + virtualSource: entryShells[index], name, outputRelativePath: `mcp/${name}.mjs`, ...(generatedRouteSources[index] === undefined ? {} : { rscManifest: true as const }), @@ -523,11 +515,8 @@ export const planMcpEntriesSurface = async ( }]), // The shell's prelude — stdout guard, then the operator `.env` layer // (#469) — carries the server's manifest `env` block, so the layer can - // tell a passed-through default from a host export; a self-connecting - // entry has no shell and applies the layer itself if it wants it. - ...(entryShells[index] === undefined - ? [] - : [stdioPreludeVirtualModule(servers.find((candidate) => candidate.id === id)?.env)]), + // tell a passed-through default from a host export. + stdioPreludeVirtualModule(servers.find((candidate) => candidate.id === id)?.env), ], })); const workerEntries = compiled.flatMap((entry, index) => { diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index ef420f92f..c2085dbef 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -203,7 +203,6 @@ const mcpEntryEntries = async ( const server = model.mcpServers.find((candidate) => candidate.id === entry.id); const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name; const generatedRoutes = server?.generatedRoutes; - const wrapped = generatedRoutes !== undefined || (await scanEntryExports(entry.source)).hasDefaultExport; const workerFile = `${entry.name}-flight.mjs`; const routeSource = generatedRoutes === undefined || server === undefined ? undefined @@ -221,24 +220,16 @@ const mcpEntryEntries = async ( entries.push({ entry: { aliases: { - // Every stdio entry can import the operator `.env` layer (#469); the - // lifecycle shell of a wrapped entry applies it itself. + // The lifecycle shell of every stdio entry applies the operator + // `.env` layer (#469) itself. [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), - ...(wrapped - ? { - [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath(), - ...(routeSource === undefined ? {} : { [mcpServerRuntimeSpecifier]: mcpServerRuntimePath() }), - } - : {}), + [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath(), + ...(routeSource === undefined ? {} : { [mcpServerRuntimeSpecifier]: mcpServerRuntimePath() }), }, - ...(wrapped - ? { - virtualSource: generatedStdioMcpEntrySource({ - entrySource: routeSource === undefined ? entry.source : 'agent-bundle/generated-route-server', - serverName, - }), - } - : {}), + virtualSource: generatedStdioMcpEntrySource({ + entrySource: routeSource === undefined ? entry.source : 'agent-bundle/generated-route-server', + serverName, + }), name: entry.name, outputRelativePath: `mcp/${entry.name}.mjs`, ...(routeSource === undefined ? {} : { rscManifest: true as const }), @@ -250,7 +241,7 @@ const mcpEntryEntries = async ( source: '/* The MCP App registry virtual module is generated from built app HTML at build time. */', }, ...(routeSource === undefined ? [] : [{ name: 'agent-bundle/generated-route-server', source: routeSource }]), - ...(wrapped ? [stdioPreludeVirtualModule(server?.env)] : []), + stdioPreludeVirtualModule(server?.env), ], }, kind: 'mcp-entry', diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 7aab6c686..84c27438c 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -49,14 +49,6 @@ export interface DiscoveredProject { assets?: DiscoveredAsset[]; /** Conventional flat `src/commands/*.md` documents; absent when none are discovered. */ commands?: readonly CommandDocument[]; - /** - * Documents using removed top-level conventions. Validation reports AB4736 - * for every unclaimed document; absent when none are discovered. - */ - legacyConventionalDocuments?: readonly { - readonly kind: 'skill' | 'command' | 'rule'; - readonly source: string; - }[]; payloads?: DiscoveredPayload[]; /** Conventional flat `src/rules/*.mdc` documents; absent when none are discovered. */ rules?: readonly RuleDocument[]; @@ -290,39 +282,6 @@ export const discoverProject = async ( } const shadowedConventionalSkills = [...shadowedByDir.values()]; - const legacySkillSources = (await fastGlob('skills/*/SKILL.{md,ts,tsx}', { - absolute: true, - cwd: projectRoot, - dot: true, - followSymbolicLinks: false, - onlyFiles: true, - })) - .filter((source) => - !isProjectPathIgnored(rules, projectRoot, source) && - !coveredDirs.has(dirname(source)) - ); - const legacyCommandSources = (await fastGlob('commands/*.md', { - absolute: true, - cwd: projectRoot, - dot: true, - followSymbolicLinks: false, - onlyFiles: true, - })).filter((source) => !isProjectPathIgnored(rules, projectRoot, source)); - const legacyRuleSources = (await fastGlob('rules/*.mdc', { - absolute: true, - cwd: projectRoot, - dot: true, - followSymbolicLinks: false, - onlyFiles: true, - })).filter((source) => !isProjectPathIgnored(rules, projectRoot, source)); - const legacyConventionalDocuments = [ - ...legacySkillSources.map((source) => ({ kind: 'skill' as const, source })), - ...legacyCommandSources.map((source) => ({ kind: 'command' as const, source })), - ...legacyRuleSources.map((source) => ({ kind: 'rule' as const, source })), - ].sort((left, right) => - left.source.localeCompare(right.source) || left.kind.localeCompare(right.kind) - ); - const payloads = await discoverPayloads(projectRoot, config.payload); const routeGraph = await compileRouteGraph(projectRoot, config, rules); const commandSources = (await fastGlob('src/commands/*.md', { @@ -349,7 +308,6 @@ export const discoverProject = async ( return { assets: await discoverAssets(projectRoot, config.assets, rules), ...(discoveredCommands.length === 0 ? {} : { commands: discoveredCommands }), - ...(legacyConventionalDocuments.length === 0 ? {} : { legacyConventionalDocuments }), ...(payloads.length === 0 ? {} : { payloads }), ...(routeGraph === undefined || isEmptyRouteGraph(routeGraph) ? {} : { routeGraph }), ...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index c1efec68f..c8aca060f 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -75,9 +75,9 @@ const sourceDiagnostic = ( }); /** - * Informational migration nudges (AB4730-AB4735): they surface pre-convention - * patterns the entry conventions now replace, and they must never gate a - * build — migrations stay optional, so the severity is always `info`. + * Informational convention nudges (AB4731-AB4735): they surface a confusable + * state where explicit configuration shadows a conventional file, and they + * must never gate a build, so the severity is always `info`. */ const nudgeDiagnostic = ( code: string, @@ -615,12 +615,12 @@ const validateMcpApps = ( const relativePosix = toPosixRelative; /** - * AB4730: a local stdio entry whose module never default-exports a factory - * is self-connecting, so the build cannot wrap it in the framework stdio - * lifecycle shell. The detection is the same static export scan the build - * uses to decide the wrap, so the nudge and the build always agree. + * AB4730: every local stdio entry is wrapped in the framework stdio lifecycle + * shell, which imports the entry's default export as its server factory. The + * detection is the same static export scan the build uses to build the wrap, + * so the diagnostic and the build always agree. */ -const selfConnectingEntryNudge = ( +const missingServerFactoryErrors = ( name: string, entry: string | undefined, conventionalEntry: string | undefined, @@ -638,11 +638,11 @@ const selfConnectingEntryNudge = ( // An unreadable entry is already reported by the existence diagnostics. return []; } - return [nudgeDiagnostic( + return [sourceDiagnostic( 'AB4730', - `MCP server ${JSON.stringify(name)} stdio entry is self-connecting; a default-exported server factory would receive the framework stdio lifecycle shell.`, + `MCP server ${JSON.stringify(name)} stdio entry must default-export a server factory.`, source, - 'Optional: default-export a server factory from the entry module to adopt the framework lifecycle; self-connecting entries keep their current behavior.', + 'Default-export the server factory from the entry module, or declare a prebuilt server with command or url.', )]; }; @@ -729,7 +729,7 @@ const validateMcpServer = ( diagnostics.push(...validateStringList(server.args, 'args', 'AB4311', loaded)); diagnostics.push(...validateStringRecord(server.env, 'env', 'AB4312', loaded)); if (!isPrebuiltEntryInput(entry)) { - diagnostics.push(...selfConnectingEntryNudge(name, entry, conventionalEntry, loaded)); + diagnostics.push(...missingServerFactoryErrors(name, entry, conventionalEntry, loaded)); } return diagnostics; } @@ -1962,22 +1962,6 @@ const skillConventionShadowNudges = ( 'Optional: remove the explicit skills configuration to adopt the src/skills//SKILL.md convention, add the directory to skills, or remove it to silence this nudge.', )); -const legacyConventionalDocumentErrors = ( - loaded: LoadedConfig, - discovered: DiscoveredProject, -): Diagnostic[] => (discovered.legacyConventionalDocuments ?? []).map(({ kind, source }) => { - const relativePath = relativePosix(loaded.context.projectRoot, source); - const destination = `src/${relativePath}`; - return sourceDiagnostic( - 'AB4736', - `${relativePath} uses the removed top-level ${kind} convention and is no longer discovered.`, - source, - kind === 'skill' - ? `Move the document to ${destination}, or cover its directory with explicit skills configuration.` - : `Move the document to ${destination}.`, - ); -}); - const isRspackHatchValue = (value: unknown): boolean => typeof value === 'function' || isRecord(value); @@ -2489,7 +2473,6 @@ export const validateSource = ( ).diagnostics); diagnostics.push(...packageConventionShadowNudges(loaded)); diagnostics.push(...skillConventionShadowNudges(loaded, discovered)); - diagnostics.push(...legacyConventionalDocumentErrors(loaded, discovered)); // Route overrides are validated during discovery; source validation must // still observe the config getter so hostile accessors fail closed as AB7001. void loaded.config['routes']; diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index cca82734b..046b8e679 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -1499,20 +1499,17 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati await Promise.all([ writeFile(join(root, 'src', 'hook.ts'), "export default () => ({ additionalContext: 'synthetic hook' });\n"), writeFile(join(root, 'src', 'mcp-server.ts'), [ - "let buffer = '';", - 'const send = (id, result) => process.stdout.write(`${JSON.stringify({ jsonrpc: \'2.0\', id, result })}\\n`);', - "process.stdin.setEncoding('utf8');", - "process.stdin.on('data', (chunk) => {", - ' buffer += chunk;', - " for (let newline; (newline = buffer.indexOf('\\n')) >= 0;) {", - ' const line = buffer.slice(0, newline).trim();', - ' buffer = buffer.slice(newline + 1);', - ' if (!line) continue;', - ' const request = JSON.parse(line);', - " if (request.method === 'initialize') send(request.id, { capabilities: { tools: {} }, protocolVersion: request.params.protocolVersion, serverInfo: { name: 'synthetic', version: '1.0.0' } });", - " if (request.method === 'tools/list') send(request.id, { tools: [{ description: 'Synthetic tool', inputSchema: { properties: {}, type: 'object' }, name: 'synthetic-tool' }] });", - " if (request.method === 'tools/call') send(request.id, { content: [{ text: 'synthetic result', type: 'text' }], structuredContent: { synthetic: true } });", - ' }', + 'export default () => ({', + ' close() {},', + ' async connect(transport) {', + ' const send = (id, result) => void transport.send({ id, jsonrpc: \'2.0\', result });', + ' transport.onmessage = (request) => {', + " if (request.method === 'initialize') send(request.id, { capabilities: { tools: {} }, protocolVersion: request.params.protocolVersion, serverInfo: { name: 'synthetic', version: '1.0.0' } });", + " if (request.method === 'tools/list') send(request.id, { tools: [{ description: 'Synthetic tool', inputSchema: { properties: {}, type: 'object' }, name: 'synthetic-tool' }] });", + " if (request.method === 'tools/call') send(request.id, { content: [{ text: 'synthetic result', type: 'text' }], structuredContent: { synthetic: true } });", + ' };', + ' await transport.start();', + ' },', '});', '', ].join('\n')), diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index 2a30755ba..736dc0341 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -222,7 +222,7 @@ const createWebOnlyFixture = async (): Promise => { '});', '', ].join('\n')), - writeProjectFile(root, 'src/mcp/status.ts', "process.stderr.write('status server\\n');\n"), + writeProjectFile(root, 'src/mcp/status.ts', "process.stderr.write('status server\\n');\nexport default () => ({});\n"), writeProjectFile(root, 'views/status.html', '
\n'), writeProjectFile(root, 'views/status.ts', "document.querySelector('#view')!.textContent = 'web-only status';\n"), ]); diff --git a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts index 3252a1c30..875f62d74 100644 --- a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts +++ b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts @@ -185,45 +185,40 @@ it('bridges a browser-bound session to a generated stdio artifact without exposi await writeFile( join(root, 'src', 'server.ts'), [ - 'let buffer = "";', 'const pending = new Map();', - 'const send = (message) => process.stdout.write(`${JSON.stringify(message)}\\n`);', - 'const reply = (id, result) => send({ id, jsonrpc: "2.0", result });', - 'const handle = (message) => {', - ' if (message.method === "initialize") {', - ' reply(message.id, { capabilities: { tools: {} }, protocolVersion: message.params.protocolVersion, serverInfo: { name: "bridge-fixture", version: "1.0.0" } });', - ' return;', - ' }', - ' if (message.method === "tools/list") {', - ' reply(message.id, { tools: [{ inputSchema: { type: "object" }, name: "progress" }, { inputSchema: { type: "object" }, name: "wait" }] });', - ' return;', - ' }', - ' if (message.method === "tools/call" && message.params.name === "progress") {', - ' process.stderr.write("bridge fixture stderr\\n");', - ' send({ jsonrpc: "2.0", method: "notifications/progress", params: { progress: 1, progressToken: "bridge-progress", total: 1 } });', - ' reply(message.id, { content: [{ text: "progress complete", type: "text" }] });', - ' return;', - ' }', - ' if (message.method === "tools/call" && message.params.name === "wait") {', - ' pending.set(message.id, setTimeout(() => reply(message.id, { content: [{ text: "late", type: "text" }] }), 5_000));', - ' return;', - ' }', - ' if (message.method === "notifications/cancelled") {', - ' const timer = pending.get(message.params.requestId);', - ' if (timer !== undefined) clearTimeout(timer);', - ' pending.delete(message.params.requestId);', - ' send({ error: { code: -32800, message: "Cancelled by bridge fixture" }, id: message.params.requestId, jsonrpc: "2.0" });', - ' }', - '};', - 'process.stdin.on("data", (chunk) => {', - ' buffer += chunk;', - ' for (;;) {', - ' const newline = buffer.indexOf("\\n");', - ' if (newline < 0) return;', - ' const line = buffer.slice(0, newline);', - ' buffer = buffer.slice(newline + 1);', - ' if (line.length > 0) handle(JSON.parse(line));', - ' }', + 'export default () => ({', + ' close() {},', + ' async connect(transport) {', + ' const send = (message) => void transport.send(message);', + ' const reply = (id, result) => send({ id, jsonrpc: "2.0", result });', + ' transport.onmessage = (message) => {', + ' if (message.method === "initialize") {', + ' reply(message.id, { capabilities: { tools: {} }, protocolVersion: message.params.protocolVersion, serverInfo: { name: "bridge-fixture", version: "1.0.0" } });', + ' return;', + ' }', + ' if (message.method === "tools/list") {', + ' reply(message.id, { tools: [{ inputSchema: { type: "object" }, name: "progress" }, { inputSchema: { type: "object" }, name: "wait" }] });', + ' return;', + ' }', + ' if (message.method === "tools/call" && message.params.name === "progress") {', + ' process.stderr.write("bridge fixture stderr\\n");', + ' send({ jsonrpc: "2.0", method: "notifications/progress", params: { progress: 1, progressToken: "bridge-progress", total: 1 } });', + ' reply(message.id, { content: [{ text: "progress complete", type: "text" }] });', + ' return;', + ' }', + ' if (message.method === "tools/call" && message.params.name === "wait") {', + ' pending.set(message.id, setTimeout(() => reply(message.id, { content: [{ text: "late", type: "text" }] }), 5_000));', + ' return;', + ' }', + ' if (message.method === "notifications/cancelled") {', + ' const timer = pending.get(message.params.requestId);', + ' if (timer !== undefined) clearTimeout(timer);', + ' pending.delete(message.params.requestId);', + ' send({ error: { code: -32800, message: "Cancelled by bridge fixture" }, id: message.params.requestId, jsonrpc: "2.0" });', + ' }', + ' };', + ' await transport.start();', + ' },', '});', '', ].join('\n'), diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 7f8498a97..9ec3ebe65 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -83,7 +83,7 @@ const writeProject = async (root: string, options: FixtureOptions = {}): Promise writeProjectFile(root, 'native/claude.json', nativeHookDocument('echo claude-native')), writeProjectFile(root, 'native/codex.json', nativeHookDocument('echo codex-native')), writeProjectFile(root, 'src/hooks/session-start.ts', "export default () => ({ outcome: 'continue' as const, additionalContext: 'started' });\n"), - writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\n"), + writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\nexport default () => ({});\n"), writeProjectFile(root, 'src/tools/hello.ts', "console.log('hello');\n"), writeProjectFile(root, 'src/skills/review/SKILL.md', [ '---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '', diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index eac3f8f97..39b08aef3 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -104,7 +104,7 @@ const createCliProject = async ( '---\nname: review\ndescription: Reviews changes\n---\n# Review\n', ), ...(mcpApp ? [ - writeFile(join(root, 'src', 'server.ts'), 'export {};\n'), + writeFile(join(root, 'src', 'server.ts'), 'export default () => ({});\n'), writeFile( join(root, 'views', 'dashboard.ts'), `document.body.textContent = ${JSON.stringify(`dashboard ${incompressibleText(16 * 1024)}`)};\n`, @@ -139,20 +139,17 @@ const createServiceProject = async (): Promise => { writeFile( join(root, 'src', 'server.ts'), [ - "let buffer = '';", - "process.stdin.setEncoding('utf8');", - "process.stdin.on('data', (chunk) => {", - ' buffer += chunk;', - " for (let newline; (newline = buffer.indexOf('\\n')) >= 0;) {", - ' const line = buffer.slice(0, newline).trim();', - ' buffer = buffer.slice(newline + 1);', - ' if (!line) continue;', - ' const request = JSON.parse(line);', - " const send = (result) => process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\\n`);", - " if (request.method === 'initialize') send({ capabilities: { tools: {} }, protocolVersion: request.params.protocolVersion, serverInfo: { name: 'cli-fixture', version: '1.0.0' } });", - " if (request.method === 'tools/list') send({ tools: [{ description: 'Inspects a fixture', inputSchema: { properties: {}, type: 'object' }, name: 'inspect' }] });", - " if (request.method === 'tools/call') send({ content: [{ text: 'inspected', type: 'text' }], structuredContent: { invoked: true } });", - ' }', + 'export default () => ({', + ' close() {},', + ' async connect(transport) {', + " transport.onmessage = (request) => {", + " const send = (result) => void transport.send({ id: request.id, jsonrpc: '2.0', result });", + " if (request.method === 'initialize') send({ capabilities: { tools: {} }, protocolVersion: request.params.protocolVersion, serverInfo: { name: 'cli-fixture', version: '1.0.0' } });", + " if (request.method === 'tools/list') send({ tools: [{ description: 'Inspects a fixture', inputSchema: { properties: {}, type: 'object' }, name: 'inspect' }] });", + " if (request.method === 'tools/call') send({ content: [{ text: 'inspected', type: 'text' }], structuredContent: { invoked: true } });", + ' };', + ' await transport.start();', + ' },', '});', '', ].join('\n'), diff --git a/packages/agent-bundle/tests/composite-rules.test.ts b/packages/agent-bundle/tests/composite-rules.test.ts index dab20b66c..ec584dc86 100644 --- a/packages/agent-bundle/tests/composite-rules.test.ts +++ b/packages/agent-bundle/tests/composite-rules.test.ts @@ -77,7 +77,7 @@ const writeProject = async (root: string, options: FixtureOptions): Promise ({ outcome: 'continue' as const, additionalContext: 'started' });\n"), - writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\n"), + writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\nexport default () => ({});\n"), writeProjectFile(root, 'src/tools/hello.ts', "console.log('hello');\n"), writeProjectFile(root, 'src/skills/review/SKILL.md', [ '---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '', diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index b830c8d0c..a04824d51 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -83,7 +83,7 @@ const createRuntimeProject = async (options: Readonly<{ 'Review the changed files.', '', ].join('\n')), - writeFile(join(root, 'src', 'server.ts'), 'export const server = true;\n'), + writeFile(join(root, 'src', 'server.ts'), 'export default () => ({});\n'), writeFile(join(root, 'src', 'app.ts'), 'export const app = true;\n'), writeFile(join(root, 'src', 'shell.html'), '
fixture
\n'), writeFile(join(root, 'src', 'dev', 'provider.ts'), [ diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index b9a3c5082..797a1d88b 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -115,17 +115,18 @@ const writeMcpProject = async (root: string): Promise => { writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", '', - "const server = new McpServer({ name: 'workbench-fixture', version: '1.0.0' });", - "server.registerTool('wait', { description: 'Wait for shutdown.' }, async () => new Promise(() => {}));", - "server.registerResource('app', 'ui://fixture/app.html', { mimeType: 'text/html;profile=mcp-app' }, async (uri) => ({", - " contents: [{ mimeType: 'text/html;profile=mcp-app', text: '
Fixture App
', uri: uri.href }],", - '}));', - "server.registerTool('show-app', { _meta: { ui: { resourceUri: 'ui://fixture/app.html' } } }, async () => ({", - " content: [{ type: 'text', text: 'Fixture App ready.' }],", - '}));', - 'await server.connect(new StdioServerTransport());', + 'export default () => {', + " const server = new McpServer({ name: 'workbench-fixture', version: '1.0.0' });", + " server.registerTool('wait', { description: 'Wait for shutdown.' }, async () => new Promise(() => {}));", + " server.registerResource('app', 'ui://fixture/app.html', { mimeType: 'text/html;profile=mcp-app' }, async (uri) => ({", + " contents: [{ mimeType: 'text/html;profile=mcp-app', text: '
Fixture App
', uri: uri.href }],", + ' }));', + " server.registerTool('show-app', { _meta: { ui: { resourceUri: 'ui://fixture/app.html' } } }, async () => ({", + " content: [{ type: 'text', text: 'Fixture App ready.' }],", + ' }));', + ' return server;', + '};', '', ].join('\n')), writeFile(join(root, 'agent-bundle.config.ts'), [ diff --git a/packages/agent-bundle/tests/host-mcp-proxy.test.ts b/packages/agent-bundle/tests/host-mcp-proxy.test.ts index 4a5bf03c0..ca9238162 100644 --- a/packages/agent-bundle/tests/host-mcp-proxy.test.ts +++ b/packages/agent-bundle/tests/host-mcp-proxy.test.ts @@ -25,32 +25,34 @@ const within = async (promise: Promise, milliseconds = 10_000): Pr const serverSource = (root: string, version: 'v1' | 'v2'): string => [ "import { access } from 'node:fs/promises';", "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", '', `const version = ${JSON.stringify(version)};`, - 'let count = 0;', - "const server = new McpServer({ name: 'host-proxy-fixture', version: '1.0.0' });", - "server.registerTool('count', { description: 'Increment session-local state.' }, async () => ({", - " content: [{ type: 'text', text: String(++count) }],", - '}));', - "server.registerTool('version', { description: 'Report the built fixture version.' }, async () => ({", - " content: [{ type: 'text', text: version }],", - '}));', - "server.registerTool('slow-version', { description: 'Complete after the test releases the call.' }, async () => {", - ` const release = ${JSON.stringify(join(root, 'release-slow-call'))};`, - ' while (true) {', - ' try { await access(release); break; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); }', - ' }', - " return { content: [{ type: 'text', text: version }] };", - '});', + '', + 'export default () => {', + ' let count = 0;', + " const server = new McpServer({ name: 'host-proxy-fixture', version: '1.0.0' });", + " server.registerTool('count', { description: 'Increment session-local state.' }, async () => ({", + " content: [{ type: 'text', text: String(++count) }],", + ' }));', + " server.registerTool('version', { description: 'Report the built fixture version.' }, async () => ({", + " content: [{ type: 'text', text: version }],", + ' }));', + " server.registerTool('slow-version', { description: 'Complete after the test releases the call.' }, async () => {", + ` const release = ${JSON.stringify(join(root, 'release-slow-call'))};`, + ' while (true) {', + ' try { await access(release); break; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); }', + ' }', + " return { content: [{ type: 'text', text: version }] };", + ' });', ...(version === 'v2' ? [ - "server.registerTool('new-tool', { description: 'Added by the rebuild.' }, async () => ({", - " content: [{ type: 'text', text: 'new' }],", - '}));', + " server.registerTool('new-tool', { description: 'Added by the rebuild.' }, async () => ({", + " content: [{ type: 'text', text: 'new' }],", + ' }));', ] : []), - 'await server.connect(new StdioServerTransport());', + ' return server;', + '};', '', ].join('\n'); diff --git a/packages/agent-bundle/tests/launch-env.test.ts b/packages/agent-bundle/tests/launch-env.test.ts index a82ebe7a7..c8d02808d 100644 --- a/packages/agent-bundle/tests/launch-env.test.ts +++ b/packages/agent-bundle/tests/launch-env.test.ts @@ -161,7 +161,7 @@ describe('the operator .env layer of an installed pack (#469)', () => { TOKENIZED: '/installs/curator/data', }); - // Without the defaults every present variable is reserved (the self-connecting entry's position). + // Without the defaults every present variable is reserved. const blind: NodeJS.ProcessEnv = { MANIFEST_ONLY: 'manifest-default' }; expect(applyOperatorEnv({ env: blind, pluginRoot: root }).applied).toEqual(['ABSENT_EVERYWHERE', 'HOST_EXPORTED', 'HOST_ONLY']); expect(blind.MANIFEST_ONLY).toBe('manifest-default'); diff --git a/packages/agent-bundle/tests/manifest-relocatable.test.ts b/packages/agent-bundle/tests/manifest-relocatable.test.ts index 186933879..cfe14ccd1 100644 --- a/packages/agent-bundle/tests/manifest-relocatable.test.ts +++ b/packages/agent-bundle/tests/manifest-relocatable.test.ts @@ -163,7 +163,7 @@ beforeAll(async () => { writeProjectFile( projectRoot, 'src/mcp/echo.ts', - "process.stdin.on('data', (chunk) => process.stdout.write(chunk));\n", + 'export default () => ({ close() {}, async connect() {} });\n', ), writeProjectFile(projectRoot, 'payload-config/echo.json', '{ "echo": true }\n'), writeProjectFile(projectRoot, 'src/scripts/greet.ts', "console.log('hello');\n"), diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 771c6e66f..4ed9ecdbc 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -120,28 +120,29 @@ const publishFixtureEpoch = async ( await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); await writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", '', - "const server = new McpServer({ name: 'persistent-fixture', version: '1.0.0' });", - "server.registerTool('inspect', { description: 'Inspect persistent session state.' }, async () => {", - " process.stderr.write('fixture stderr\\n');", - ' return {', - " _meta: { ui: { resourceUri: 'ui://fixture/result.html' }, opaque: { nested: ['exact', 42] } },", - ' content: [', - " { type: 'text', text: JSON.stringify({ cwd: process.cwd(), data: process.env.FIXTURE_DATA, inherited: process.env.AGENT_BUNDLE_PERSISTENT_INHERITED, pid: process.pid, root: process.env.FIXTURE_ROOT, stateRoot: process.env.AGENT_BUNDLE_STATE_ROOT, workspace: process.env.FIXTURE_WORKSPACE }) },", - " { type: 'resource_link', name: 'fixture', uri: 'ui://fixture/resource.txt' },", - ' ],', - " structuredContent: { answer: 42, opaque: { exact: true } },", - ' };', - '});', - "server.registerTool('hang', { description: 'Wait for cancellation.' }, async () => new Promise(() => {}));", - "server.registerResource('fixture', 'ui://fixture/resource.txt', { mimeType: 'text/plain' }, async (uri) => ({", - " contents: [{ mimeType: 'text/plain', text: 'fixture resource', uri: uri.href }],", - '}));', - "server.registerPrompt('fixture', { description: 'Fixture prompt.' }, async () => ({", - " messages: [{ role: 'user', content: { type: 'text', text: 'fixture prompt' } }],", - '}));', - 'await server.connect(new StdioServerTransport());', + 'export default () => {', + " const server = new McpServer({ name: 'persistent-fixture', version: '1.0.0' });", + " server.registerTool('inspect', { description: 'Inspect persistent session state.' }, async () => {", + " process.stderr.write('fixture stderr\\n');", + ' return {', + " _meta: { ui: { resourceUri: 'ui://fixture/result.html' }, opaque: { nested: ['exact', 42] } },", + ' content: [', + " { type: 'text', text: JSON.stringify({ cwd: process.cwd(), data: process.env.FIXTURE_DATA, inherited: process.env.AGENT_BUNDLE_PERSISTENT_INHERITED, pid: process.pid, root: process.env.FIXTURE_ROOT, stateRoot: process.env.AGENT_BUNDLE_STATE_ROOT, workspace: process.env.FIXTURE_WORKSPACE }) },", + " { type: 'resource_link', name: 'fixture', uri: 'ui://fixture/resource.txt' },", + ' ],', + " structuredContent: { answer: 42, opaque: { exact: true } },", + ' };', + ' });', + " server.registerTool('hang', { description: 'Wait for cancellation.' }, async () => new Promise(() => {}));", + " server.registerResource('fixture', 'ui://fixture/resource.txt', { mimeType: 'text/plain' }, async (uri) => ({", + " contents: [{ mimeType: 'text/plain', text: 'fixture resource', uri: uri.href }],", + ' }));', + " server.registerPrompt('fixture', { description: 'Fixture prompt.' }, async () => ({", + " messages: [{ role: 'user', content: { type: 'text', text: 'fixture prompt' } }],", + ' }));', + ' return server;', + '};', '', ].join('\n')); @@ -397,12 +398,13 @@ it('lowers every session trace entry onto the unified trace with request/respons method: 'tools/call', }); - expect(ofKind('mcp.stderr')).toMatchObject([{ + // The lifecycle shell's activity heartbeat is the first stderr line. + expect(ofKind('mcp.stderr').at(-1)).toMatchObject({ correlation: { epochId: 'epoch-1', host: 'portable', mcpSessionId: session.id }, details: { bytes: Buffer.byteLength('fixture stderr\n') }, href: href('/advanced/protocol'), summary: 'stderr: fixture stderr', - }]); + }); expect(JSON.stringify(published)).not.toContain(root); await session.close(); diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index dcb9785d9..697abc301 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -461,7 +461,7 @@ it('rejects non-JSON MCP App metadata before normalization', async () => { try { await mkdir(join(root, 'src'), { recursive: true }); await mkdir(join(root, 'views'), { recursive: true }); - await writeFile(join(root, 'src', 'server.ts'), 'export {};\n'); + await writeFile(join(root, 'src', 'server.ts'), 'export default () => ({});\n'); await writeFile(join(root, 'views', 'dashboard.ts'), 'document.body.textContent = "dashboard";\n'); for (const [name, value] of [ @@ -492,11 +492,7 @@ it('rejects non-JSON MCP App metadata before normalization', async () => { expect(diagnostics.filter(({ severity }) => severity === 'error').map(({ code }) => code)).toEqual([ 'AB4338', ]); - // The self-connecting fixture entry additionally draws the AB4730 - // migration nudge, which must stay informational. - expect(diagnostics.filter(({ severity }) => severity !== 'error')).toEqual([ - expect.objectContaining({ code: 'AB4730', severity: 'info' }), - ]); + expect(diagnostics.filter(({ severity }) => severity !== 'error')).toEqual([]); } } finally { await removeTree(root); @@ -514,6 +510,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art [ 'import { message } from "./message.ts";', 'process.stderr.write(`${message}\\n`);', + 'export default () => ({});', '', ].join('\n'), ); @@ -566,7 +563,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art const bundle = await readFile(join(mcpRoot, outputName), 'utf8'); expect(bundle).toContain('bundled'); expect(bundle).not.toContain('./message.ts'); - expect(bundle).not.toContain('agent-bundle'); + expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); // Every selected host's document points at that one bundle in its own // dialect: the portable and Claude documents sit at their conventional @@ -664,65 +661,6 @@ it('bundles each local MCP entry once and maps every target manifest to that art } }, 30_000); -it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply the operator .env layer itself (#469)', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-self-connecting-env-')); - try { - await mkdir(join(root, 'src'), { recursive: true }); - await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); - // No default export: the entry gets no lifecycle shell, so it applies the - // layer first thing, anchored exactly as a shell would (the documented recipe). - await writeFile(join(root, 'src', 'probe.ts'), [ - "import { fileURLToPath } from 'node:url';", - "import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env';", - '', - "const layer = applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });", - "process.stdout.write(`${JSON.stringify({ applied: layer.applied, file: process.env.PROBE_FILE ?? null, host: process.env.PROBE_HOST ?? null })}\\n`);", - '', - ].join('\n')); - const model = await normalizeProject( - loadedProject(root, { - mcp: { servers: { probe: { entry: './src/probe.ts' } } }, - plugin: { name: 'mcp-self-connecting-env' }, - targets: ['portable'], - }), - { skills: [] }, - registry, - ); - const outputRoot = join(root, 'artifact'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); - expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); - const [entry] = result.compiledMcpEntries; - // The inlined loader is framework runtime, never authored-source evidence. - expect(entry).toMatchObject({ - id: 'mcp:probe', - sourceInputs: [join(root, 'agent-bundle.config.ts'), join(root, 'src', 'probe.ts')], - target: 'portable', - }); - - const bundle = await readFile(entry!.output, 'utf8'); - // Self-contained (the alias resolved to the framework's own module) and - // still shell-free: the entry's own top-level code is what runs first. - expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); - expect(bundle).not.toContain('runGeneratedStdioMcpEntry'); - expect(bundle).toContain('AGENT_BUNDLE_ENV_FILE'); - - // `/.env` is one directory above `mcp/`; it fills the gap and - // an exported variable still wins. - const pluginRoot = outputRoot; - const probe = async (env: Readonly>): Promise => { - const run = await runNodeScript({ args: [entry!.output], env }); - expect(run).toMatchObject({ code: 0, stderr: '' }); - return JSON.parse(run.stdout); - }; - expect(await probe({ PROBE_HOST: 'from-host' })).toEqual({ applied: [], file: null, host: 'from-host' }); - await writeFile(join(pluginRoot, '.env'), 'PROBE_FILE=from-file\nPROBE_HOST=from-file\n'); - expect(await probe({ PROBE_HOST: 'from-host' })).toEqual({ applied: ['PROBE_FILE'], file: 'from-file', host: 'from-host' }); - expect(await probe({ AGENT_BUNDLE_ENV_FILE: 'none', PROBE_HOST: 'from-host' })).toEqual({ applied: [], file: null, host: 'from-host' }); - } finally { - await removeTree(root); - } -}, 30_000); - it('lets the operator .env beat a manifest env default the host passed through, never a host export, before the server module evaluates (#469)', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-manifest-env-')); try { @@ -1039,6 +977,7 @@ it('injects one release identity into both the Node bundle and the browser MCP A "import meta from 'agent-bundle/meta';", "import { name, packageName, version } from 'agent-bundle/meta';", 'export const serverIdentity = [name, version, packageName, meta.packageVersion];', + 'export default () => ({});', '', ].join('\n')); await writeFile(join(root, 'views', 'dashboard.ts'), [ @@ -1111,6 +1050,7 @@ it('compiles one shared MCP App once and serves it from every identically declar const serverSource = [ "import apps from 'agent-bundle/mcp-apps';", 'export const bundledApps = apps;', + 'export default () => ({});', '', ].join('\n'); await writeFile(join(root, 'src', 'library.ts'), serverSource); @@ -1132,12 +1072,7 @@ it('compiles one shared MCP App once and serves it from every identically declar plugin: { name: 'mcp-app-shared' }, targets: ['portable'], }; - // Both fixture entries are deliberately self-connecting registry probes, - // so validation reports exactly the two informational AB4730 nudges. - expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([ - expect.objectContaining({ code: 'AB4730', severity: 'info' }), - expect.objectContaining({ code: 'AB4730', severity: 'info' }), - ]); + expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([]); const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry); const outputRoot = join(root, 'dist'); @@ -1529,29 +1464,30 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", "import apps from 'agent-bundle/mcp-apps';", '', - "const server = new McpServer({ name: 'app-resource-fixture', version: '1.0.0' });", - 'for (const app of apps) {', - ' server.registerResource(app.name, app.resourceUri, {', - ' mimeType: app.mimeType,', - ' _meta: app._meta,', - ' }, async (uri) => ({', - ' contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }],', + 'export default () => {', + " const server = new McpServer({ name: 'app-resource-fixture', version: '1.0.0' });", + ' for (const app of apps) {', + ' server.registerResource(app.name, app.resourceUri, {', + ' mimeType: app.mimeType,', + ' _meta: app._meta,', + ' }, async (uri) => ({', + ' contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }],', + ' }));', + ' }', + ' const [app] = apps;', + " if (app === undefined) throw new Error('Expected bundled MCP App.');", + " server.registerTool('show-dashboard', {", + " description: 'Open the bundled dashboard.',", + ' _meta: { ui: { resourceUri: app.resourceUri } },', + ' }, async () => ({', + ' _meta: { ui: { resourceUri: app.resourceUri } },', + " content: [{ type: 'text', text: 'dashboard ready' }],", + ' structuredContent: { resourceUri: app.resourceUri, view: app.name },', ' }));', - '}', - 'const [app] = apps;', - "if (app === undefined) throw new Error('Expected bundled MCP App.');", - "server.registerTool('show-dashboard', {", - " description: 'Open the bundled dashboard.',", - ' _meta: { ui: { resourceUri: app.resourceUri } },', - '}, async () => ({', - ' _meta: { ui: { resourceUri: app.resourceUri } },', - " content: [{ type: 'text', text: 'dashboard ready' }],", - ' structuredContent: { resourceUri: app.resourceUri, view: app.name },', - '}));', - 'await server.connect(new StdioServerTransport());', + ' return server;', + '};', '', ].join('\n'), ); @@ -1652,40 +1588,43 @@ it('lists tools from a validated copied artifact without reading project source' join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", '', - "const server = new McpServer({ name: 'fixture-server', version: '1.0.0' });", - "server.registerTool('inspect', { description: 'Inspect the launched artifact.' }, async () => {", - " process.stderr.write('fixture stderr\\n');", - ' return {', - " content: [{ type: 'text' as const, text: JSON.stringify({", - ' args: process.argv.slice(2),', - ' data: process.env.FIXTURE_DATA,', - ' root: process.env.FIXTURE_ROOT,', - ' }) }],', - ' };', - '});', - "server.registerTool('tool-error', { description: 'Return a tool-level error.' }, async () => ({", - " content: [{ type: 'text' as const, text: 'expected failure' }],", - ' isError: true,', - '}));', - "server.registerTool('noisy', { description: 'Exceed the stderr limit.' }, async () => {", - " process.stderr.write('x'.repeat(1_000_001));", - " return { content: [{ type: 'text' as const, text: 'too noisy' }] };", - '});', - "server.registerTool('hang', { description: 'Wait for cancellation.' }, async () => new Promise(() => {}));", - "server.registerTool('rich', {", - " _meta: { 'openai/outputTemplate': 'ui://fixture/tool.html', ui: { resourceUri: 'ui://fixture/tool.html' } },", - " description: 'Return an Apps-compatible result.',", - "}, async () => ({", - ' _meta: { ui: { resourceUri: \'ui://fixture/result.html\' } },', - ' content: [', - " { type: 'resource_link' as const, name: 'fixture', uri: 'ui://fixture/tool.html' },", - " { type: 'resource' as const, resource: { mimeType: 'text/plain', text: 'embedded fixture', uri: 'ui://fixture/embedded.txt' } },", - ' ],', - " structuredContent: { view: 'fixture', value: 42 },", - '}));', - 'await server.connect(new StdioServerTransport());', + 'export default () => {', + " const server = new McpServer({ name: 'fixture-server', version: '1.0.0' });", + " server.registerTool('inspect', { description: 'Inspect the launched artifact.' }, async () => {", + " process.stderr.write('fixture stderr\\n');", + ' return {', + " content: [{ type: 'text' as const, text: JSON.stringify({", + ' args: process.argv.slice(2),', + ' data: process.env.FIXTURE_DATA,', + ' root: process.env.FIXTURE_ROOT,', + ' }) }],', + ' };', + ' });', + " server.registerTool('tool-error', { description: 'Return a tool-level error.' }, async () => ({", + " content: [{ type: 'text' as const, text: 'expected failure' }],", + ' isError: true,', + ' }));', + " server.registerTool('noisy', { description: 'Exceed the stderr limit.' }, async () => {", + // Flushed before the result so the parent reads it all, whatever the + // lifecycle shell does at transport close. + " await new Promise((resolve) => { process.stderr.write('x'.repeat(1_000_001), () => { resolve(undefined); }); });", + " return { content: [{ type: 'text' as const, text: 'too noisy' }] };", + ' });', + " server.registerTool('hang', { description: 'Wait for cancellation.' }, async () => new Promise(() => {}));", + " server.registerTool('rich', {", + " _meta: { 'openai/outputTemplate': 'ui://fixture/tool.html', ui: { resourceUri: 'ui://fixture/tool.html' } },", + " description: 'Return an Apps-compatible result.',", + " }, async () => ({", + ' _meta: { ui: { resourceUri: \'ui://fixture/result.html\' } },', + ' content: [', + " { type: 'resource_link' as const, name: 'fixture', uri: 'ui://fixture/tool.html' },", + " { type: 'resource' as const, resource: { mimeType: 'text/plain', text: 'embedded fixture', uri: 'ui://fixture/embedded.txt' } },", + ' ],', + " structuredContent: { view: 'fixture', value: 42 },", + ' }));', + ' return server;', + '};', '', ].join('\n'), ); @@ -1733,7 +1672,8 @@ it('lists tools from a validated copied artifact without reading project source' const result = await new api.McpService!().list({ artifact, server: 'fixture', target: 'portable' }); expect(result).toMatchObject({ server: { name: 'fixture-server', version: '1.0.0' }, - stderr: '', + // The lifecycle shell logs one activity heartbeat on the first request. + stderr: expect.stringContaining('[fixture] stdio heartbeat (activity)'), tools: [ { name: 'inspect' }, { name: 'tool-error' }, @@ -1757,7 +1697,7 @@ it('lists tools from a validated copied artifact without reading project source' }); expect(invoked).toMatchObject({ result: { content: [{ text: expect.any(String), type: 'text' }] }, - stderr: 'fixture stderr\n', + stderr: expect.stringContaining('fixture stderr\n'), }); const inspected = invoked as { readonly result: { readonly content: readonly [{ readonly text: string }] }; diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index b3fbffebf..c5ebdbcd0 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -259,7 +259,7 @@ describe('framework-owned package build', () => { await expect(execFile(process.execPath, [script, 'beta'])).resolves.toMatchObject({ stdout: 'hauled:beta\n' }); }, 120_000); - it('wraps factory-exporting MCP entries in the lifecycle shell and leaves self-connecting entries alone', async () => { + it('wraps every local MCP entry, conventional or explicit, in the lifecycle shell', async () => { const root = await fixtureRoot({ ...conventionFixture(), 'agent-bundle.config.ts': [ @@ -275,24 +275,33 @@ describe('framework-owned package build', () => { '};', '', ].join('\n'), - 'src/plain.ts': "process.stderr.write('self-connecting entry ran\\n');\n", + 'src/plain.ts': [ + 'export default () => {', + " process.stderr.write('plain factory ran\\n');", + ' return {', + ' close() {},', + ' async connect(transport: { onmessage?: unknown }) { void transport; },', + ' };', + '};', + '', + ].join('\n'), }); const result = await build({ output: 'artifact', packageOutputs: true, root }); expect(result.packageBuild).toBeUndefined(); const entries = Object.fromEntries(result.build.compiledMcpEntries.map((entry) => [entry.id, entry.output])); - const wrapped = await readFile(entries['mcp:echoer']!, 'utf8'); - const plain = await readFile(entries['mcp:plain']!, 'utf8'); - expect(wrapped).toContain('stdio heartbeat'); - expect(wrapped).not.toMatch(/from\s*['"]agent-bundle/u); - expect(plain).not.toContain('stdio heartbeat'); + for (const id of ['mcp:echoer', 'mcp:plain']) { + const bundle = await readFile(entries[id]!, 'utf8'); + expect(bundle).toContain('stdio heartbeat'); + expect(bundle).not.toMatch(/from\s*['"]agent-bundle/u); + } // The lifecycle shell exits 0 on stdin EOF so clients can respawn. - const eofRun = execFile(process.execPath, [entries['mcp:echoer']!], { timeout: 15_000 }); - eofRun.child.stdin?.end(); - await expect(eofRun).resolves.toMatchObject({ stdout: '' }); - const plainRun = await execFile(process.execPath, [entries['mcp:plain']!], { timeout: 15_000 }); - expect(plainRun.stderr).toContain('self-connecting entry ran'); + for (const id of ['mcp:echoer', 'mcp:plain']) { + const eofRun = execFile(process.execPath, [entries[id]!], { timeout: 15_000 }); + eofRun.child.stdin?.end(); + await expect(eofRun).resolves.toMatchObject({ stdout: '' }); + } }, 120_000); it('applies the tools escape hatch after the profile and before the invariant enforcer', async () => { @@ -864,12 +873,15 @@ describe('mcp run', () => { 'src/pin.ts': [ "import { mkdirSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", - "const anchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? '';", - "mkdirSync(join(anchor, '.runtime'), { recursive: true });", - "writeFileSync(join(anchor, '.runtime', 'state.json'), JSON.stringify({", - ' anchor,', - " cookie: process.env.MCP_RUN_TRACKER_COOKIE ?? null,", - '}));', + 'export default () => {', + " const anchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? '';", + " mkdirSync(join(anchor, '.runtime'), { recursive: true });", + " writeFileSync(join(anchor, '.runtime', 'state.json'), JSON.stringify({", + ' anchor,', + " cookie: process.env.MCP_RUN_TRACKER_COOKIE ?? null,", + ' }));', + ' process.exit(0);', + '};', '', ].join('\n'), }); @@ -913,7 +925,7 @@ describe('mcp run', () => { '', ].join('\n'), 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"version":"1.0.0"}\n', - 'src/exit.ts': 'process.exitCode = 7;\nexport const marker = true;\n', + 'src/exit.ts': 'export default () => { process.exit(7); };\n', }); await build({ output: 'artifact', root }); const exitCode = await runCli([ diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts index 0b7fe8486..08dd3c756 100644 --- a/packages/agent-bundle/tests/package-conventions.test.ts +++ b/packages/agent-bundle/tests/package-conventions.test.ts @@ -511,9 +511,9 @@ describe('artifact output validation', () => { }); }); -describe('migration nudges (AB473x)', () => { +describe('entry conventions (AB473x)', () => { const factoryEntry = 'export default () => ({ close() {}, async connect() {} });\n'; - const selfConnectingEntry = [ + const factorylessEntry = [ "import { connect } from './transport.js';", 'await connect();', '', @@ -533,28 +533,29 @@ describe('migration nudges (AB473x)', () => { }; }; - it('nudges AB4730 for a self-connecting explicit stdio entry', async () => { + it('errors with AB4730 for an explicit stdio entry without a default export', async () => { const { diagnostics, root } = await validated( { mcp: { servers: { curator: { entry: './src/server.ts' } } } }, - { 'src/server.ts': selfConnectingEntry }, + { 'src/server.ts': factorylessEntry }, ); expect(diagnostics).toEqual([{ code: 'AB4730', - message: expect.stringContaining('self-connecting'), - recovery: expect.stringContaining('Optional'), - severity: 'info', + message: 'MCP server "curator" stdio entry must default-export a server factory.', + recovery: + 'Default-export the server factory from the entry module, or declare a prebuilt server with command or url.', + severity: 'error', sourcePath: `${root}/src/server.ts`, }]); }); - it('nudges AB4730 for a self-connecting conventional stdio entry', async () => { + it('errors with AB4730 for a conventional stdio entry without a default export', async () => { const { diagnostics, root } = await validated( { mcp: { servers: { curator: {} } } }, - { 'src/mcp/curator.ts': selfConnectingEntry }, + { 'src/mcp/curator.ts': factorylessEntry }, ); expect(diagnostics).toEqual([expect.objectContaining({ code: 'AB4730', - severity: 'info', + severity: 'error', sourcePath: `${root}/src/mcp/curator.ts`, })]); }); @@ -688,55 +689,34 @@ describe('migration nudges (AB473x)', () => { return { diagnostics: validateSource(loaded, discovered, registry), root }; }; - it('errors with AB4736 for a skill in the removed top-level conventional location', async () => { - const { diagnostics, root } = await discoveredAndValidated( - {}, - { 'skills/legacy/SKILL.md': skillMarkdown('legacy') }, - ); + it('ignores top-level skill, command, and rule documents', async () => { + const root = await projectRoot({ + 'commands/legacy.md': '# Legacy command\n', + 'rules/legacy.mdc': '---\ndescription: Legacy rule\n---\nAlways verify.\n', + 'skills/legacy/SKILL.md': skillMarkdown('legacy'), + }); + const loaded = loadedProject({ plugin: { name: 'review-tools' } }, root); + const discovered = await discoverProject(root, loaded.config); - expect(diagnostics).toEqual([{ - code: 'AB4736', - message: expect.stringContaining('skills/legacy/SKILL.md'), - recovery: expect.stringContaining('src/skills/legacy/SKILL.md'), - severity: 'error', - sourcePath: `${root}/skills/legacy/SKILL.md`, - }]); + expect(discovered.skills).toEqual([]); + expect(discovered.commands).toBeUndefined(); + expect(discovered.rules).toBeUndefined(); + expect(validateSource(loaded, discovered, registry)).toEqual([]); }); - it('does not report AB4736 when explicit skills config claims a top-level skill', async () => { - const { diagnostics } = await discoveredAndValidated( + it('discovers a top-level skill directory named by explicit skills configuration', async () => { + const { diagnostics, root } = await discoveredAndValidated( { skills: ['skills/legacy'] }, { 'skills/legacy/SKILL.md': skillMarkdown('legacy') }, ); - expect(diagnostics.filter(({ code }) => code === 'AB4736')).toEqual([]); - }); - - it('errors with AB4736 for top-level command and rule documents', async () => { - const { diagnostics, root } = await discoveredAndValidated( - {}, - { - 'commands/legacy.md': '# Legacy command\n', - 'rules/legacy.mdc': '---\ndescription: Legacy rule\n---\nAlways verify.\n', - }, - ); - - expect(diagnostics).toEqual([ - { - code: 'AB4736', - message: expect.stringContaining('commands/legacy.md'), - recovery: expect.stringContaining('src/commands/legacy.md'), - severity: 'error', - sourcePath: `${root}/commands/legacy.md`, - }, - { - code: 'AB4736', - message: expect.stringContaining('rules/legacy.mdc'), - recovery: expect.stringContaining('src/rules/legacy.mdc'), - severity: 'error', - sourcePath: `${root}/rules/legacy.mdc`, - }, - ]); + expect(diagnostics).toEqual([]); + expect( + (await discoverProject(root, { plugin: { name: 'review-tools' }, skills: ['skills/legacy'] })).skills, + ).toMatchObject([{ + dir: `${root}/skills/legacy`, + source: `${root}/skills/legacy/SKILL.md`, + }]); }); it('discovers skills from the src convention', async () => { @@ -801,11 +781,11 @@ describe('migration nudges (AB473x)', () => { 'src/index.ts': 'export const a = 1;\n', 'src/mcp/curator.ts': factoryEntry, 'src/other.ts': 'export const main = async () => 0;\n', - 'src/server.ts': selfConnectingEntry, + 'src/server.ts': factoryEntry, }, ); expect(diagnostics.map((diagnostic) => diagnostic.code).sort()).toEqual([ - 'AB4730', 'AB4731', 'AB4732', 'AB4733', + 'AB4731', 'AB4732', 'AB4733', ]); expect(diagnostics.every((diagnostic) => diagnostic.severity === 'info')).toBe(true); }); diff --git a/packages/create-agent-bundle/README.md b/packages/create-agent-bundle/README.md index 53059f25d..f4f86ed9d 100644 --- a/packages/create-agent-bundle/README.md +++ b/packages/create-agent-bundle/README.md @@ -36,9 +36,9 @@ scripted and asks nothing, the remaining values fall back to their defaults. | `cli-tool` | An installable routed CLI from one `src/cli/.ts` route module (generated executable, help, argv grammar, validation), a conventional `src/scripts/.ts` artifact script, and a `src/index.ts` library export with declarations, with the framework test harness wired up. | Every template ships a `check` script (validate + build + typecheck + tests) -and validates with zero diagnostics, including the `AB473x` migration -nudges, because the templates are written against the entry conventions from -the start. +and validates with zero diagnostics, including the informational `AB473x` +convention nudges, because the templates are written against the entry +conventions from the start. The `mcp-server` and `cli-tool` templates also start with the consumer test harness. `mcp-server` ships a route-unit pool (`agentBundleRstest()` from diff --git a/packages/create-agent-bundle/tests/support/scaffold-fixture.ts b/packages/create-agent-bundle/tests/support/scaffold-fixture.ts index 6c4b1a6d5..b0ee63ab3 100644 --- a/packages/create-agent-bundle/tests/support/scaffold-fixture.ts +++ b/packages/create-agent-bundle/tests/support/scaffold-fixture.ts @@ -270,7 +270,7 @@ export const expectPassedPool = async ( if (exit !== 0) throw new Error(`\`npm run ${script}\` exited ${String(exit)} although its report says pass:\n${stderr}`); }; -/** Zero diagnostics — including the informational AB473x migration nudges. */ +/** Zero diagnostics — including the informational AB473x convention nudges. */ export const expectCleanValidate = async (projectRoot: string): Promise => { const cli = join(projectRoot, 'node_modules', '.bin', 'agent-bundle'); const { stdout } = await execFile(cli, ['validate', '--json', '--root', projectRoot], { diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 957af1c97..768aac9bd 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -82,21 +82,22 @@ const writeRealAppProject = async (root: string): Promise => { writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", "import { z } from 'zod';", '', - "const server = new McpServer({ name: 'real-app-fixture', version: '1.0.0' });", `const appHtml = ${JSON.stringify(appFixtureHtml)};`, - "server.registerResource('app', 'ui://fixture/app.html', { mimeType: 'text/html;profile=mcp-app' }, async (uri) => ({", - " contents: [{ mimeType: 'text/html;profile=mcp-app', text: appHtml, uri: uri.href }],", - '}));', - "server.registerTool('show-app', { _meta: { ui: { resourceUri: 'ui://fixture/app.html' } } }, async () => ({", - " content: [{ text: 'Real App result.', type: 'text' }], structuredContent: { source: 'real-sdk-v2' },", - '}));', - "server.registerTool('inner-echo', { inputSchema: z.object({ message: z.string() }) }, async ({ message }) => ({", - " content: [{ text: `Inner echo: ${message}`, type: 'text' }],", - '}));', - 'await server.connect(new StdioServerTransport());', + 'export default () => {', + " const server = new McpServer({ name: 'real-app-fixture', version: '1.0.0' });", + " server.registerResource('app', 'ui://fixture/app.html', { mimeType: 'text/html;profile=mcp-app' }, async (uri) => ({", + " contents: [{ mimeType: 'text/html;profile=mcp-app', text: appHtml, uri: uri.href }],", + ' }));', + " server.registerTool('show-app', { _meta: { ui: { resourceUri: 'ui://fixture/app.html' } } }, async () => ({", + " content: [{ text: 'Real App result.', type: 'text' }], structuredContent: { source: 'real-sdk-v2' },", + ' }));', + " server.registerTool('inner-echo', { inputSchema: z.object({ message: z.string() }) }, async ({ message }) => ({", + " content: [{ text: `Inner echo: ${message}`, type: 'text' }],", + ' }));', + ' return server;', + '};', '', ].join('\n')), writeFile(join(root, 'agent-bundle.config.ts'), [ @@ -148,27 +149,28 @@ const writeBundledAppProject = async (root: string): Promise => { writeFile(join(root, 'views', 'shell.html'), '
waiting
\n'), writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", "import apps from 'agent-bundle/mcp-apps';", "import { z } from 'zod';", '', - "const server = new McpServer({ name: 'bundled-app-fixture', version: '1.0.0' });", - 'for (const app of apps) {', - ' server.registerResource(app.name, app.resourceUri, { mimeType: app.mimeType }, async (uri) => ({', - ' contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }],', + 'export default () => {', + " const server = new McpServer({ name: 'bundled-app-fixture', version: '1.0.0' });", + ' for (const app of apps) {', + ' server.registerResource(app.name, app.resourceUri, { mimeType: app.mimeType }, async (uri) => ({', + ' contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }],', + ' }));', + ' }', + ' const [app] = apps;', + " if (app === undefined) throw new Error('Expected one bundled MCP App.');", + " server.registerTool('show-dashboard', { _meta: { ui: { resourceUri: app.resourceUri } } }, async () => ({", + " content: [{ text: 'Packed release dashboard.', type: 'text' }],", + " structuredContent: { source: 'packed-release' },", + ' }));', + " server.registerTool('inner-echo', { inputSchema: z.object({ message: z.string() }) }, async ({ message }) => ({", + " content: [{ text: `Inner echo: ${message}`, type: 'text' }],", + ' structuredContent: { echo: message },', ' }));', - '}', - 'const [app] = apps;', - "if (app === undefined) throw new Error('Expected one bundled MCP App.');", - "server.registerTool('show-dashboard', { _meta: { ui: { resourceUri: app.resourceUri } } }, async () => ({", - " content: [{ text: 'Packed release dashboard.', type: 'text' }],", - " structuredContent: { source: 'packed-release' },", - '}));', - "server.registerTool('inner-echo', { inputSchema: z.object({ message: z.string() }) }, async ({ message }) => ({", - " content: [{ text: `Inner echo: ${message}`, type: 'text' }],", - ' structuredContent: { echo: message },', - '}));', - 'await server.connect(new StdioServerTransport());', + ' return server;', + '};', '', ].join('\n')), writeFile(join(root, 'agent-bundle.config.ts'), [ diff --git a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts index 7d32c5535..4897fd729 100644 --- a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts +++ b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts @@ -22,11 +22,12 @@ const writeTimeoutProject = async (root: string): Promise => { writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", '', - "const server = new McpServer({ name: 'timeout-fixture', version: '1.0.0' });", - "server.registerTool('inspect', {}, async () => ({ content: [{ text: 'ready', type: 'text' }] }));", - 'await server.connect(new StdioServerTransport());', + 'export default () => {', + " const server = new McpServer({ name: 'timeout-fixture', version: '1.0.0' });", + " server.registerTool('inspect', {}, async () => ({ content: [{ text: 'ready', type: 'text' }] }));", + ' return server;', + '};', '', ].join('\n')), writeFile(join(root, 'agent-bundle.config.ts'), [ diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index cdf0a375c..1b15bc346 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -36,21 +36,22 @@ const writeMcpPlaygroundProject = async (root: string): Promise => { writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", - "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", "import { z } from 'zod';", '', - "const server = new McpServer({ name: 'playground-fixture', version: '1.0.0' });", - "server.registerTool('echo', { description: 'Echo one message.', inputSchema: z.object({ message: z.string() }) }, async ({ message }) => ({", - " content: [{ type: 'text', text: `Echo: ${message}` }],", - '}));', - "server.registerTool('wait', { description: 'Wait for cancellation.' }, async () => new Promise(() => {}));", - "server.registerResource('fixture', 'ui://fixture/resource.txt', { mimeType: 'text/plain' }, async (uri) => ({", - " contents: [{ mimeType: 'text/plain', text: 'fixture resource', uri: uri.href }],", - '}));', - "server.registerPrompt('fixture', { description: 'Fixture prompt.' }, async () => ({", - " messages: [{ role: 'user', content: { type: 'text', text: 'fixture prompt' } }],", - '}));', - 'await server.connect(new StdioServerTransport());', + 'export default () => {', + " const server = new McpServer({ name: 'playground-fixture', version: '1.0.0' });", + " server.registerTool('echo', { description: 'Echo one message.', inputSchema: z.object({ message: z.string() }) }, async ({ message }) => ({", + " content: [{ type: 'text', text: `Echo: ${message}` }],", + ' }));', + " server.registerTool('wait', { description: 'Wait for cancellation.' }, async () => new Promise(() => {}));", + " server.registerResource('fixture', 'ui://fixture/resource.txt', { mimeType: 'text/plain' }, async (uri) => ({", + " contents: [{ mimeType: 'text/plain', text: 'fixture resource', uri: uri.href }],", + ' }));', + " server.registerPrompt('fixture', { description: 'Fixture prompt.' }, async () => ({", + " messages: [{ role: 'user', content: { type: 'text', text: 'fixture prompt' } }],", + ' }));', + ' return server;', + '};', '', ].join('\n')), writeFile(join(root, 'agent-bundle.config.ts'), [ diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 3f3dc736a..f9409a62e 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -551,8 +551,8 @@ keep the rest of the document places it behind `Suspense`. ## Handwritten stdio entries A server declared in config with no `entry`, `command`, or `url` picks up the conventional -`src/mcp/.ts` module. The framework serves it under the lifecycle shell when it -**default-exports a server factory**: +`src/mcp/.ts` module. Every local entry **default-exports a server factory** and is +served under the lifecycle shell: ```ts // src/mcp/curator.ts — the whole stdio entry a consumer writes @@ -571,19 +571,17 @@ sixty-second activity throttle, labeled with the server name). That guard matters because stdout carries JSON-RPC framing: one stray `console.log` from any imported module would corrupt the protocol stream. -Self-connecting entries, modules that construct and connect a transport at top level without a -default export, keep their existing behavior byte for byte: no lifecycle shell, and no operator -`.env` layer, since there is no shell to apply it before the module's own top-level code runs. -Source validation reports the informational `AB4730` nudge suggesting the factory upgrade; it is -never an error. +A module that constructs and connects a transport at top level without a default export cannot be +built: source validation reports `AB4730` as an **error**. Declare a server you do not want +compiled with `command` or `url`, or as a `{ prebuilt: ... }` entry your own build produced. The same lifecycle is public API for hand-rolled entries, and so is the operator `.env` layer the generated shell imports ahead of the server module, so that even a `process.env` read at the module's top level sees the composed environment (see [Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)). Both imports are inlined into the emitted entry, which stays self-contained; -`agent-bundle/launch-env` is aliased into every stdio entry, so it resolves whether or not the -entry has a shell. A self-connecting entry that needs the layer applies it first thing, anchored +`agent-bundle/launch-env` is aliased into every stdio entry. A hand-rolled entry outside this +build, a prebuilt server for instance, applies the layer first thing, anchored the way the generated shell is (the artifact root is the parent of `mcp/`), and may pass its own declared `env` block as `manifestEnv` so that a manifest default the host passed through yields to the file the way it does in the generated shell: diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 562ae474e..49751e289 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -197,10 +197,9 @@ IPT_SESSION=… RTORRENT_SSH_HOST=nas.local ``` -Every shell the pack emits reads that file when it starts, the stdio MCP server entries (factory -exports; a [self-connecting entry](../authoring/mcp.mdx#handwritten-stdio-entries) has no shell and -reads none unless it calls `applyOperatorEnv` itself), the hook wrappers that run plugin code, and -the artifact CLI `bin/.mjs`, under one rule: +Every shell the pack emits reads that file when it starts, the +[stdio MCP server entries](../authoring/mcp.mdx#handwritten-stdio-entries), the hook wrappers that +run plugin code, and the artifact CLI `bin/.mjs`, under one rule: | Precedence | Layer | Contents | | --- | --- | --- | diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 002003926..fac19a77d 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -484,7 +484,7 @@ export default defineTool({ inputSchema, resultSchema }, async (input) => { ## 手写 stdio 入口 在配置中声明、但未指定 `entry`、`command` 或 `url` 的服务器,会识别约定的 `src/mcp/.ts` -模块。当它**默认导出一个服务器工厂函数**时,框架会把它放在生命周期外壳之下运行: +模块。每一个本地入口都**默认导出一个服务器工厂函数**,并在生命周期外壳之下运行: ```ts // src/mcp/curator.ts — the whole stdio entry a consumer writes @@ -501,14 +501,13 @@ export default () => new McpServer({ name: 'curator', version: '1.0.0' }); 这层保护之所以重要,是因为 stdout 承载着 JSON-RPC 帧:任何被导入模块中一次走神的 `console.log` 都会 破坏协议流。 -自行连接的入口,在顶层构造并连接传输层、且没有默认导出的模块,保持现有行为,逐字节不变:没有生命周期 -外壳,也没有操作者 `.env` 层,因为没有外壳能在模块自身的顶层代码运行之前应用它。源码校验 -会报告信息级的 `AB4730` 提示,建议升级为工厂函数写法;它永远不是错误。 +在顶层构造并连接传输层、且没有默认导出的模块无法构建:源码校验会把 `AB4730` 报告为**错误**。不希望 +被编译的服务器请用 `command` 或 `url` 声明,或声明为由你自己的构建产出的 `{ prebuilt: ... }` 入口。 同一套生命周期对手写入口也是公开 API,生成外壳先于服务器模块导入的操作者 `.env` 层同样如此,因此即使 在模块顶层读取 `process.env` 也能看到合成后的环境(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env))。 这两个导入都会被内联进产出的入口,入口保持自包含;`agent-bundle/launch-env` 会被别名到每一个 stdio -入口,因此无论入口有没有外壳都能解析。需要这一层的自行连接入口应在第一行自行应用它,并像生成外壳一样 +入口。本次构建之外的手写入口(例如预构建服务器)应在第一行自行应用它,并像生成外壳一样 锚定(产物根目录是 `mcp/` 的上一级);还可以把自己声明的 `env` 块作为 `manifestEnv` 传入,让宿主透传的 清单默认值像在生成外壳中那样让位于文件: diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 15a99ea6a..71eb0e9b6 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -156,9 +156,8 @@ IPT_SESSION=… RTORRENT_SSH_HOST=nas.local ``` -包输出的每个外壳,stdio MCP 服务器入口(工厂导出;[自行连接的入口](../authoring/mcp.mdx#手写-stdio-入口) -没有外壳,除非自行调用 `applyOperatorEnv`,否则不读取任何文件)、运行插件代码的 hook 包装器、产物 CLI -`bin/.mjs`,在启动时按同一条规则读取该文件: +包输出的每个外壳,[stdio MCP 服务器入口](../authoring/mcp.mdx#手写-stdio-入口)、运行插件代码的 +hook 包装器、产物 CLI `bin/.mjs`,在启动时按同一条规则读取该文件: | 优先级 | 层 | 内容 | | --- | --- | --- |