From 7ffbd52bf7e6ba3d691f95239a33932733ca96b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 05:05:10 +0100 Subject: [PATCH] fix: respect stdio cwd config --- docs/config.md | 1 + mcporter.schema.json | 4 +++ src/config-normalize.ts | 10 +++++- src/config-schema.ts | 6 ++++ src/env.ts | 5 +-- tests/config-normalize.test.ts | 56 ++++++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index c2a29ed..682e272 100644 --- a/docs/config.md +++ b/docs/config.md @@ -189,6 +189,7 @@ Server definition fields (subset of what `RawEntrySchema` accepts): | `description` | Free-form summary printed by `mcporter list`/`config list`. | | `baseUrl` / `url` / `serverUrl` | HTTPS or HTTP endpoint. `http://` requires `--allow-http` in ad-hoc mode but works in config if you explicitly set it. | | `command` / `args` | Stdio executable definition (string or array). Arrays are preferred because they avoid shell quoting issues. | +| `cwd` | Working directory for stdio servers. A leading `~` is expanded to `$HOME`; relative paths resolve against the config file directory. Defaults to the config file directory when omitted. | | `env` | Key/value pairs applied when launching stdio commands. Supports `${VAR}` interpolation and `${VAR:-fallback}` defaults. Existing process env values win over fallbacks. | | `headers` | Request headers for HTTP/SSE transports. Values can reference `$env:VAR` or `${VAR}` placeholders, which must be set at runtime or mcporter aborts with a helpful error. | | `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. | diff --git a/mcporter.schema.json b/mcporter.schema.json index d034348..ab99ee3 100644 --- a/mcporter.schema.json +++ b/mcporter.schema.json @@ -59,6 +59,10 @@ "type": "string" } }, + "cwd": { + "description": "Working directory for stdio servers. A leading ~ is expanded to $HOME; relative paths resolve against the config file directory", + "type": "string" + }, "headers": { "description": "HTTP headers for requests. Supports $VAR and $env:VAR placeholders", "type": "object", diff --git a/src/config-normalize.ts b/src/config-normalize.ts index 35f3dd8..9623cbd 100644 --- a/src/config-normalize.ts +++ b/src/config-normalize.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js'; import { expandHome } from './env.js'; import { resolveLifecycle } from './lifecycle.js'; @@ -36,7 +37,7 @@ export function normalizeServerEntry( kind: 'stdio', command: stdio.command, args: stdio.args, - cwd: baseDir, + cwd: resolveCwd(raw.cwd, baseDir), }; } else { throw new Error(`Server '${name}' is missing a baseUrl/url or command definition in mcporter.json`); @@ -93,6 +94,13 @@ function normalizePath(input: string | undefined): string | undefined { return expandHome(input); } +function resolveCwd(input: string | undefined, baseDir: string): string { + if (!input) { + return baseDir; + } + return path.resolve(baseDir, expandHome(input)); +} + function getUrl(raw: RawEntry): string | undefined { return raw.baseUrl ?? raw.base_url ?? raw.url ?? raw.serverUrl ?? raw.server_url ?? undefined; } diff --git a/src/config-schema.ts b/src/config-schema.ts index 20bbc5e..a67f1fa 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -62,6 +62,12 @@ export const RawEntrySchema = z .describe('Command to spawn for stdio transport (string or array of arguments)'), executable: z.string().optional().describe('Executable path for stdio transport'), args: z.array(z.string()).optional().describe('Arguments to pass to the stdio command'), + cwd: z + .string() + .optional() + .describe( + 'Working directory for stdio servers. A leading ~ is expanded to $HOME; relative paths resolve against the config file directory' + ), headers: z .record(z.string(), z.string()) .optional() diff --git a/src/env.ts b/src/env.ts index 3021f9e..7c22fd1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,5 @@ import os from 'node:os'; +import path from 'node:path'; const ENV_DEFAULT_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-|:|-)?([^}]*)\}$/; const ENV_INTERPOLATION_PATTERN = /\\?\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; @@ -13,8 +14,8 @@ export function expandHome(input: string): string { if (input === '~') { return home; } - if (input.startsWith('~/')) { - return `${home}/${input.slice(2)}`; + if (input.startsWith('~/') || input.startsWith('~\\')) { + return path.join(home, input.slice(2)); } return input; } diff --git a/tests/config-normalize.test.ts b/tests/config-normalize.test.ts index e850f7a..5880513 100644 --- a/tests/config-normalize.test.ts +++ b/tests/config-normalize.test.ts @@ -36,6 +36,62 @@ describe('config normalization', () => { expect(headers?.accept?.toLowerCase()).toContain('text/event-stream'); }); + it('respects cwd on stdio servers', async () => { + await fs.mkdir(TEMP_DIR, { recursive: true }); + const configPath = path.join(TEMP_DIR, 'mcporter-cwd.json'); + const absoluteCwd = path.join(os.tmpdir(), 'mcporter-cwd-absolute'); + await fs.mkdir(absoluteCwd, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + absolute: { + command: 'node', + args: ['server.js'], + cwd: absoluteCwd, + }, + relative: { + command: 'node', + args: ['server.js'], + cwd: 'packages/foo', + }, + tilde: { + command: 'node', + args: ['server.js'], + cwd: '~/mcporter-cwd-home', + }, + tildeBackslash: { + command: 'node', + args: ['server.js'], + cwd: '~\\mcporter-cwd-home', + }, + defaulted: { + command: 'node', + args: ['server.js'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const servers = await loadServerDefinitions({ configPath }); + const byName = new Map(servers.map((entry) => [entry.name, entry])); + const cwdFor = (name: string): string | undefined => { + const command = byName.get(name)?.command; + return command?.kind === 'stdio' ? command.cwd : undefined; + }; + + expect(cwdFor('absolute')).toBe(absoluteCwd); + expect(cwdFor('relative')).toBe(path.resolve(TEMP_DIR, 'packages/foo')); + expect(cwdFor('tilde')).toBe(path.join(os.homedir(), 'mcporter-cwd-home')); + expect(cwdFor('tildeBackslash')).toBe(path.join(os.homedir(), 'mcporter-cwd-home')); + expect(cwdFor('defaulted')).toBe(TEMP_DIR); + }); + it('normalizes oauthScope from camelCase and snake_case keys', async () => { await fs.mkdir(TEMP_DIR, { recursive: true }); const configPath = path.join(TEMP_DIR, 'mcporter-oauth-scope.json');