From 1d31f4960723e84385ea0284cb617f82c63e4df0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 01:50:36 +0000 Subject: [PATCH 1/2] feat(config)!: require a server factory from every stdio MCP entry and retire AB4736 Wrap every local MCP entry in the framework stdio lifecycle shell and raise AB4730 to an error when the entry has no default export; delete the byte-for-byte self-connecting build path. Stop discovering and reporting documents in the removed top-level skills/, commands/, and rules/ locations. --- .changeset/remove-legacy-entry-conventions.md | 6 + docs/diagnostics.md | 60 +++--- docs/entry-conventions.md | 43 ++-- docs/framework-mode.md | 6 +- .../comprehensive/src/mcp-server.ts | 49 ++--- .../packed-release/src/mcp-server.ts | 35 ++-- packages/agent-bundle/src/build/entries.ts | 61 +++--- .../agent-bundle/src/build/inspect-bundler.ts | 27 +-- packages/agent-bundle/src/config/discover.ts | 42 ---- packages/agent-bundle/src/config/validate.ts | 41 ++-- packages/agent-bundle/tests/api.test.ts | 25 +-- .../tests/artifact-cli-bin.test.ts | 2 +- .../tests/browser-stdio-bridge-spike.test.ts | 71 +++---- .../agent-bundle/tests/build-compose.test.ts | 2 +- packages/agent-bundle/tests/cli.test.ts | 27 ++- .../tests/composite-rules.test.ts | 2 +- .../agent-bundle/tests/dev-services.test.ts | 2 +- .../agent-bundle/tests/dev-workbench.test.ts | 21 +- .../agent-bundle/tests/host-mcp-proxy.test.ts | 42 ++-- .../agent-bundle/tests/launch-env.test.ts | 2 +- .../tests/manifest-relocatable.test.ts | 2 +- .../tests/mcp-session-service.test.ts | 48 ++--- packages/agent-bundle/tests/mcp.test.ts | 194 ++++++------------ .../agent-bundle/tests/package-build.test.ts | 50 +++-- .../tests/package-conventions.test.ts | 88 +++----- packages/create-agent-bundle/README.md | 6 +- .../tests/support/scaffold-fixture.ts | 2 +- .../workbench/tests/mcp-app-real.e2e.test.ts | 60 +++--- .../tests/mcp-session-timeout.e2e.test.ts | 9 +- packages/workbench/tests/overview.e2e.test.ts | 27 +-- website/docs/en/guide/authoring/mcp.mdx | 16 +- .../en/guide/distribution/installation.mdx | 7 +- website/docs/zh/guide/authoring/mcp.mdx | 9 +- .../zh/guide/distribution/installation.mdx | 5 +- 34 files changed, 472 insertions(+), 617 deletions(-) create mode 100644 .changeset/remove-legacy-entry-conventions.md diff --git a/.changeset/remove-legacy-entry-conventions.md b/.changeset/remove-legacy-entry-conventions.md new file mode 100644 index 000000000..45d4e56b3 --- /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 (#PR) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 36cbcc068..fb9a1ca52 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 8c26ba581..23b4ab276 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -458,27 +458,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'); @@ -488,22 +484,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 }), @@ -522,11 +514,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`,在启动时按同一条规则读取该文件: | 优先级 | 层 | 内容 | | --- | --- | --- | From 832709131fb235c3aef7c74a4b17f1211384cf79 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:05:43 +0000 Subject: [PATCH 2/2] chore: link changeset to #839 --- .changeset/remove-legacy-entry-conventions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/remove-legacy-entry-conventions.md b/.changeset/remove-legacy-entry-conventions.md index 45d4e56b3..6260f6d31 100644 --- a/.changeset/remove-legacy-entry-conventions.md +++ b/.changeset/remove-legacy-entry-conventions.md @@ -3,4 +3,4 @@ "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 (#PR) +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)