fix: preserve spaced stdio paths
Some checks failed
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled

This commit is contained in:
Peter Steinberger 2026-05-06 22:49:36 +01:00
parent 7d345bc7db
commit 23d3f9ef8d
No known key found for this signature in database
3 changed files with 72 additions and 3 deletions

View File

@ -2,7 +2,11 @@
## [Unreleased]
- Nothing yet.
### Config
- Preserve existing stdio executable paths that contain spaces instead of
splitting them as inline command strings, so app bundle helpers like Hopper's
MCP server can be configured directly.
## [0.10.1] - 2026-05-04

View File

@ -1,3 +1,4 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
import { expandHome } from './env.js';
@ -27,7 +28,7 @@ export function normalizeServerEntry(
const headers = buildHeaders(raw);
const httpUrl = getUrl(raw);
const stdio = getCommand(raw);
const stdio = getCommand(raw, baseDir);
let command: CommandSpec;
@ -114,7 +115,7 @@ function getUrl(raw: RawEntry): string | undefined {
return raw.baseUrl ?? raw.base_url ?? raw.url ?? raw.serverUrl ?? raw.server_url ?? undefined;
}
function getCommand(raw: RawEntry): { command: string; args: string[] } | undefined {
function getCommand(raw: RawEntry, baseDir: string): { command: string; args: string[] } | undefined {
const commandValue = raw.command ?? raw.executable;
if (Array.isArray(commandValue)) {
if (commandValue.length === 0 || typeof commandValue[0] !== 'string') {
@ -127,6 +128,9 @@ function getCommand(raw: RawEntry): { command: string; args: string[] } | undefi
if (args.length > 0) {
return { command: commandValue, args };
}
if (isExistingCommandPath(commandValue, baseDir)) {
return { command: commandValue, args: [] };
}
const tokens = parseCommandString(commandValue);
if (tokens.length === 0) {
return undefined;
@ -140,6 +144,27 @@ function getCommand(raw: RawEntry): { command: string; args: string[] } | undefi
return undefined;
}
function isExistingCommandPath(value: string, baseDir: string): boolean {
const trimmed = value.trim();
if (!trimmed.includes(' ')) {
return false;
}
if (!looksLikePath(trimmed)) {
return false;
}
const expanded = expandHome(trimmed);
const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(baseDir, expanded);
try {
return fs.statSync(resolved).isFile();
} catch {
return false;
}
}
function looksLikePath(value: string): boolean {
return value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/');
}
function buildHeaders(raw: RawEntry): Record<string, string> | undefined {
const headers: Record<string, string> = {};

View File

@ -93,4 +93,44 @@ describe('command string parsing', () => {
path: configPath,
});
});
it('preserves existing executable paths that contain spaces', async () => {
tmpDir = await fs.mkdtemp(TMP_PREFIX);
const binDir = path.join(tmpDir, 'Application Bundle.app', 'Contents', 'MacOS');
await fs.mkdir(binDir, { recursive: true });
const executable = path.join(binDir, 'HopperMCPServer');
await fs.writeFile(executable, '#!/bin/sh\nexit 0\n');
await fs.chmod(executable, 0o755);
const configDir = path.join(tmpDir, 'config');
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, 'mcporter.json');
await fs.writeFile(
configPath,
JSON.stringify({
mcpServers: {
hopper: {
command: executable,
},
},
imports: [],
})
);
const servers = await loadServerDefinitions({
configPath,
rootDir: tmpDir,
});
const server = servers[0];
if (!server) {
throw new Error('expected server definition');
}
expect(server.command.kind).toBe('stdio');
if (server.command.kind !== 'stdio') {
throw new Error('expected stdio command');
}
expect(server.command.command).toBe(executable);
expect(server.command.args).toEqual([]);
});
});