mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
feat(mcp): multi-server support — repeatable --mcp with per-server names
`--mcp` is now a collect-style option; pass it multiple times to attach
multiple servers to the same chat / run. Each spec can optionally name
its namespace:
reasonix chat \
--mcp "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe" \
--mcp "demo=node --import tsx examples/mcp-server-demo.ts"
Tools land in a shared ToolRegistry with the prefix baked in
(fs_read_file, demo_add, …). The model sees the flat namespace; the
registry dispatches to the right subprocess.
Spec syntax (new src/mcp/spec.ts::parseMcpSpec):
- "name=command args..." → namespaced; `[a-zA-Z_][a-zA-Z0-9_]*` name
- "command args..." → anonymous; tools keep their native names
The identifier regex is deliberately narrow so Windows drive letters
(`C:\...`) never accidentally look like a namespace prefix.
--mcp-prefix still exists as a global fallback, but only applies when
exactly one anonymous spec is given. With named specs, each server
brings its own name.
Also fixes a shellSplit bug: backslashes outside quotes were being
treated as POSIX escapes, which mangled every Windows path. Now they
pass through literally outside quotes (use quotes to group args with
spaces). Matches user expectation on Windows; POSIX users who want
escape-a-space can quote the arg.
+9 tests: parseMcpSpec (8 cases including Windows drive letters,
identifier-name validation, empty/malformed input) + an integration
test spawning two demo subprocesses concurrently with different
prefixes, verifying the shared registry dispatches to each correctly.
Suite: 233 passing (was 224).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
407793c99e
commit
7cd862194a
10 files changed
+248
-85
No files matched your search
@@ -120,6 +120,12 @@ npx reasonix replay benchmarks/tau-bench/transcripts/mcp-filesystem.jsonl
|
||||
reasonix chat --mcp "node --import tsx examples/mcp-server-demo.ts"
|
||||
# or against the real filesystem server:
|
||||
reasonix chat --mcp "npx -y @modelcontextprotocol/server-filesystem /path/to/safe/dir"
|
||||
|
||||
# Multiple servers at once — each gets its own namespace prefix:
|
||||
reasonix chat \
|
||||
--mcp "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe" \
|
||||
--mcp "demo=node --import tsx examples/mcp-server-demo.ts"
|
||||
# Tools land in a shared registry as fs_read_file, demo_add, etc.
|
||||
```
|
||||
|
||||
[mcp]: ./benchmarks/tau-bench/transcripts/mcp-demo.add.jsonl
|
||||
|
||||
+33
-34
@@ -4,9 +4,9 @@ import { loadApiKey } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { shellSplit } from "../../mcp/shell-split.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { StdioTransport } from "../../mcp/stdio.js";
|
||||
import type { ToolRegistry } from "../../tools.js";
|
||||
import { ToolRegistry } from "../../tools.js";
|
||||
import { App } from "../ui/App.js";
|
||||
import { Setup } from "../ui/Setup.js";
|
||||
|
||||
@@ -17,9 +17,9 @@ export interface ChatOptions {
|
||||
harvest?: boolean;
|
||||
branch?: number;
|
||||
session?: string;
|
||||
/** Shell-style command string: `"npx -y @modelcontextprotocol/server-filesystem /tmp"`. */
|
||||
mcp?: string;
|
||||
/** Name prefix applied to every MCP tool so names from multiple servers don't collide. */
|
||||
/** Zero or more MCP server specs. Each: `"name=cmd args..."` or `"cmd args..."`. */
|
||||
mcp?: string[];
|
||||
/** Global prefix — only used when a single anonymous server is given. */
|
||||
mcpPrefix?: string;
|
||||
}
|
||||
|
||||
@@ -58,35 +58,34 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
loadDotenv();
|
||||
const initialKey = loadApiKey();
|
||||
|
||||
// Spawn + bridge any MCP server BEFORE rendering. This surfaces spawn
|
||||
// errors up-front (clearer than seeing them mid-TUI) and gives us a
|
||||
// ready ToolRegistry to hand to the loop.
|
||||
let mcp: McpClient | undefined;
|
||||
const mcpSpecs = opts.mcp ?? [];
|
||||
const clients: McpClient[] = [];
|
||||
let tools: ToolRegistry | undefined;
|
||||
if (opts.mcp) {
|
||||
const argv = shellSplit(opts.mcp);
|
||||
if (argv.length === 0) {
|
||||
process.stderr.write("error: --mcp requires a command\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const [command, ...args] = argv;
|
||||
if (!command) {
|
||||
process.stderr.write("error: --mcp command is empty\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const transport = new StdioTransport({ command, args });
|
||||
mcp = new McpClient({ transport });
|
||||
try {
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, { namePrefix: opts.mcpPrefix });
|
||||
tools = bridge.registry;
|
||||
process.stderr.write(
|
||||
`▸ MCP: ${bridge.registeredNames.length} tool(s) from ${argv.join(" ")}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(`MCP setup failed: ${(err as Error).message}\n`);
|
||||
await mcp.close();
|
||||
process.exit(1);
|
||||
|
||||
if (mcpSpecs.length > 0) {
|
||||
tools = new ToolRegistry();
|
||||
for (const raw of mcpSpecs) {
|
||||
try {
|
||||
const spec = parseMcpSpec(raw);
|
||||
const prefix = spec.name
|
||||
? `${spec.name}_`
|
||||
: mcpSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
const transport = new StdioTransport({ command: spec.command, args: spec.args });
|
||||
const mcp = new McpClient({ transport });
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, { registry: tools, namePrefix: prefix });
|
||||
const label = spec.name ?? "anon";
|
||||
process.stderr.write(
|
||||
`▸ MCP[${label}]: ${bridge.registeredNames.length} tool(s) from ${spec.command} ${spec.args.join(" ")}\n`,
|
||||
);
|
||||
clients.push(mcp);
|
||||
} catch (err) {
|
||||
process.stderr.write(`MCP setup failed for "${raw}": ${(err as Error).message}\n`);
|
||||
for (const c of clients) await c.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +95,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
for (const c of clients) await c.close();
|
||||
}
|
||||
}
|
||||
+32
-26
@@ -6,9 +6,9 @@ import { loadDotenv } from "../../env.js";
|
||||
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { shellSplit } from "../../mcp/shell-split.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { StdioTransport } from "../../mcp/stdio.js";
|
||||
import type { ToolRegistry } from "../../tools.js";
|
||||
import { ToolRegistry } from "../../tools.js";
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
|
||||
|
||||
export interface RunOptions {
|
||||
@@ -21,9 +21,9 @@ export interface RunOptions {
|
||||
branch?: number;
|
||||
/** JSONL transcript path — lets `reasonix replay` / `diff` audit this run. */
|
||||
transcript?: string;
|
||||
/** Shell-style command string for an MCP server to bridge tools from. */
|
||||
mcp?: string;
|
||||
/** Name prefix for bridged MCP tools. */
|
||||
/** Zero or more MCP server specs. Each: `"name=cmd args..."` or `"cmd args..."`. */
|
||||
mcp?: string[];
|
||||
/** Global prefix — only honored when a single anonymous server is given. */
|
||||
mcpPrefix?: string;
|
||||
}
|
||||
|
||||
@@ -67,27 +67,33 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
|
||||
// Optional MCP setup — mirrors chat's flow. Must happen before loop
|
||||
// construction so the tools make it into the prefix.
|
||||
let mcp: McpClient | undefined;
|
||||
const mcpSpecs = opts.mcp ?? [];
|
||||
const clients: McpClient[] = [];
|
||||
let tools: ToolRegistry | undefined;
|
||||
if (opts.mcp) {
|
||||
const argv = shellSplit(opts.mcp);
|
||||
const [command, ...args] = argv;
|
||||
if (!command) {
|
||||
process.stderr.write("error: --mcp command is empty\n");
|
||||
process.exit(2);
|
||||
}
|
||||
mcp = new McpClient({ transport: new StdioTransport({ command, args }) });
|
||||
try {
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, { namePrefix: opts.mcpPrefix });
|
||||
tools = bridge.registry;
|
||||
process.stderr.write(
|
||||
`▸ MCP: ${bridge.registeredNames.length} tool(s) from ${argv.join(" ")}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(`MCP setup failed: ${(err as Error).message}\n`);
|
||||
await mcp.close();
|
||||
process.exit(1);
|
||||
if (mcpSpecs.length > 0) {
|
||||
tools = new ToolRegistry();
|
||||
for (const raw of mcpSpecs) {
|
||||
try {
|
||||
const spec = parseMcpSpec(raw);
|
||||
const prefix = spec.name
|
||||
? `${spec.name}_`
|
||||
: mcpSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
const mcp = new McpClient({
|
||||
transport: new StdioTransport({ command: spec.command, args: spec.args }),
|
||||
});
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, { registry: tools, namePrefix: prefix });
|
||||
process.stderr.write(
|
||||
`▸ MCP[${spec.name ?? "anon"}]: ${bridge.registeredNames.length} tool(s) from ${spec.command} ${spec.args.join(" ")}\n`,
|
||||
);
|
||||
clients.push(mcp);
|
||||
} catch (err) {
|
||||
process.stderr.write(`MCP setup failed for "${raw}": ${(err as Error).message}\n`);
|
||||
for (const c of clients) await c.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,5 +156,5 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
process.stdout.write(` → npx reasonix replay ${opts.transcript}\n`);
|
||||
}
|
||||
|
||||
await mcp?.close();
|
||||
for (const c of clients) await c.close();
|
||||
}
|
||||
+18
-8
@@ -38,10 +38,15 @@ program
|
||||
)
|
||||
.option("--no-session", "Disable session persistence for this run (ephemeral chat)")
|
||||
.option(
|
||||
"--mcp <command>",
|
||||
'Spawn an MCP server and bridge its tools. Shell-quoted: --mcp "npx -y @scope/server-x /path"',
|
||||
"--mcp <spec>",
|
||||
'MCP server spec; repeatable. Forms: "name=cmd args..." (namespaced, tools get `name_` prefix) or "cmd args..." (anonymous). Example: --mcp "fs=npx -y @scope/fs /tmp" --mcp "gh=npx -y @scope/gh"',
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--mcp-prefix <str>",
|
||||
"Global prefix applied to every MCP tool (only honored when no per-spec name is set; avoids collisions with a single anonymous server)",
|
||||
)
|
||||
.option("--mcp-prefix <str>", "Prefix prepended to every MCP tool name (avoid collisions)")
|
||||
.action(async (opts) => {
|
||||
// Default behavior: every chat is auto-saved to a session named 'default'
|
||||
// and auto-resumed next launch. Pass --no-session to opt out, or
|
||||
@@ -61,7 +66,7 @@ program
|
||||
harvest: !!opts.harvest,
|
||||
branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : undefined,
|
||||
session,
|
||||
mcp: opts.mcp,
|
||||
mcp: opts.mcp as string[],
|
||||
mcpPrefix: opts.mcpPrefix,
|
||||
});
|
||||
});
|
||||
@@ -82,10 +87,15 @@ program
|
||||
)
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path for replay/diff")
|
||||
.option(
|
||||
"--mcp <command>",
|
||||
'Spawn an MCP server and bridge its tools. Shell-quoted: --mcp "npx -y @scope/server-x /path"',
|
||||
"--mcp <spec>",
|
||||
'MCP server spec; repeatable. "name=cmd args..." or "cmd args...".',
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--mcp-prefix <str>",
|
||||
"Global prefix (only honored when no per-spec name is set; for a single anonymous server)",
|
||||
)
|
||||
.option("--mcp-prefix <str>", "Prefix prepended to every MCP tool name (avoid collisions)")
|
||||
.action(async (task: string, opts) => {
|
||||
await runCommand({
|
||||
task,
|
||||
@@ -94,7 +104,7 @@ program
|
||||
harvest: !!opts.harvest,
|
||||
branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : undefined,
|
||||
transcript: opts.transcript,
|
||||
mcp: opts.mcp,
|
||||
mcp: opts.mcp as string[],
|
||||
mcpPrefix: opts.mcpPrefix,
|
||||
});
|
||||
});
|
||||
|
||||
+17
-10
@@ -50,17 +50,23 @@ tests/mcp.test.ts — 10 tests, in-process fake transport, no child processes
|
||||
|
||||
## Usage (CLI)
|
||||
|
||||
Spawn an MCP server alongside chat / run, its tools become first-class:
|
||||
`--mcp` is repeatable — attach one or many MCP servers; their tools become
|
||||
first-class citizens of the loop.
|
||||
|
||||
```bash
|
||||
# Against the bundled demo server (no external install required):
|
||||
# Single server, anonymous (tools use native names):
|
||||
reasonix chat --mcp "node --import tsx examples/mcp-server-demo.ts"
|
||||
|
||||
# Against the official filesystem server:
|
||||
# Official filesystem server:
|
||||
reasonix chat --mcp "npx -y @modelcontextprotocol/server-filesystem /tmp/safe-dir"
|
||||
|
||||
# Optional name prefix when mixing multiple servers (future) or to
|
||||
# disambiguate:
|
||||
# Multiple servers, each namespaced. Syntax: "name=command args..."
|
||||
# Tools land in a shared registry as fs_read_file, demo_add, etc.
|
||||
reasonix chat \
|
||||
--mcp "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe" \
|
||||
--mcp "demo=node --import tsx examples/mcp-server-demo.ts"
|
||||
|
||||
# Global prefix (only honored when there's ONE anonymous server):
|
||||
reasonix chat \
|
||||
--mcp "npx -y @modelcontextprotocol/server-filesystem /tmp" \
|
||||
--mcp-prefix fs_
|
||||
@@ -70,11 +76,12 @@ reasonix run "list files in /tmp/safe-dir" \
|
||||
--mcp "npx -y @modelcontextprotocol/server-filesystem /tmp/safe-dir"
|
||||
```
|
||||
|
||||
`--mcp` accepts a shell-style string (spaces separate args; use quotes for
|
||||
paths containing spaces). The tools land in a ToolRegistry, get folded
|
||||
into the ImmutablePrefix for the model, and every call goes through
|
||||
Reasonix's Cache-First loop + tool-call repair (scavenge / flatten /
|
||||
storm) automatically.
|
||||
Each spec is shell-split (spaces separate args; use quotes for paths with
|
||||
spaces). Windows-friendly: backslashes pass through literally outside
|
||||
quotes, so `C:\path\to\dir` works. Tools get folded into the
|
||||
`ImmutablePrefix` for the model, and every call goes through Reasonix's
|
||||
Cache-First loop + tool-call repair (scavenge / flatten / storm)
|
||||
automatically.
|
||||
|
||||
## Usage (library)
|
||||
|
||||
|
||||
@@ -44,11 +44,10 @@ export function shellSplit(input: string): string[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\" && i + 1 < s.length) {
|
||||
cur += s[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Backslash escape ONLY applies inside double quotes (handled above).
|
||||
// Outside quotes, backslashes pass through literally — otherwise
|
||||
// Windows paths like `C:\path\to\exe` get mangled. POSIX users who
|
||||
// want to escape a space outside quotes can use single quotes instead.
|
||||
|
||||
if (ch === " " || ch === "\t") {
|
||||
if (cur.length > 0) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Parse the `--mcp` CLI argument into name + command + args.
|
||||
*
|
||||
* Two accepted forms:
|
||||
* "name=command args..." → namespaced (tools prefixed with `name_`)
|
||||
* "command args..." → anonymous (tools registered under their native names)
|
||||
*
|
||||
* The identifier regex before `=` is deliberately narrow
|
||||
* (`[a-zA-Z_][a-zA-Z0-9_]*`) so Windows drive letters ("C:\\...") and
|
||||
* other strings containing `=` or `:` don't accidentally trigger the
|
||||
* namespace branch. If a user ever wants their command to literally start
|
||||
* with `foo=...` as a bare command, they can wrap it in quotes inside the
|
||||
* shell command string.
|
||||
*/
|
||||
|
||||
import { shellSplit } from "./shell-split.js";
|
||||
|
||||
export interface McpSpec {
|
||||
/** Namespace prefix applied to each registered tool, or null if anonymous. */
|
||||
name: string | null;
|
||||
/** Argv[0]. */
|
||||
command: string;
|
||||
/** Remaining argv. */
|
||||
args: string[];
|
||||
}
|
||||
|
||||
const NAME_PREFIX = /^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/;
|
||||
|
||||
export function parseMcpSpec(input: string): McpSpec {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("empty MCP spec");
|
||||
}
|
||||
|
||||
const nameMatch = NAME_PREFIX.exec(trimmed);
|
||||
const name = nameMatch ? nameMatch[1]! : null;
|
||||
const body = nameMatch ? nameMatch[2]! : trimmed;
|
||||
|
||||
const argv = shellSplit(body);
|
||||
if (argv.length === 0) {
|
||||
throw new Error(`MCP spec has name but no command: ${input}`);
|
||||
}
|
||||
const [command, ...args] = argv;
|
||||
return { name, command: command!, args };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { McpClient } from "../src/mcp/client.js";
|
||||
import { bridgeMcpTools } from "../src/mcp/registry.js";
|
||||
import { StdioTransport } from "../src/mcp/stdio.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
// Spawning `tsx` directly needs a cross-platform approach. `node --import tsx`
|
||||
// works everywhere Node 20+ is installed (which is our engines target) and
|
||||
@@ -75,4 +76,32 @@ describe("MCP integration — real subprocess against bundled demo server", () =
|
||||
const out = await registry.dispatch("demo_add", JSON.stringify({ a: 100, b: 1 }));
|
||||
expect(out).toContain("101");
|
||||
}, 30_000);
|
||||
|
||||
it("bridges two MCP servers into a shared registry with different prefixes", async () => {
|
||||
// Two instances of the same demo server, namespaced `a_` and `b_`.
|
||||
// Proves the multi-server CLI wiring: both dispatches go through
|
||||
// their respective subprocesses without cross-talk.
|
||||
const tA = new StdioTransport({ command: NODE_CMD, args: DEMO_SERVER_ARGS, shell: false });
|
||||
const a = new McpClient({ transport: tA, requestTimeoutMs: 15_000 });
|
||||
const tB = new StdioTransport({ command: NODE_CMD, args: DEMO_SERVER_ARGS, shell: false });
|
||||
const b = new McpClient({ transport: tB, requestTimeoutMs: 15_000 });
|
||||
try {
|
||||
await a.initialize();
|
||||
await b.initialize();
|
||||
const shared = new ToolRegistry();
|
||||
const resA = await bridgeMcpTools(a, { registry: shared, namePrefix: "a_" });
|
||||
const resB = await bridgeMcpTools(b, { registry: shared, namePrefix: "b_" });
|
||||
expect(resA.registeredNames).toHaveLength(3);
|
||||
expect(resB.registeredNames).toHaveLength(3);
|
||||
expect(shared.size).toBe(6);
|
||||
|
||||
const outA = await shared.dispatch("a_add", JSON.stringify({ a: 10, b: 20 }));
|
||||
expect(outA).toContain("30");
|
||||
const outB = await shared.dispatch("b_add", JSON.stringify({ a: 1, b: 2 }));
|
||||
expect(outB).toContain("3");
|
||||
} finally {
|
||||
await a.close();
|
||||
await b.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -26,8 +26,10 @@ describe("shellSplit", () => {
|
||||
expect(shellSplit('cmd "a\\"b"')).toEqual(["cmd", 'a"b']);
|
||||
});
|
||||
|
||||
it("handles escaped spaces outside quotes", () => {
|
||||
expect(shellSplit("cmd foo\\ bar baz")).toEqual(["cmd", "foo bar", "baz"]);
|
||||
it("passes backslashes through literally OUTSIDE quotes (so Windows paths don't mangle)", () => {
|
||||
// Critical for `reasonix chat --mcp "... C:\\path\\to\\dir"`. Users
|
||||
// who want to escape a space outside quotes can quote the arg.
|
||||
expect(shellSplit("cmd C:\\path\\to\\file.exe")).toEqual(["cmd", "C:\\path\\to\\file.exe"]);
|
||||
});
|
||||
|
||||
it("throws on unterminated double quote", () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMcpSpec } from "../src/mcp/spec.js";
|
||||
|
||||
describe("parseMcpSpec", () => {
|
||||
it("parses a namespaced spec", () => {
|
||||
const spec = parseMcpSpec("fs=npx -y @scope/fs /tmp/dir");
|
||||
expect(spec.name).toBe("fs");
|
||||
expect(spec.command).toBe("npx");
|
||||
expect(spec.args).toEqual(["-y", "@scope/fs", "/tmp/dir"]);
|
||||
});
|
||||
|
||||
it("parses an anonymous spec (no name=)", () => {
|
||||
const spec = parseMcpSpec("npx -y @scope/fs /tmp/dir");
|
||||
expect(spec.name).toBeNull();
|
||||
expect(spec.command).toBe("npx");
|
||||
expect(spec.args).toEqual(["-y", "@scope/fs", "/tmp/dir"]);
|
||||
});
|
||||
|
||||
it("does NOT treat Windows drive letters as a namespace", () => {
|
||||
// `C:\...` matches the colon but NOT the identifier regex [a-zA-Z_]\w* =
|
||||
// So it stays anonymous with the whole path as command.
|
||||
const spec = parseMcpSpec("C:\\path\\to\\server.exe arg1");
|
||||
expect(spec.name).toBeNull();
|
||||
expect(spec.command).toBe("C:\\path\\to\\server.exe");
|
||||
expect(spec.args).toEqual(["arg1"]);
|
||||
});
|
||||
|
||||
it("handles quoted args in the body", () => {
|
||||
const spec = parseMcpSpec('myserver=cmd "path with spaces" --flag');
|
||||
expect(spec.name).toBe("myserver");
|
||||
expect(spec.command).toBe("cmd");
|
||||
expect(spec.args).toEqual(["path with spaces", "--flag"]);
|
||||
});
|
||||
|
||||
it("trims leading/trailing whitespace", () => {
|
||||
const spec = parseMcpSpec(" fs=npx pkg ");
|
||||
expect(spec.name).toBe("fs");
|
||||
expect(spec.command).toBe("npx");
|
||||
expect(spec.args).toEqual(["pkg"]);
|
||||
});
|
||||
|
||||
it("throws on empty input", () => {
|
||||
expect(() => parseMcpSpec("")).toThrow(/empty MCP spec/);
|
||||
expect(() => parseMcpSpec(" ")).toThrow(/empty MCP spec/);
|
||||
});
|
||||
|
||||
it("throws when name is given but no command follows", () => {
|
||||
expect(() => parseMcpSpec("fs=")).toThrow(/has name but no command/);
|
||||
expect(() => parseMcpSpec("fs= ")).toThrow(/has name but no command/);
|
||||
});
|
||||
|
||||
it("allows underscores and digits in the name (but not leading digit)", () => {
|
||||
expect(parseMcpSpec("my_fs2=cmd").name).toBe("my_fs2");
|
||||
// Leading digit → not a valid identifier → whole thing is command
|
||||
// (since `2fs` doesn't match identifier regex).
|
||||
const s = parseMcpSpec("2fs=cmd");
|
||||
expect(s.name).toBeNull();
|
||||
expect(s.command).toBe("2fs=cmd");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user