diff --git a/CHANGELOG.md b/CHANGELOG.md index a1dbd3a..572dace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/config-normalize.ts b/src/config-normalize.ts index 7b8f164..9addf59 100644 --- a/src/config-normalize.ts +++ b/src/config-normalize.ts @@ -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 | undefined { const headers: Record = {}; diff --git a/tests/config-command-string.test.ts b/tests/config-command-string.test.ts index e7ad90d..dcda778 100644 --- a/tests/config-command-string.test.ts +++ b/tests/config-command-string.test.ts @@ -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([]); + }); });