Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/plugin-root-env-anchor.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
standardPluginArtifactPlan,
validateJsonSchemaDocument,
validateModernMcpDocument,
withPluginRootEnvAnchor,
type TargetAdapter,
type TargetArtifactPlan,
} from './types.ts';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -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]}`;
}
Expand All @@ -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',
},
};
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
standardArtifactLayout,
standardPluginArtifactPlan,
validateJsonSchemaDocument,
withPluginRootEnvAnchor,
type TargetAdapter,
type TargetArtifactDocumentValidator,
type TargetArtifactPlan,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
},
};
Expand Down
7 changes: 4 additions & 3 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
standardPluginArtifactPlan,
validateJsonSchemaDocument,
validateModernMcpDocument,
withPluginRootEnvAnchor,
type TargetAdapter,
type TargetArtifactPlan,
} from './types.ts';
Expand Down Expand Up @@ -152,15 +153,15 @@ 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 {
diagnostics: [],
value: {
...(args === undefined ? {} : { args }),
command: expandCursorToken(server.command),
...(env === undefined ? {} : { env }),
env: withPluginRootEnvAnchor(declaredEnv, expandCursorToken(pathTokens.pluginRoot)),
},
};
}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
7 changes: 4 additions & 3 deletions packages/agent-bundle/src/adapters/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
sourceInputs,
validateJsonSchemaDocument,
validateModernMcpDocument,
withPluginRootEnvAnchor,
type TargetAdapter,
type TargetArtifactEntry,
type TargetArtifactPlan,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
},
};
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-bundle/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = <Value extends string | undefined>(
env: Readonly<Record<string, Value>> | undefined,
pluginRoot: string,
): Record<string, Value | string> => ({ [pluginRootEnvAnchor]: pluginRoot, ...env });

export interface StandardPluginArtifactsInput {
readonly diagnostics: readonly Diagnostic[];
readonly hookDocument?: Record<string, unknown>;
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
headers?: Readonly<Record<string, string>>;
targets?: readonly string[];
Expand Down Expand Up @@ -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';
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/agent-bundle/tests/adapter-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/tests/cursor-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading
Loading