fix: respect stdio cwd config

This commit is contained in:
Peter Steinberger 2026-05-04 05:05:10 +01:00
parent e1a35f8d91
commit 7ffbd52bf7
No known key found for this signature in database
6 changed files with 79 additions and 3 deletions

View File

@ -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. |

View File

@ -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",

View File

@ -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;
}

View File

@ -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()

View File

@ -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;
}

View File

@ -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');