diff --git a/.changeset/mcp-run-env-and-anchor.md b/.changeset/mcp-run-env-and-anchor.md new file mode 100644 index 000000000..cf209fff6 --- /dev/null +++ b/.changeset/mcp-run-env-and-anchor.md @@ -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.`, `.env..local`), with `--env-file ` (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/` root, so consumer state survives rebuilds. `args`/`cwd` stay artifact-rooted (the entry is the content-hashed bundle inside the artifact). `--plugin-root ` restores a byte-faithful copied-artifact rehearsal when wanted. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 7daa2e597..95e569ff2 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -137,6 +137,7 @@ overriding the contract. The hatch customizes *how code compiles*, never ```sh agent-bundle mcp run --server --target [--artifact ] + [--env-file ]... [--no-env] [--plugin-root ] ``` Runs one built stdio MCP server in the foreground with inherited stdio: the @@ -144,5 +145,39 @@ 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//` 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.`, `.env..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 ` +(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//`, +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 ` +overrides the env-anchor root, e.g. point it at `artifact/` 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. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index a5371b0f1..fa722f371 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -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. | diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index be487b509..e9d0d5255 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -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[0]['spawnProcess']; @@ -648,14 +664,22 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise/` in the project root. + * Both durable-state anchors point at the project root: plugin-data state + * persists under `.agent-bundle/mcp-run//`, 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 => { 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, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 691439888..43cacf164 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -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 ', 'MCP server name'); - mcpRunCommand.action(async (options: ArtifactCommandOptions & { readonly server: string; readonly target: string }) => { + ) + .requiredOption('--server ', 'MCP server name') + .option('--env-file ', 'Load exactly this .env file, replacing the project-root set (repeatable)', collect, []) + .option('--no-env', 'Launch without loading any .env files') + .option('--plugin-root ', '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, }); diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 42ebdda2d..899990257 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -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'; @@ -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 @@ -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; @@ -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, }); @@ -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.` variants of the conventional set. */ + readonly mode?: string; /** Injectable only to make foreground process behavior deterministic in tests. */ readonly spawnProcess?: ( command: string, @@ -116,10 +163,49 @@ 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.`, `.env..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>, +): Promise> => { + if (options.loadEnvFiles === false) return {}; + if (options.envFiles !== undefined && options.envFiles.length > 0) { + const merged: Record = {}; + 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 => { const launch = await resolveMcpStdioLaunch(options); @@ -127,10 +213,11 @@ export const runMcpForeground = async (options: RunMcpForegroundOptions): Promis 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', }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 9e136da67..55d7c7f8c 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -242,6 +242,153 @@ describe('mcp run', () => { await expect(stat(join(launches[0]!.cwd, launches[0]!.args[0]!))).resolves.toMatchObject({}); }, 120_000); + it('layers the launch environment: manifest env under .env files under operator process.env', async () => { + const root = await fixtureRoot({ + ...conventionFixture(), + 'agent-bundle.config.ts': [ + 'export default {', + ' mcp: { servers: { echoer: { env: {', + " SHARED: 'manifest',", + " STATE_DIR: 'agent-bundle:path:plugin-root/.runtime',", + ' } } } },', + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['codex', 'portable'],", + '};', + '', + ].join('\n'), + '.env': 'FROM_DOTENV=dotenv\nSHARED=dotenv\nMCP_RUN_BEATEN_BY_PROCESS=dotenv\n', + '.env.staging': 'FROM_MODE=staging\n', + 'custom.env': 'CUSTOM_ONLY=custom\n', + }); + const artifact = join(root, 'artifact'); + await build({ output: 'artifact', root }); + const base = { artifact, root, server: 'echoer', target: 'portable' }; + + type Launch = { args: readonly string[]; command: string; cwd: string; env: Readonly> }; + const captureLaunch = async (options: Parameters[0]): Promise => { + const launches: Launch[] = []; + const child = new EventEmitter() as import('node:child_process').ChildProcess; + child.kill = () => true; + await expect(runMcp({ + ...options, + spawnProcess: (command, args, spawnOptions) => { + launches.push({ args, command, cwd: spawnOptions.cwd, env: spawnOptions.env }); + queueMicrotask(() => child.emit('exit', 0, null)); + return child; + }, + })).resolves.toBe(0); + expect(launches).toHaveLength(1); + return launches[0]!; + }; + + // Bare run: .env fills gaps, .env beats manifest env, and the plugin-root + // env anchors expand to the durable project root — not the artifact. + const bare = await captureLaunch(base); + expect(bare.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + expect(bare.env.STATE_DIR).toBe(join(root, '.runtime')); + expect(bare.env.FROM_DOTENV).toBe('dotenv'); + expect(bare.env.SHARED).toBe('dotenv'); + // args/cwd stay artifact-rooted: args[0] is the content-hashed bundle. + expect(bare.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); + expect(bare.cwd).toBe(join(artifact, 'portable')); + // Loading never leaks .env values into the runner's own environment. + expect(process.env.FROM_DOTENV).toBeUndefined(); + + // Operator exports beat both the .env layer and the manifest anchor. + process.env.MCP_RUN_BEATEN_BY_PROCESS = 'process'; + process.env.AGENT_BUNDLE_PLUGIN_ROOT = '/operator/pin'; + try { + const exported = await captureLaunch(base); + expect(exported.env.MCP_RUN_BEATEN_BY_PROCESS).toBe('process'); + expect(exported.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe('/operator/pin'); + } finally { + delete process.env.MCP_RUN_BEATEN_BY_PROCESS; + delete process.env.AGENT_BUNDLE_PLUGIN_ROOT; + } + + // The mode variants of the conventional set participate. + const staged = await captureLaunch({ ...base, mode: 'staging' }); + expect(staged.env.FROM_MODE).toBe('staging'); + + // Explicit env files replace the conventional set; opting out drops the + // layer without touching the anchor expansion. + const custom = await captureLaunch({ ...base, envFiles: [join(root, 'custom.env')] }); + expect(custom.env.CUSTOM_ONLY).toBe('custom'); + expect(custom.env.FROM_DOTENV).toBeUndefined(); + const disabled = await captureLaunch({ ...base, loadEnvFiles: false }); + expect(disabled.env.FROM_DOTENV).toBeUndefined(); + expect(disabled.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + + // pluginRoot restores the byte-faithful artifact-rooted rehearsal. + const rehearsal = await captureLaunch({ ...base, pluginRoot: join(artifact, 'portable') }); + expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(join(artifact, 'portable')); + expect(rehearsal.env.STATE_DIR).toBe(join(artifact, 'portable', '.runtime')); + + // Codex has no token interpolation — its anchor is a `./` path, so the + // target's own relative rule must re-anchor it durably too. + const codex = await captureLaunch({ ...base, target: 'codex' }); + expect(codex.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + expect(codex.env.STATE_DIR).toBe(join(root, '.runtime')); + + // A named env file that cannot be read is an error, never a silent skip. + await expect(runMcp({ ...base, envFiles: [join(root, 'missing.env')] })) + .rejects.toThrow(/Cannot read env file/u); + }, 120_000); + + it('anchors consumer state at the project root under a bare CLI mcp run', async () => { + const root = await fixtureRoot({ + 'agent-bundle.config.ts': [ + 'export default {', + " mcp: { servers: { pinner: { entry: './src/pin.ts' } } },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', + '.env': 'MCP_RUN_TRACKER_COOKIE=secret\n', + // A consumer trusting the documented anchor exactly as PR #49 intends. + '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,", + '}));', + '', + ].join('\n'), + }); + await build({ output: 'artifact', root }); + const exitCode = await runCli([ + 'mcp', 'run', + '--root', root, + '--artifact', join(root, 'artifact'), + '--target', 'portable', + '--server', 'pinner', + ]); + expect(exitCode).toBe(0); + const state = JSON.parse(await readFile(join(root, '.runtime', 'state.json'), 'utf8')) as { + anchor: string; + cookie: string | null; + }; + expect(state.anchor).toBe(root); + expect(state.cookie).toBe('secret'); + // Nothing durable may land inside the rebuildable artifact. + await expect(stat(join(root, 'artifact', 'portable', '.runtime'))).rejects.toMatchObject({ code: 'ENOENT' }); + }, 120_000); + + it('rejects --env-file combined with --no-env', async () => { + const stderr: string[] = []; + const exitCode = await runCli( + ['mcp', 'run', '--root', '.', '--artifact', 'artifact', '--target', 'portable', '--server', 's', '--env-file', 'x.env', '--no-env'], + { stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: () => undefined } }, + ); + expect(exitCode).toBe(1); + expect(stderr.join('')).toContain('Use either --env-file or --no-env, not both.'); + }); + it('runs a built server end to end through the CLI and forwards its exit code', async () => { const root = await fixtureRoot({ 'agent-bundle.config.ts': [