From 5d54fd647a941dc72df8880ea1a8db329b27d22d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 17:51:19 +0000 Subject: [PATCH 1/2] fix(adapters): anchor emitted stdio MCP entries with AGENT_BUNDLE_PLUGIN_ROOT and restore claude cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every adapter now injects a well-known AGENT_BUNDLE_PLUGIN_ROOT env entry pointing at the plugin install root in its native spelling (claude ${CLAUDE_PLUGIN_ROOT}, portable ${PLUGIN_ROOT}, cursor ${CURSOR_PLUGIN_ROOT}, codex ./ against its plugin-root cwd), with user-declared keys winning, so plugin runtimes stop fragmenting state across host launch directories on hosts that do not anchor cwd — Claude Code silently ignores stdio cwd at runtime. The claude adapter also stops dropping cwd for source-built servers, emitting the documented schema-valid field again, and all adapter revisions advance to 1.1.0 so stale artifacts revalidate as AB6010 instead of passing. Closes #47 --- .changeset/plugin-root-env-anchor.md | 19 ++++++ packages/agent-bundle/README.md | 13 ++++ packages/agent-bundle/src/adapters/claude.ts | 14 +++-- packages/agent-bundle/src/adapters/codex.ts | 9 ++- packages/agent-bundle/src/adapters/cursor.ts | 7 ++- packages/agent-bundle/src/adapters/plugin.ts | 2 +- .../agent-bundle/src/adapters/portable.ts | 7 ++- packages/agent-bundle/src/adapters/types.ts | 11 ++++ packages/agent-bundle/src/core/types.ts | 17 +++++ packages/agent-bundle/src/index.ts | 2 +- .../tests/adapter-metadata.test.ts | 8 +-- .../agent-bundle/tests/cursor-adapter.test.ts | 2 +- .../agent-bundle/tests/host-adapters.test.ts | 62 ++++++++++++++++--- .../tests/portable-adapter.test.ts | 4 +- 14 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 .changeset/plugin-root-env-anchor.md diff --git a/.changeset/plugin-root-env-anchor.md b/.changeset/plugin-root-env-anchor.md new file mode 100644 index 000000000..c426d19fd --- /dev/null +++ b/.changeset/plugin-root-env-anchor.md @@ -0,0 +1,19 @@ +--- +"agent-bundle": minor +--- + +Anchor every emitted stdio MCP server entry with a well-known +`AGENT_BUNDLE_PLUGIN_ROOT` environment variable holding the plugin install +root in each target's native spelling: `${CLAUDE_PLUGIN_ROOT}` on Claude Code, +`${PLUGIN_ROOT}` on portable, `${CURSOR_PLUGIN_ROOT}` on Cursor, and `./` on +Codex resolved against the entry's plugin-root cwd (a Codex entry without one +omits the anchor; source-built servers always carry it on every target). +User-declared `env` keys win over the injected value. The Claude adapter also +stops dropping `cwd` for source-built servers and emits +`cwd: "${CLAUDE_PLUGIN_ROOT}"` as documented, schema-valid future-proofing — +Claude Code currently ignores the field at runtime, which is exactly why +runtime code should resolve persistent state against the env anchor instead of +the process working directory. The anchor name ships as the new +`pluginRootEnvAnchor` export, and every adapter's revision advances to 1.1.0 +so previously built artifacts revalidate as stale instead of silently passing +with the old emission shape. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index a9e17f694..fe4465b4e 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -25,6 +25,19 @@ directory. Top-level `assets` replaces that convention with explicit entries: li whole directories, or globs, all resolved from the project root. Entries outside `assets/` keep their project-relative path under the artifact's `assets/` directory. +Every emitted stdio MCP server entry carries an `AGENT_BUNDLE_PLUGIN_ROOT` environment variable +holding the plugin install root in the target's native spelling: `${CLAUDE_PLUGIN_ROOT}` on Claude +Code, `${PLUGIN_ROOT}` on portable, `${CURSOR_PLUGIN_ROOT}` on Cursor, and `./` on Codex, resolved +against the entry's plugin-root `cwd`. Codex has no path-token interpolation, so a Codex stdio +server without a plugin-root working directory omits the anchor; source-built (`entry:`) servers +always have one on every target. Server runtime code should resolve persistent state and bundled +assets against this anchor rather than the process working directory: Claude Code currently +launches stdio servers from the host's own working directory and ignores the emitted `cwd` field +(the Claude adapter still emits `cwd: "${CLAUDE_PLUGIN_ROOT}"` as documented, schema-valid +future-proofing). A server's own `env` entries win over the injected value, so declaring +`env: { AGENT_BUNDLE_PLUGIN_ROOT: ... }` replaces the anchor. The `pluginRootEnvAnchor` export +names the variable for consumer code. + Hook `tools` accept the canonical selectors (`shell`, `file.read`, `file.write`, `mcp`, `agent`) plus explicit host-native selectors such as `claude:WebSearch` or `codex:view_image`, which contribute only to that host's native matcher. A hook that selects tools must leave every selected diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 94f1bfe7b..4ddcc329e 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -38,6 +38,7 @@ import { standardPluginArtifactPlan, validateJsonSchemaDocument, validateModernMcpDocument, + withPluginRootEnvAnchor, type TargetAdapter, type TargetArtifactPlan, } from './types.ts'; @@ -81,7 +82,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: capabilityTable.observedCliVersion, capabilitySha256: 'a1d90db5f605e76dad541a1ba37ba06283aa24f8b55f10ce7d197b5c6b5ac9f2', observedVersion: capabilityTable.observedCliVersion, @@ -137,7 +138,7 @@ const planMcpServer = ( diagnostics.push(errorDiagnostic('claude.mcp.command.required', `Claude MCP server "${server.name}" requires a command.`)); return { diagnostics }; } - const env = server.env === undefined + const declaredEnv = server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env).map(([key, value]) => { if (hasPathToken(key)) { @@ -150,6 +151,11 @@ const planMcpServer = ( })); if (diagnostics.length > 0) return { diagnostics }; const args = server.args?.map(expandClaudeToken); + // Claude Code currently ignores stdio cwd at runtime (see + // anthropics/claude-code#17565), so the absolute entry path stays as the + // script-resolution hedge and the env anchor carries the working + // plugin-root guarantee; cwd is still emitted below as documented, + // schema-valid future-proofing. if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) { args[0] = `${hookContract.commandRoot}/${args[0]}`; } @@ -158,8 +164,8 @@ const planMcpServer = ( value: { ...(args === undefined ? {} : { args }), command: expandClaudeToken(server.command), - ...(server.cwd === undefined || server.source !== undefined ? {} : { cwd: expandClaudeToken(server.cwd) }), - ...(env === undefined ? {} : { env }), + ...(server.cwd === undefined ? {} : { cwd: expandClaudeToken(server.cwd) }), + env: withPluginRootEnvAnchor(declaredEnv, expandClaudeToken(pathTokens.pluginRoot)), type: 'stdio', }, }; diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index e1a6e1e57..e6c554776 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -37,6 +37,7 @@ import { standardArtifactLayout, standardPluginArtifactPlan, validateJsonSchemaDocument, + withPluginRootEnvAnchor, type TargetAdapter, type TargetArtifactDocumentValidator, type TargetArtifactPlan, @@ -107,7 +108,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: capabilityTable.observedCliVersion, capabilitySha256: '4b08c8820ace59ca068677dcb1863a9fd4cb730b7e00733b994b0076958beaf0', observedVersion: capabilityTable.observedCliVersion, @@ -248,13 +249,17 @@ const planMcpServer = ( if (diagnostics.length > 0 || command === undefined || nativeArgs?.some((value) => value === undefined) || Object.values(env ?? {}).some((value) => value === undefined)) { return { diagnostics }; } + // Codex has no path-token interpolation, so the plugin-root env anchor is + // representable only as `./` resolved against a plugin-root cwd; entries + // without one skip the anchor instead of emitting a misleading value. + const anchoredEnv = hasPluginRootCwd ? withPluginRootEnvAnchor(env, './') : env; return { diagnostics, value: { ...(nativeArgs === undefined ? {} : { args: nativeArgs }), command, ...(cwd === undefined ? {} : { cwd }), - ...(env === undefined ? {} : { env }), + ...(anchoredEnv === undefined ? {} : { env: anchoredEnv }), type: 'stdio', }, }; diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index fcd7d26fc..3ab0a8355 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -34,6 +34,7 @@ import { standardPluginArtifactPlan, validateJsonSchemaDocument, validateModernMcpDocument, + withPluginRootEnvAnchor, type TargetAdapter, type TargetArtifactPlan, } from './types.ts'; @@ -152,7 +153,7 @@ export const planCursorMcpServer = ( if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) { args[0] = `\${CURSOR_PLUGIN_ROOT}/${args[0]}`; } - const env = server.env === undefined + const declaredEnv = server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, expandCursorToken(value)])); return { @@ -160,7 +161,7 @@ export const planCursorMcpServer = ( value: { ...(args === undefined ? {} : { args }), command: expandCursorToken(server.command), - ...(env === undefined ? {} : { env }), + env: withPluginRootEnvAnchor(declaredEnv, expandCursorToken(pathTokens.pluginRoot)), }, }; } @@ -202,7 +203,7 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: capabilityTable.observedCliVersion, capabilitySha256: 'b8990776721f3e2cf4707364812586a0043b8a1247899a47f256302739c00443', observedVersion: capabilityTable.observedCliVersion, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 0e34bc15d..15655f117 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -162,7 +162,7 @@ const artifactValidation = Object.freeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion}`, capabilitySha256: claudeAdapter.metadata.capabilitySha256, observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}`, diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 98d4357c8..cececcee1 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -24,6 +24,7 @@ import { sourceInputs, validateJsonSchemaDocument, validateModernMcpDocument, + withPluginRootEnvAnchor, type TargetAdapter, type TargetArtifactEntry, type TargetArtifactPlan, @@ -47,7 +48,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: capabilityTable.observedSpecificationVersion, capabilitySha256: '642da9f921374a4d0143da21ed4b4b2260a2375a5eb33c4cbb4ef531f2bb7352', observedVersion: capabilityTable.observedSpecificationVersion, @@ -141,7 +142,7 @@ const planMcpServer = ( const diagnostic = unsupportedTokenDiagnostic(server.command, 'command'); if (diagnostic !== undefined) diagnostics.push(diagnostic); } - const env = server.env === undefined ? undefined : Object.fromEntries( + const declaredEnv = server.env === undefined ? undefined : Object.fromEntries( Object.entries(server.env).map(([key, value]) => { const keyDiagnostic = unsupportedTokenDiagnostic(key, 'env-key'); if (keyDiagnostic !== undefined) diagnostics.push(keyDiagnostic); @@ -176,7 +177,7 @@ const planMcpServer = ( ...(args === undefined ? {} : { args }), command: server.command, ...(cwd === undefined ? {} : { cwd }), - ...(env === undefined ? {} : { env }), + env: withPluginRootEnvAnchor(declaredEnv, expandPortableToken(pathTokens.pluginRoot)), type: transport, }, }; diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index fb6b37048..5117e1442 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -6,6 +6,7 @@ import { stableJson } from '../core/digest.ts'; import { snapshotStrictJsonValue } from '../core/strict-json.ts'; import { pathTokens, + pluginRootEnvAnchor, type AgentBundleConfig, type NormalizedPlugin, } from '../core/types.ts'; @@ -86,6 +87,16 @@ export const schemaDescriptorsFrom = ( export const hasPathToken = (value: string): boolean => value.includes(pathTokens.pluginRoot) || value.includes(pathTokens.pluginData) || value.includes(pathTokens.workspaceRoot); +/** + * Injects the well-known plugin-root env anchor beneath a stdio server's + * declared environment. The declared entries spread after the anchor, so a + * user-declared `AGENT_BUNDLE_PLUGIN_ROOT` key always wins. + */ +export const withPluginRootEnvAnchor = ( + env: Readonly> | undefined, + pluginRoot: string, +): Record => ({ [pluginRootEnvAnchor]: pluginRoot, ...env }); + export interface StandardPluginArtifactsInput { readonly diagnostics: readonly Diagnostic[]; readonly hookDocument?: Record; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index df6db443c..8595275cf 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -52,6 +52,11 @@ export interface AgentBundleMcpServer { command?: string; cwd?: string; entry?: string; + /** + * Extra environment for stdio servers. Adapters inject the well-known + * plugin-root anchor (see pluginRootEnvAnchor) beneath these entries, so a + * declared key with that name wins over the injected value. + */ env?: Readonly>; headers?: Readonly>; targets?: readonly string[]; @@ -326,3 +331,15 @@ export const pathTokens = Object.freeze({ pluginData: 'agent-bundle:path:plugin-data', workspaceRoot: 'agent-bundle:path:workspace-root', } as const); + +/** + * Well-known environment variable every adapter injects into emitted stdio + * MCP server entries, holding the plugin install root in the target's native + * representation (`${CLAUDE_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, + * `${CURSOR_PLUGIN_ROOT}`, or Codex's `./` resolved against the entry's + * plugin-root cwd). Server runtime code should resolve persistent state and + * bundled assets against it instead of the process working directory, which + * not every host anchors to the plugin root. A user-declared env entry with + * this key always wins over the injected value. + */ +export const pluginRootEnvAnchor = 'AGENT_BUNDLE_PLUGIN_ROOT'; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 7ba769b05..2470adf94 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -3,7 +3,7 @@ import type { CodexConfigExtension } from './adapters/codex.ts'; import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; -export { defineConfig, pathTokens } from './core/types.ts'; +export { defineConfig, pathTokens, pluginRootEnvAnchor } from './core/types.ts'; export { compareEvals, runEvals, startDevServer } from './api.ts'; export { createCodexEvalHarness, diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index fa527cbd0..4dc8bc2a9 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -54,7 +54,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: '1.0.0', capabilitySha256: '642da9f921374a4d0143da21ed4b4b2260a2375a5eb33c4cbb4ef531f2bb7352', observedVersion: '1.0.0', @@ -72,7 +72,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: '0.147.0', capabilitySha256: '4b08c8820ace59ca068677dcb1863a9fd4cb730b7e00733b994b0076958beaf0', observedVersion: '0.147.0', @@ -100,7 +100,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: '2.1.250', capabilitySha256: 'a1d90db5f605e76dad541a1ba37ba06283aa24f8b55f10ce7d197b5c6b5ac9f2', observedVersion: '2.1.250', @@ -128,7 +128,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.0.0', + adapterRevision: '1.1.0', capabilityRevision: '2026-08-28', capabilitySha256: 'b8990776721f3e2cf4707364812586a0043b8a1247899a47f256302739c00443', observedVersion: '2026-08-28', diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 57ab803c5..143b5a1eb 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -105,7 +105,7 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(mcp.mcpServers['status']).toEqual({ args: ['--root', '${CURSOR_PLUGIN_ROOT}/tools/server.mjs'], command: 'node', - env: { CACHE_DIR: '${workspaceFolder}/cache' }, + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}', CACHE_DIR: '${workspaceFolder}/cache' }, }); expect(mcp.mcpServers['remote']).toEqual({ headers: { Authorization: 'Bearer literal' }, diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 142d51618..7bf8fd826 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -8,7 +8,7 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { build } from './support/build.ts'; -import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; +import { pathTokens, pluginRootEnvAnchor, type NormalizedPlugin } from '../src/core/types.ts'; const installFormats = addFormats as unknown as (target: Ajv2020) => void; @@ -224,7 +224,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen relativePath: '.codex-plugin/plugin.json', }, { - content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","./tools/server.mjs"],"command":"node","cwd":"./","env":{"CACHE_DIR":"cache"},"type":"stdio"}}}\n', + content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","./tools/server.mjs"],"command":"node","cwd":"./","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"./","CACHE_DIR":"cache"},"type":"stdio"}}}\n', kind: 'write', relativePath: '.mcp.json', }, @@ -244,7 +244,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen relativePath: '.claude-plugin/plugin.json', }, { - content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","${CLAUDE_PLUGIN_ROOT}/tools/server.mjs"],"command":"node","cwd":"${CLAUDE_PLUGIN_ROOT}","env":{"CACHE_DIR":"cache"},"type":"stdio"}}}\n', + content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","${CLAUDE_PLUGIN_ROOT}/tools/server.mjs"],"command":"node","cwd":"${CLAUDE_PLUGIN_ROOT}","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","CACHE_DIR":"cache"},"type":"stdio"}}}\n', kind: 'write', relativePath: '.mcp.json', }, @@ -272,7 +272,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen await validateDocuments('claude', writeContents(plugin, 'claude')); }); -it('embeds the Claude plugin root in compiled MCP entry arguments', () => { +it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd, and the env anchor', () => { const compiled = { ...plugin, mcpServers: Object.freeze([Object.freeze({ @@ -284,12 +284,58 @@ it('embeds the Claude plugin root in compiled MCP entry arguments', () => { const entry = planEntries(compiled, 'claude').find((candidate) => candidate.relativePath === '.mcp.json'); expect(entry?.kind).toBe('write'); const document = JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { - mcpServers: Record; + mcpServers: Record }>; }; + // The absolute entry path stays as the hedge against Claude Code ignoring + // cwd at runtime; cwd is emitted anyway as schema-valid future-proofing, + // and the env anchor is the guaranteed working-directory-independent root. expect(document.mcpServers.stdio).toMatchObject({ args: ['${CLAUDE_PLUGIN_ROOT}/mcp/compiled-server.mjs'], + cwd: '${CLAUDE_PLUGIN_ROOT}', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}', CACHE_DIR: 'cache' }, }); - expect(document.mcpServers.stdio).not.toHaveProperty('cwd'); +}); + +it.each(['codex', 'claude'] as const)( + 'keeps a user-declared plugin-root env anchor over the injected %s value', + (target) => { + const overridden = { + ...plugin, + mcpServers: Object.freeze([Object.freeze({ + ...plugin.mcpServers[0]!, + env: Object.freeze({ [pluginRootEnvAnchor]: 'declared-root' }), + })]), + } satisfies NormalizedPlugin; + const entry = planEntries(overridden, target).find((candidate) => candidate.relativePath === '.mcp.json'); + const document = JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { + mcpServers: Record }>; + }; + expect(document.mcpServers.stdio?.env).toEqual({ [pluginRootEnvAnchor]: 'declared-root' }); + }, +); + +it('omits the Codex env anchor when a stdio server has no plugin-root cwd to resolve it against', () => { + const unanchored = { + ...plugin, + mcpServers: Object.freeze([Object.freeze({ + args: Object.freeze(['serve']), + command: 'external-tool', + env: Object.freeze({ CACHE_DIR: 'cache' }), + id: 'mcp:external', + name: 'external', + provenance: Object.freeze({ kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }), + targets: Object.freeze(['codex', 'claude']), + transport: 'stdio' as const, + })]), + } satisfies NormalizedPlugin; + const read = (target: 'codex' | 'claude') => { + const entry = planEntries(unanchored, target).find((candidate) => candidate.relativePath === '.mcp.json'); + return (JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { + mcpServers: Record }>; + }).mcpServers.external?.env; + }; + expect(read('codex')).toEqual({ CACHE_DIR: 'cache' }); + expect(read('claude')).toEqual({ [pluginRootEnvAnchor]: '${CLAUDE_PLUGIN_ROOT}', CACHE_DIR: 'cache' }); }); it.each(['codex', 'claude'] as const)( @@ -510,7 +556,7 @@ it('applies only native path-token semantics and surfaces exact capability diagn 'claude.mcp.token.headers.key', ]); expect(claude.entries.find((entry) => entry.relativePath === '.mcp.json')).toEqual({ - content: '{"mcpServers":{"workspace":{"args":["${CLAUDE_PROJECT_DIR}/tool"],"command":"${CLAUDE_PLUGIN_ROOT}","cwd":"${CLAUDE_PLUGIN_DATA}","env":{"WORKSPACE":"${CLAUDE_PROJECT_DIR}"},"type":"stdio"}}}\n', + content: '{"mcpServers":{"workspace":{"args":["${CLAUDE_PROJECT_DIR}/tool"],"command":"${CLAUDE_PLUGIN_ROOT}","cwd":"${CLAUDE_PLUGIN_DATA}","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","WORKSPACE":"${CLAUDE_PROJECT_DIR}"},"type":"stdio"}}}\n', kind: 'write', relativePath: '.mcp.json', sourceInputs: ['/workspace/agent-bundle.config.ts'], @@ -591,7 +637,7 @@ it('rejects Claude path tokens in environment keys while expanding values in a v expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['claude.mcp.token.env.key']); expect(plan.entries.find((entry) => entry.relativePath === '.mcp.json')).toEqual({ - content: '{"mcpServers":{"valid-env":{"command":"node","env":{"DATA":"${CLAUDE_PLUGIN_DATA}","ROOT":"${CLAUDE_PLUGIN_ROOT}","WORKSPACE":"${CLAUDE_PROJECT_DIR}"},"type":"stdio"}}}\n', + content: '{"mcpServers":{"valid-env":{"command":"node","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","DATA":"${CLAUDE_PLUGIN_DATA}","ROOT":"${CLAUDE_PLUGIN_ROOT}","WORKSPACE":"${CLAUDE_PROJECT_DIR}"},"type":"stdio"}}}\n', kind: 'write', relativePath: '.mcp.json', sourceInputs: ['/workspace/agent-bundle.config.ts'], diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 8181b1986..7a8d2f733 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -184,7 +184,7 @@ it('plans portable MCP server variants with tokens expanded only where portable expect(plan.diagnostics).toEqual([]); expect(mcp).toEqual({ content: - '{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","${PLUGIN_ROOT}/tool"],"command":"node","cwd":"${PLUGIN_DATA}/cache","env":{"CACHE_DIR":"${PLUGIN_DATA}/cache"},"type":"stdio"}}}\n', + '{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","${PLUGIN_ROOT}/tool"],"command":"node","cwd":"${PLUGIN_DATA}/cache","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${PLUGIN_ROOT}","CACHE_DIR":"${PLUGIN_DATA}/cache"},"type":"stdio"}}}\n', kind: 'write', relativePath: 'mcp.json', sourceInputs: ['/workspace/agent-bundle.config.ts'], @@ -287,7 +287,7 @@ it('preserves a valid MCP server named __proto__', () => { expect(plan.diagnostics).toEqual([]); expect(plan.entries.find((entry) => entry.relativePath === 'mcp.json')).toEqual({ content: - '{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"__proto__":{"command":"node","type":"stdio"}}}\n', + '{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"__proto__":{"command":"node","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${PLUGIN_ROOT}"},"type":"stdio"}}}\n', kind: 'write', relativePath: 'mcp.json', sourceInputs: ['/workspace/agent-bundle.config.ts'], From fc0cf2645ea5daf8b67b9cd745737827c8645911 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 18:08:13 +0000 Subject: [PATCH 2/2] test(plugin-bundle): pin per-host env anchors on the unified bundle MCP documents and note the anchor in the root artifact contract --- README.md | 2 ++ .../agent-bundle/tests/plugin-bundle.test.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d2f8e40de..0b2a41c78 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,8 @@ artifact/ Portable artifacts contain portable plugin, skills, MCP, and App-resource files. Codex and Claude artifacts contain their respective native metadata and generated hook wrappers. Cursor artifacts contain the `.cursor-plugin/plugin.json` manifest, the auto-discovered `mcp.json` (Cursor's typeless server format), the flat versioned `hooks/hooks.json` with Cursor-codec wrappers, and shared skills, scripts, and assets. Terminal hosts can use normal MCP tools and resources; visual rendering of an MCP App depends on the host supporting the standard resource metadata. +Every emitted stdio MCP server entry carries an `AGENT_BUNDLE_PLUGIN_ROOT` environment variable holding the plugin install root in the target's native spelling (`${CLAUDE_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, or Codex's `./` resolved against the entry's plugin-root `cwd`); server runtime code should resolve persistent state against this anchor rather than the process working directory, and a server's own `env` entries win over the injected value. See the [package README](packages/agent-bundle/README.md) for the exact per-target semantics. + ## Public examples The repository includes credential-free public example workspaces. Each diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 599c61a59..2dc8662fb 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -101,11 +101,19 @@ it('lays both host manifests over one shared bundle root', () => { skills: './skills/', }); - const claudeMcp = JSON.parse(documents['.mcp.json']!) as { readonly mcpServers: Record }; + const claudeMcp = JSON.parse(documents['.mcp.json']!) as { + readonly mcpServers: Record }>; + }; expect(claudeMcp.mcpServers['status']!.args[0]).toBe('${CLAUDE_PLUGIN_ROOT}/mcp/server.mjs'); + expect(claudeMcp.mcpServers['status']!.cwd).toBe('${CLAUDE_PLUGIN_ROOT}'); + expect(claudeMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }); - const codexMcp = JSON.parse(documents['.codex-plugin/mcp.json']!) as { readonly mcpServers: Record }; + const codexMcp = JSON.parse(documents['.codex-plugin/mcp.json']!) as { + readonly mcpServers: Record }>; + }; expect(codexMcp.mcpServers['status']!.args[0]).toBe('./mcp/server.mjs'); + expect(codexMcp.mcpServers['status']!.cwd).toBe('./'); + expect(codexMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: './' }); const hooks = documents['hooks/hooks.json']!; expect(hooks).toContain('${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs'); @@ -129,9 +137,12 @@ it('lays both host manifests over one shared bundle root', () => { skills: './skills/', version: '2.0.0', }); - const cursorMcp = JSON.parse(documents['.cursor-plugin/mcp.json']!) as { readonly mcpServers: Record }; + const cursorMcp = JSON.parse(documents['.cursor-plugin/mcp.json']!) as { + readonly mcpServers: Record }>; + }; expect(cursorMcp.mcpServers['status']!.args[0]).toBe('${CURSOR_PLUGIN_ROOT}/mcp/server.mjs'); expect(cursorMcp.mcpServers['status']).not.toHaveProperty('type'); + expect(cursorMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}' }); expect(JSON.parse(documents['hooks/hooks-cursor.json']!)).toEqual({ hooks: { postToolUse: [{