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
9 changes: 9 additions & 0 deletions .changeset/mcp-run-env-and-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"agent-bundle": minor
---

`mcp run` now owns the operator-environment seam the RFC #50 launchers were meant to retire, and stops fragmenting durable state per rebuild.

- The runner loads the project-root `.env` set by default (rsbuild `loadEnv` conventions: `.env`, `.env.local`, `.env.<mode>`, `.env.<mode>.local`), with `--env-file <path>` (repeatable, replaces the conventional set) and `--no-env` overrides. A named file that cannot be read is an error.
- Launch-environment precedence is now documented and enforced, lowest to highest: manifest env, `.env` file layer, operator `process.env`. Previously manifest env was spread last and silently beat operator exports (for example `AGENT_BUNDLE_PLUGIN_ROOT`).
- Plugin-root path tokens in env values — including the injected `AGENT_BUNDLE_PLUGIN_ROOT` durable-state anchor — now expand to the resolved project root under `mcp run` instead of the ephemeral `artifact/<target>` root, so consumer state survives rebuilds. `args`/`cwd` stay artifact-rooted (the entry is the content-hashed bundle inside the artifact). `--plugin-root <path>` restores a byte-faithful copied-artifact rehearsal when wanted.
39 changes: 37 additions & 2 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,47 @@ overriding the contract. The hatch customizes *how code compiles*, never

```sh
agent-bundle mcp run --server <name> --target <target> [--artifact <path>]
[--env-file <path>]... [--no-env] [--plugin-root <path>]
```

Runs one built stdio MCP server in the foreground with inherited stdio: the
content-hashed generated entry is resolved from the target's MCP manifest
(the job previously solved with bash launchers parsing `mcp.json`), path
tokens are resolved through the target adapter, and the child's exit code is
forwarded (SIGINT/SIGTERM forward to the child). Without `--artifact`, a
temporary artifact is built first. State anchored on the plugin-data token
persists under `.agent-bundle/mcp-run/<target>/<server>` in the project root.
temporary artifact is built first.

### Launch environment

The runner loads the project-root `.env` set by default — rsbuild's `loadEnv`
conventions (`.env`, `.env.local`, `.env.<mode>`, `.env.<mode>.local`, with
`--mode` selecting the variants), the same files `createRslib` reads for the
same consumers at build time — so operator credentials configured for the
plugin reach a bare `mcp run` without a wrapper script. `--env-file <path>`
(repeatable, Node's `--env-file` dialect, later files win) replaces the
conventional set with exactly the named files, and `--no-env` skips the layer
entirely; a named file that cannot be read is an error, never a silent skip.

The child environment is composed from three layers. This table is the
canonical precedence order (highest wins):

| Precedence | Layer | Contents |
| --- | --- | --- |
| 3 (highest) | Operator `process.env` | The real environment `mcp run` was started with. An exported variable always wins. |
| 2 | `.env` file layer | The conventional project-root set, or the explicit `--env-file` list in order. Fills gaps only; never beats an exported variable. |
| 1 (lowest) | Manifest env | Entries declared in the server config plus the injected plugin-root anchor, path tokens expanded. |

### Durable-state anchors

Under `mcp run` the artifact is an ephemeral build product, so both
durable-state anchors point at the project root: state anchored on the
plugin-data token persists under `.agent-bundle/mcp-run/<target>/<server>`,
and plugin-root tokens in *env values* — including the injected
`AGENT_BUNDLE_PLUGIN_ROOT` anchor — expand to the project root itself.
Targets without token interpolation (Codex serializes the anchor as a `./`
path) re-anchor their relative env values against the same durable root.
`args` and `cwd` stay artifact-rooted (the first argument is the
content-hashed bundle inside the target root). `--plugin-root <path>`
overrides the env-anchor root, e.g. point it at `artifact/<target>` for a
byte-faithful rehearsal of a copied-artifact launch; under a host install the
anchor still means the durable install root, exactly as before.
2 changes: 1 addition & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ the repository's `docs/entry-conventions.md` for the full contract.
| `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. |
| `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. |
| `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. |
| `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry. |
| `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. |
| `agent-bundle hooks list` / `hooks simulate` | List generated hooks, or run one emitted wrapper. |
| `agent-bundle eval` | Run deterministic or native Claude/Codex eval suites and record a run. |
| `agent-bundle dev` | Serve the packaged developer workbench on loopback. |
Expand Down
28 changes: 26 additions & 2 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,22 @@ export interface CompareEvalsOptions extends ProjectOptions {
}

export interface RunMcpOptions extends ArtifactOperationOptions {
/**
* Explicit `.env` files replacing the conventional project-root set.
* Loaded in order, later files winning on collision; relative paths
* resolve from the working directory.
*/
readonly envFiles?: readonly string[];
/** Set false to launch the server without any `.env` layer. */
readonly loadEnvFiles?: boolean;
/**
* Root the env-declared plugin-root anchors (for example
* `AGENT_BUNDLE_PLUGIN_ROOT`) expand to. Defaults to the project root so
* durable server state survives artifact rebuilds; point it at the
* artifact target root for a byte-faithful rehearsal of a copied-artifact
* launch.
*/
readonly pluginRoot?: string;
readonly server: string;
/** Injectable only to make foreground process behavior deterministic in tests. */
readonly spawnProcess?: Parameters<typeof runMcpForeground>[0]['spawnProcess'];
Expand Down Expand Up @@ -648,14 +664,22 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise<McpInvokeRes
/**
* Runs one built stdio MCP server in the foreground with inherited stdio,
* resolving its content-hashed generated entry from the target manifest.
* Server state anchored on the plugin-data token persists under
* `.agent-bundle/mcp-run/<target>/<server>` in the project root.
* Both durable-state anchors point at the project root: plugin-data state
* persists under `.agent-bundle/mcp-run/<target>/<server>`, and env-declared
* plugin-root anchors expand to the project root itself (override with
* `pluginRoot`). The launch environment layers, lowest to highest: manifest
* env, the project-root `.env` set (or `envFiles`), the operator's real
* `process.env`.
*/
export const runMcp = async (options: RunMcpOptions): Promise<number> => {
const registry = registryFor(options);
const workspaceRoot = resolve(options.root);
return temporaryArtifact({ ...options, registry }, async (artifact) => runMcpForeground({
artifact,
...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }),
...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }),
...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }),
...(options.mode === undefined ? {} : { mode: options.mode }),
pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', options.target, mcpServerStateDirectory(options.server)),
registry,
server: options.server,
Expand Down
20 changes: 18 additions & 2 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,13 +457,29 @@ export const runCli = async (
const mcpRunCommand = configureArtifactOptions(
mcpCommand.command('run').description('Run one stdio MCP server in the foreground from an artifact'),
true,
).requiredOption('--server <server>', 'MCP server name');
mcpRunCommand.action(async (options: ArtifactCommandOptions & { readonly server: string; readonly target: string }) => {
)
.requiredOption('--server <server>', 'MCP server name')
.option('--env-file <path>', 'Load exactly this .env file, replacing the project-root set (repeatable)', collect, [])
.option('--no-env', 'Launch without loading any .env files')
.option('--plugin-root <path>', 'Expand env plugin-root anchors against this root instead of the project root');
mcpRunCommand.action(async (options: ArtifactCommandOptions & {
readonly env: boolean;
readonly envFile: readonly string[];
readonly pluginRoot?: string;
readonly server: string;
readonly target: string;
}) => {
if (options.env === false && options.envFile.length > 0) {
throw new TypeError('Use either --env-file or --no-env, not both.');
}
const { runMcp } = await import('./api.ts');
// No stdout writes here: with inherited stdio the server owns the
// JSON-RPC channel for the whole foreground run.
exitCode = await runMcp({
...artifactOptions(options),
...(options.envFile.length === 0 ? {} : { envFiles: options.envFile }),
...(options.env === false ? { loadEnvFiles: false } : {}),
...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }),
server: options.server,
target: options.target,
});
Expand Down
93 changes: 90 additions & 3 deletions packages/agent-bundle/src/services/mcp-run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { loadEnv } from '@rsbuild/core';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdir, readFile } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { parseEnv } from 'node:util';

import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts';
import { validateArtifact } from '../build/validate-artifact.ts';
Expand All @@ -9,7 +11,11 @@ import { sha256Hex } from '../core/digest.ts';
import { assertInside, joinArtifact } from '../core/paths.ts';
import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';
import { resolveMcpPathTokens } from './mcp-path-tokens.ts';
import { readTargetMcpServer, type ModernMcpStdioServer } from './mcp-runtime.ts';
import {
readTargetMcpServer,
type ModernMcpStdioServer,
type TargetMcpRuntimeContract,
} from './mcp-runtime.ts';

/**
* The foreground MCP server runner behind `agent-bundle mcp run`: it resolves
Expand All @@ -27,6 +33,15 @@ export interface ResolvedMcpStdioLaunch {

export interface ResolveMcpStdioLaunchOptions {
readonly artifact: string;
/**
* Root that plugin-root path tokens in *env values* expand to — the
* durable-state anchors like `AGENT_BUNDLE_PLUGIN_ROOT`. Defaults to
* `workspaceRoot`: under `mcp run` the artifact is an ephemeral build
* product, so anchoring durable state on it would fragment that state per
* rebuild. Point it back at the artifact target root for a byte-faithful
* rehearsal of a copied-artifact launch.
*/
readonly envPluginRoot?: string;
/** Durable per-server state root replacing the plugin-data path token. */
readonly pluginDataRoot: string;
readonly registry?: TargetRegistry;
Expand Down Expand Up @@ -85,13 +100,34 @@ export const resolveMcpStdioLaunch = async (
throw new Error(`MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} is invalid.`);
}

/**
* Per-field plugin-root split: `args`/`cwd` must stay artifact-rooted
* (`args[0]` is the content-hashed bundle inside the target root), but env
* values are durable-state anchors, so their plugin-root tokens expand to
* the durable `envPluginRoot` instead of the rebuildable artifact.
*/
const envPluginRoot = resolve(options.envPluginRoot ?? options.workspaceRoot);
const launchRuntime: TargetMcpRuntimeContract = {
manifestPath: runtime.manifestPath,
readModernServers: (document) => runtime.readModernServers(document),
resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots),
resolveValue: (field, roots, value) => {
if (field !== 'env') return runtime.resolveValue(field, roots, value);
const envRoots = { ...roots, pluginRoot: envPluginRoot };
const resolution = runtime.resolveValue(field, envRoots, value);
// Targets without token interpolation (Codex) serialize the anchor as
// a `./` path instead: the target's own relative-argument rule
// re-anchors it against the durable root the tokens expand to.
return { ...resolution, value: runtime.resolveStdioArgument(resolution.value, envRoots) };
},
};
const resolved = resolveMcpPathTokens({
roots: {
pluginData: resolve(options.pluginDataRoot),
pluginRoot: targetRoot,
workspaceRoot: resolve(options.workspaceRoot),
},
runtime,
runtime: launchRuntime,
server: result.server,
target: options.target,
});
Expand All @@ -108,6 +144,17 @@ export const resolveMcpStdioLaunch = async (
};

export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions {
/**
* Explicit `.env` files replacing the conventional workspace-root set.
* Files use Node's `--env-file` dialect and load in order, later files
* winning on collision; relative paths resolve from the working directory.
* A named file that cannot be read is an error, never a silent skip.
*/
readonly envFiles?: readonly string[];
/** Set false to launch without any `.env` layer. */
readonly loadEnvFiles?: boolean;
/** Configuration mode selecting `.env.<mode>` variants of the conventional set. */
readonly mode?: string;
/** Injectable only to make foreground process behavior deterministic in tests. */
readonly spawnProcess?: (
command: string,
Expand All @@ -116,21 +163,61 @@ export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions {
) => ChildProcess;
}

/**
* The `.env` layer of the launch environment: explicit `--env-file` paths
* when given, otherwise rsbuild's `loadEnv` convention (`.env`, `.env.local`,
* `.env.<mode>`, `.env.<mode>.local`) at the workspace root — the same files
* `createRslib` reads for the same consumers at build time. Loading targets a
* scratch object so the real `process.env` is never mutated; `processEnv`
* still seeds `${VAR}` interpolation inside env-file values.
*/
const loadLaunchFileEnv = async (
options: RunMcpForegroundOptions,
processEnv: Readonly<Record<string, string>>,
): Promise<Record<string, string>> => {
if (options.loadEnvFiles === false) return {};
if (options.envFiles !== undefined && options.envFiles.length > 0) {
const merged: Record<string, string> = {};
for (const file of options.envFiles) {
const path = resolve(file);
let contents: string;
try {
contents = await readFile(path, 'utf8');
} catch {
throw new Error(`Cannot read env file ${JSON.stringify(path)}.`);
}
Object.assign(merged, parseEnv(contents));
}
return merged;
}
return loadEnv({
cwd: resolve(options.workspaceRoot),
...(options.mode === undefined ? {} : { mode: options.mode }),
processEnv: { ...processEnv },
}).parsed;
};

/**
* Resolves the server's generated entry from the built artifact and runs it
* in the foreground with inherited stdio. SIGINT/SIGTERM forward to the
* child; the child's exit code (or 128 + signal number) is returned.
*
* Launch environment precedence, lowest to highest: manifest env (declared
* entries plus the injected plugin-root anchor, path tokens expanded), the
* `.env` file layer, then the operator's real `process.env` — an exported
* variable always beats every file- or manifest-declared value.
*/
export const runMcpForeground = async (options: RunMcpForegroundOptions): Promise<number> => {
const launch = await resolveMcpStdioLaunch(options);
await mkdir(resolve(options.pluginDataRoot), { recursive: true });
const inheritedEnv = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
);
const fileEnv = await loadLaunchFileEnv(options, inheritedEnv);
const spawnProcess = options.spawnProcess ?? ((command, args, spawnOptions) => spawn(command, [...args], spawnOptions));
const child = spawnProcess(launch.command, launch.args, {
cwd: launch.cwd,
env: { ...inheritedEnv, ...launch.env },
env: { ...launch.env, ...fileEnv, ...inheritedEnv },
stdio: 'inherit',
});

Expand Down
Loading
Loading