diff --git a/apps/docs/src/content/docs/bundles/manifest.mdx b/apps/docs/src/content/docs/bundles/manifest.mdx index e94815b..2663be3 100644 --- a/apps/docs/src/content/docs/bundles/manifest.mdx +++ b/apps/docs/src/content/docs/bundles/manifest.mdx @@ -140,13 +140,28 @@ Use `${__dirname}` to reference the bundle's extraction directory: } ``` -### Runtime Environment Variables +### Runtime Variables -`mpak run` automatically sets environment variables that bundles can use at runtime: +Use `${mpak.*}` placeholders in `mcp_config.env` to reference mpak runtime values. These are resolved before the bundle is spawned: -| Variable | Description | -|----------|-------------| -| `MPAK_WORKSPACE` | Project-local directory for persistent data (defaults to `$CWD/.mpak`). Use this instead of relative paths for any data that should survive across restarts. | +| Placeholder | Description | +|-------------|-------------| +| `${mpak.workspace}` | Project-local workspace directory (defaults to `$CWD/.mpak`) | +| `${mpak.cache_dir}` | Bundle's cache/extraction directory | +| `${mpak.bundle_name}` | Bundle name (e.g., `@scope/name`) | + +```json +{ + "mcp_config": { + "env": { + "DATA_ROOT": "${mpak.workspace}", + "CACHE_PATH": "${mpak.cache_dir}/state" + } + } +} +``` + +`mpak run` also sets `MPAK_WORKSPACE` in the child environment automatically. Bundles can read it directly at runtime if they don't use `mcp_config.env`: ```python import os diff --git a/apps/docs/src/content/docs/cli/run.mdx b/apps/docs/src/content/docs/cli/run.mdx index 9bb99a4..789021c 100644 --- a/apps/docs/src/content/docs/cli/run.mdx +++ b/apps/docs/src/content/docs/cli/run.mdx @@ -81,6 +81,18 @@ workspace = os.environ.get("MPAK_WORKSPACE", ".") const workspace = process.env.MPAK_WORKSPACE ?? "."; ``` +### Manifest Placeholders + +Bundles can also reference runtime values in `mcp_config.env` using `${mpak.*}` placeholders. These are resolved before the bundle is spawned: + +| Placeholder | Description | +|-------------|-------------| +| `${mpak.workspace}` | Same as `MPAK_WORKSPACE` | +| `${mpak.cache_dir}` | Bundle's cache/extraction directory | +| `${mpak.bundle_name}` | Bundle name (e.g., `@scope/name`) | + +See the [Manifest Reference](/bundles/manifest#runtime-variables) for usage examples. + ## Configuration Some bundles require configuration (API keys, etc.). See [mpak config](/cli/config) for storing configuration values. diff --git a/packages/cli/src/commands/packages/run.test.ts b/packages/cli/src/commands/packages/run.test.ts index 6038059..184f913 100644 --- a/packages/cli/src/commands/packages/run.test.ts +++ b/packages/cli/src/commands/packages/run.test.ts @@ -6,6 +6,7 @@ import { resolveArgs, resolveWorkspace, substituteUserConfig, + substituteMpakVars, substituteEnvVars, getLocalCacheDir, localBundleNeedsExtract, @@ -261,6 +262,46 @@ describe("substituteEnvVars", () => { DEBUG: "true", }); }); + + it("substitutes mpak runtime vars when provided", () => { + const env = { + DATA_DIR: "${mpak.workspace}/data", + CACHE: "${mpak.cache_dir}", + NAME: "${mpak.bundle_name}", + }; + const mpakVars = { + workspace: "/home/.mpak", + cache_dir: "/home/.mpak/cache/test", + bundle_name: "@scope/test", + }; + expect(substituteEnvVars(env, {}, mpakVars)).toEqual({ + DATA_DIR: "/home/.mpak/data", + CACHE: "/home/.mpak/cache/test", + NAME: "@scope/test", + }); + }); + + it("substitutes both user_config and mpak vars in the same value", () => { + const env = { + DSN: "${user_config.db_host}:${mpak.workspace}/db", + }; + expect( + substituteEnvVars( + env, + { db_host: "localhost" }, + { workspace: "/data" }, + ), + ).toEqual({ + DSN: "localhost:/data/db", + }); + }); + + it("leaves unmatched mpak vars intact", () => { + const env = { X: "${mpak.unknown}" }; + expect(substituteEnvVars(env, {}, { workspace: "/w" })).toEqual({ + X: "${mpak.unknown}", + }); + }); }); describe("getLocalCacheDir", () => { @@ -333,3 +374,38 @@ describe("resolveWorkspace", () => { ); }); }); + +describe("substituteMpakVars", () => { + it("replaces ${mpak.workspace}", () => { + expect( + substituteMpakVars("${mpak.workspace}/data", { workspace: "/home/.mpak" }), + ).toBe("/home/.mpak/data"); + }); + + it("replaces multiple mpak vars in one string", () => { + expect( + substituteMpakVars("${mpak.workspace}/${mpak.bundle_name}", { + workspace: "/w", + bundle_name: "@scope/test", + }), + ).toBe("/w/@scope/test"); + }); + + it("leaves unmatched mpak vars intact", () => { + expect( + substituteMpakVars("${mpak.missing}/path", { workspace: "/w" }), + ).toBe("${mpak.missing}/path"); + }); + + it("leaves non-mpak placeholders untouched", () => { + expect( + substituteMpakVars("${user_config.key}", { workspace: "/w" }), + ).toBe("${user_config.key}"); + }); + + it("handles string with no placeholders", () => { + expect( + substituteMpakVars("plain-value", { workspace: "/w" }), + ).toBe("plain-value"); + }); +}); diff --git a/packages/cli/src/commands/packages/run.ts b/packages/cli/src/commands/packages/run.ts index 17d6b5e..cd243d7 100644 --- a/packages/cli/src/commands/packages/run.ts +++ b/packages/cli/src/commands/packages/run.ts @@ -117,6 +117,23 @@ export function resolveWorkspace( return override || join(cwd, ".mpak"); } +/** + * Substitute ${mpak.*} runtime variable placeholders in a string. + * Available variables: workspace, cache_dir, bundle_name + * @example substituteMpakVars('${mpak.workspace}/data', { workspace: '/home/.mpak' }) => '/home/.mpak/data' + */ +export function substituteMpakVars( + value: string, + vars: Record, +): string { + return value.replace( + /\$\{mpak\.([^}]+)\}/g, + (match, key: string) => { + return vars[key] ?? match; + }, + ); +} + /** * Substitute ${user_config.*} placeholders in a string * @example substituteUserConfig('${user_config.api_key}', { api_key: 'secret' }) => 'secret' @@ -134,16 +151,21 @@ export function substituteUserConfig( } /** - * Substitute ${user_config.*} placeholders in env vars + * Substitute ${user_config.*} and ${mpak.*} placeholders in env vars */ export function substituteEnvVars( env: Record | undefined, userConfigValues: Record, + mpakVars?: Record, ): Record { if (!env) return {}; const result: Record = {}; for (const [key, value] of Object.entries(env)) { - result[key] = substituteUserConfig(value, userConfigValues); + let substituted = substituteUserConfig(value, userConfigValues); + if (mpakVars) { + substituted = substituteMpakVars(substituted, mpakVars); + } + result[key] = substituted; } return result; } @@ -453,11 +475,22 @@ export async function handleRun( ); } - // Substitute user_config placeholders in env vars + // Resolve workspace early so bundles can reference it via ${mpak.workspace} + const workspace = resolveWorkspace(process.env["MPAK_WORKSPACE"], process.cwd()); + + // Runtime variables available to mcp_config.env via ${mpak.*} + const mpakVars: Record = { + workspace, + cache_dir: cacheDir, + bundle_name: packageName, + }; + + // Substitute user_config and mpak placeholders in env vars // Priority: process.env (from parent like Claude Desktop) > substituted values (from mpak config) const substitutedEnv = substituteEnvVars( mcp_config.env, userConfigValues, + mpakVars, ); let command: string; @@ -520,9 +553,8 @@ export async function handleRun( throw new Error(`Unsupported server type: ${type as string}`); } - // Provide a project-local workspace directory for stateful bundles. - // Defaults to $CWD/.mpak — user can override via MPAK_WORKSPACE in their environment. - env["MPAK_WORKSPACE"] = resolveWorkspace(env["MPAK_WORKSPACE"], process.cwd()); + // Ensure MPAK_WORKSPACE is always available in the child environment + env["MPAK_WORKSPACE"] = workspace; // Spawn with stdio passthrough for MCP const child = spawn(command, args, {