Skip to content

Commit e4b66e4

Browse files
ralyodioclaude
andauthored
mcp: register with qwen too, and stop calling a re-install a failure (#427)
`mcp install` already fanned out to Claude Code, but a re-run reported it as `claude ✗ failed (code 1)` — Claude Code exits 1 with "already exists in user config" — while opencode printed a green box for the same no-op. Read from a box where the server was already registered, that summary says moshcode cannot register with Claude Code, which is the wrong conclusion. - qwen joins MCP_ENGINES. Qwen Code is a Gemini CLI fork and kept the whole `mcp add` surface (-s/-t/-e/-H, "URL or command" positional), verified against its own --help, so it shares gemini's argv builder rather than getting a copy. Checked and left alone: kimi still has no `mcp` subcommand, openagents exposes an MCP server rather than registering one, deepseek-code exits silently, aider has none. - An engine that says the server is already there now reports `already registered` in grey instead of a red failure, and no longer makes the command exit 1. runCmd grew an opt-in `capture` that tees the child's output instead of inheriting it, so the classification reads the engine's own words; every other caller is untouched. - An unknown flag after a remote URL was swallowed: it landed in `args`, which every remote builder discards. `mcp install <url> --dry-run` — a flag mcp does not have — therefore wrote to five engines' configs and said nothing. It is now an error, before anything runs. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6270c33 commit e4b66e4

6 files changed

Lines changed: 230 additions & 16 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -991,10 +991,14 @@ moshcode mcp catalog # what we know how to run
991991
moshcode mcp add porkbun # expands to: npx -y @porkbunllc/mcp-server
992992
```
993993

994-
That registers it across every engine that supports MCP (claude, gemini, codex,
995-
opencode, privacycode) in one go. Kimi is skipped with a reason: it runs MCP
996-
servers but has no command to register one from a script — add those in-session
997-
with its own `/mcp-config`, or in `~/.kimi-code/mcp.json`.
994+
That registers it across every engine that supports MCP (claude, gemini, qwen,
995+
codex, opencode, privacycode) in one go. Kimi is skipped with a reason: it runs
996+
MCP servers but has no command to register one from a script — add those
997+
in-session with its own `/mcp-config`, or in `~/.kimi-code/mcp.json`.
998+
999+
Re-running an install is safe: an engine that already has the server reports
1000+
`already registered` rather than an error, so the summary only goes red when
1001+
something actually went wrong.
9981002

9991003
The catalog is a convenience, never a gate — an explicit command always wins, so
10001004
`moshcode mcp add porkbun -- node ./my-fork.js` runs your fork.

src/engines.mjs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,15 +405,37 @@ export function agentLaunchArgs(engine, args = []) {
405405
* Spawn an arbitrary command with stdio inherited (so its own progress/prompts
406406
* own the terminal). Resolves { ok, code, signal } on exit. Used by install +
407407
* upgrade to run engine installers/updaters.
408+
*
409+
* With `{ capture: true }` the child's stdout/stderr are piped and *echoed
410+
* through* rather than inherited, and the combined text comes back as `output`.
411+
* The terminal still sees exactly what it saw before — the tee exists so a
412+
* caller can read the engine's own words about *why* it exited non-zero, which
413+
* a bare exit code cannot tell apart (see `alreadyRegistered` in mcp.mjs).
414+
* Inherit stays the default: piping costs a couple of streams, and every other
415+
* caller runs installers whose output nobody needs to parse.
416+
*
417+
* stdin is inherited either way, so a child that prompts still reaches the user.
408418
*/
409-
export function runCmd(cmd, args = []) {
419+
export function runCmd(cmd, args = [], { capture = false } = {}) {
410420
return new Promise((resolve) => {
411421
let child;
412422
const spec = spawnSpec(cmd, args);
413-
try { child = spawn(spec.cmd, spec.args, { stdio: "inherit" }); }
423+
const stdio = capture ? ["inherit", "pipe", "pipe"] : "inherit";
424+
try { child = spawn(spec.cmd, spec.args, { stdio }); }
414425
catch (e) { resolve({ ok: false, error: e }); return; }
415-
child.on("error", (e) => resolve({ ok: false, error: e }));
416-
child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
426+
let output = "";
427+
if (capture) {
428+
for (const [stream, sink] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
429+
stream?.on("data", (chunk) => { output += chunk.toString(); sink.write(chunk); });
430+
}
431+
}
432+
child.on("error", (e) => resolve({ ok: false, error: e, output }));
433+
// "exit" fires as soon as the process is gone, which with pipes can leave
434+
// the last chunk still queued — the one line we are trying to read. "close"
435+
// waits for the streams too. With stdio inherited there are no streams, so
436+
// the two are the same moment and existing callers are unaffected; the
437+
// distinction is kept explicit so neither branch changes by accident.
438+
child.on(capture ? "close" : "exit", (code, signal) => resolve({ ok: true, code, signal, output }));
417439
});
418440
}
419441

src/integrations.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@ export function parseMcp(tokens) {
105105
};
106106
}
107107

108+
// A remote server is a URL and nothing else — every engine's builder pushes
109+
// the target alone and discards `args`. So a leftover token here is not a
110+
// command line, it is something the user typed that this command will silently
111+
// throw away. `mcp install <url> --dry-run` is the case that matters: the flag
112+
// does not exist, it lands here, and the install goes ahead and writes to
113+
// every engine's config — the exact opposite of what the person typing it
114+
// expected. Say so instead of dropping it on the floor.
115+
if (!cmdParts && target && isRemoteTarget(target) && args.length) {
116+
const extra = args[0];
117+
return {
118+
error: extra.startsWith("-")
119+
? `unknown mcp flag "${extra}" — mcp takes --name, -t/--transport, -e/--env, and -H/--header, and has no --dry-run`
120+
: `unexpected argument "${extra}" after a remote server URL — a URL server takes no command arguments`,
121+
};
122+
}
123+
108124
if (verb === "install" && !name) {
109125
if (target && isRemoteTarget(target)) name = deriveName(target);
110126
else return { error: "a stdio command server needs an explicit --name" };
@@ -185,6 +201,9 @@ export function printSkillTargets(json = false) {
185201
function summarize(results) {
186202
for (const r of results) {
187203
if (r.status === "added" || r.status === "installed" || r.status === "removed") console.log(line(r.key, ok(r.status)));
204+
// Nothing to do and nothing wrong: grey, like the other "we didn't act"
205+
// rows, rather than the green of a change we actually made.
206+
else if (r.status === "already") console.log(line(r.key, ash("already registered")));
188207
else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}`)));
189208
else if (r.status === "not-installed") console.log(line(r.key, ash("not installed — /install " + r.key)));
190209
else console.log(line(r.key, ash(`skipped — ${r.reason}`)));

src/mcp.mjs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
55
import { isIP } from "node:net";
66

77
// Coding engines that can register MCP servers. Aider has no MCP support.
8-
export const MCP_ENGINES = ["claude", "gemini", "codex", "opencode", "privacycode"];
8+
export const MCP_ENGINES = ["claude", "gemini", "qwen", "codex", "opencode", "privacycode"];
99

1010
/** Is this target a remote server URL (vs a local stdio command)? */
1111
export function isRemoteTarget(target) {
@@ -71,7 +71,11 @@ export function mcpAddArgs(key, spec) {
7171
else argv.push("--", target, ...args);
7272
return { argv };
7373
}
74-
case "gemini": {
74+
// Qwen Code is a Gemini CLI fork and kept the whole `mcp add` surface —
75+
// same `-s/-t/-e/-H` flags, same "URL or command" positional. It shares the
76+
// builder rather than getting a copy, so the two can only drift on purpose.
77+
case "gemini":
78+
case "qwen": {
7579
const argv = ["mcp", "add", "-s", "user"];
7680
if (remote) argv.push("-t", transport);
7781
for (const [k, v] of env) argv.push("-e", `${k}=${v}`);
@@ -138,18 +142,39 @@ export function planMcpAdd(spec, { installedSet } = {}) {
138142
});
139143
}
140144

145+
/**
146+
* Did this engine exit non-zero only because the server was already there?
147+
*
148+
* Registering the same server twice is the normal way to re-run `mcp install`,
149+
* and it is not a failure — but Claude Code and Gemini/Qwen exit 1 on it, so the
150+
* fan-out summary painted `claude ✗ failed (code 1)` next to opencode's cheerful
151+
* green box. Read from a box where four engines already had the server, that
152+
* says "moshcode cannot register with Claude Code" — which is exactly the wrong
153+
* conclusion, and the reason this function exists rather than a nicer exit code.
154+
*
155+
* Matched against the engine's own words, so it stays honest: an engine that
156+
* fails for any *other* reason still comes back failed.
157+
*/
158+
const ALREADY_RE = /already (?:exists|configured|registered|added)|exists in (?:user|global|project) config/i;
159+
export function alreadyRegistered(r) {
160+
return ALREADY_RE.test(String(r?.output ?? ""));
161+
}
162+
141163
/**
142164
* Execute a plan: run each installed, non-skipped engine's `mcp add`. Returns
143-
* results [{ key, status: "added"|"skipped"|"failed"|"not-installed", reason? }].
165+
* results [{ key, status: "added"|"already"|"skipped"|"failed"|"not-installed", reason? }].
144166
* `run` is injectable for tests; defaults to the real spawner.
145167
*/
146168
export async function runMcpAdd(plan, { run = runCmd } = {}) {
147169
const results = [];
148170
for (const item of plan) {
149171
if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
150172
if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
151-
const r = await run(item.bin, item.argv);
152-
results.push({ key: item.key, status: ranOk(r) ? "added" : "failed", code: r.code, signal: r.signal ?? null });
173+
// capture so a non-zero exit can be read for "already exists" rather than
174+
// reported as a failure; the child's output still reaches the terminal.
175+
const r = await run(item.bin, item.argv, { capture: true });
176+
const status = ranOk(r) ? "added" : alreadyRegistered(r) ? "already" : "failed";
177+
results.push({ key: item.key, status, code: r.code, signal: r.signal ?? null });
153178
}
154179
return results;
155180
}

test/mcp-add-fanout.test.mjs

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import assert from "node:assert/strict";
77
import test from "node:test";
88

99
import { ENGINES } from "../src/engines.mjs";
10-
import { MCP_ENGINES, mcpAddArgs, planMcpAdd, runMcpAdd } from "../src/mcp.mjs";
10+
import { MCP_ENGINES, alreadyRegistered, mcpAddArgs, planMcpAdd, runMcpAdd } from "../src/mcp.mjs";
1111
import { mcpTargetStatus } from "../src/integrations.mjs";
1212

1313
const REMOTE = { name: "sentry", target: "https://mcp.sentry.dev/mcp", args: [], env: [], headers: [] };
@@ -77,8 +77,13 @@ test("the fan-out and the /mcp list matrix name the same engines", () => {
7777

7878
// --- controls: the fix must not over-report ----------------------------------
7979

80-
test("MCP_ENGINES is unchanged — no engine gained MCP support", () => {
81-
assert.deepEqual(MCP_ENGINES, ["claude", "gemini", "codex", "opencode", "privacycode"]);
80+
test("MCP_ENGINES is the reviewed list — an engine only joins on purpose", () => {
81+
// qwen joined deliberately: Qwen Code is a Gemini CLI fork and shipped the
82+
// whole `qwen mcp add` surface, verified against its own --help. Everything
83+
// else here is unchanged. This stays a pinned list rather than something
84+
// derived from ENGINES, because "can moshcode register a server here" is a
85+
// claim someone has to check against a real CLI, not infer from a roster.
86+
assert.deepEqual(MCP_ENGINES, ["claude", "gemini", "qwen", "codex", "opencode", "privacycode"]);
8287
});
8388

8489
test("kimi is skipped for the reason that actually applies to it", () => {
@@ -108,6 +113,29 @@ test("gemini's argv is byte-identical", () => {
108113
assert.deepEqual(plan.gemini.argv, ["mcp", "add", "-s", "user", "-t", "http", "sentry", "https://mcp.sentry.dev/mcp"]);
109114
});
110115

116+
test("qwen's argv matches gemini's — it is the same CLI surface", () => {
117+
const plan = byKey(planMcpAdd(REMOTE, { installedSet: new Set() }));
118+
assert.deepEqual(plan.qwen.argv, ["mcp", "add", "-s", "user", "-t", "http", "sentry", "https://mcp.sentry.dev/mcp"]);
119+
assert.deepEqual(plan.qwen.argv, plan.gemini.argv);
120+
assert.equal(plan.qwen.skip, undefined, "qwen registers servers; it must not be skipped");
121+
});
122+
123+
test("qwen carries env and headers through, like gemini", () => {
124+
const spec = { ...REMOTE, env: [["TOKEN", "z"]], headers: ["X-Api-Key: abc"] };
125+
const plan = byKey(planMcpAdd(spec, { installedSet: new Set() }));
126+
assert.deepEqual(plan.qwen.argv, [
127+
"mcp", "add", "-s", "user", "-t", "http",
128+
"-e", "TOKEN=z", "-H", "X-Api-Key: abc",
129+
"sentry", "https://mcp.sentry.dev/mcp",
130+
]);
131+
});
132+
133+
test("qwen takes a stdio command server too", () => {
134+
const stdio = { name: "my-tools", target: "npx", args: ["-y", "my-mcp-server"], env: [], headers: [] };
135+
const plan = byKey(planMcpAdd(stdio, { installedSet: new Set() }));
136+
assert.deepEqual(plan.qwen.argv, ["mcp", "add", "-s", "user", "my-tools", "npx", "-y", "my-mcp-server"]);
137+
});
138+
111139
test("codex's and opencode's argv are byte-identical", () => {
112140
const plan = byKey(planMcpAdd(REMOTE, { installedSet: new Set() }));
113141
assert.deepEqual(plan.codex.argv, ["mcp", "add", "sentry", "--url", "https://mcp.sentry.dev/mcp"]);
@@ -159,6 +187,59 @@ test("a signal or non-zero exit still reports failed, not skipped", async () =>
159187
assert.equal(results.gemini.status, "failed");
160188
});
161189

190+
// --- re-running an install is not a failure ----------------------------------
191+
192+
test("an engine that says the server already exists reports `already`, not failed", async () => {
193+
// Claude Code exits 1 with "MCP server X already exists in user config". Read
194+
// as a failure, that row said moshcode could not register with Claude Code —
195+
// when in fact it already had.
196+
const plan = planMcpAdd(REMOTE, { installedSet: new Set(["claude"]) });
197+
const results = byKey(await runMcpAdd(plan, {
198+
run: async () => ({ ok: true, code: 1, output: "MCP server sentry already exists in user config\n" }),
199+
}));
200+
assert.equal(results.claude.status, "already");
201+
});
202+
203+
test("the same wording is recognised from each engine that uses it", () => {
204+
for (const words of [
205+
"MCP server sentry already exists in user config", // claude
206+
"Server \"sentry\" is already configured", // gemini / qwen
207+
"server already registered",
208+
"sentry already added",
209+
]) {
210+
assert.equal(alreadyRegistered({ code: 1, output: words }), true, words);
211+
}
212+
});
213+
214+
test("a failure for any other reason is still a failure", async () => {
215+
const plan = planMcpAdd(REMOTE, { installedSet: new Set(["claude"]) });
216+
const results = byKey(await runMcpAdd(plan, {
217+
run: async () => ({ ok: true, code: 1, output: "error: connection refused\n" }),
218+
}));
219+
assert.equal(results.claude.status, "failed");
220+
assert.equal(results.claude.code, 1);
221+
// and no output at all must never be read as "already there"
222+
assert.equal(alreadyRegistered({ code: 1 }), false);
223+
assert.equal(alreadyRegistered({ code: 1, output: "" }), false);
224+
});
225+
226+
test("a zero exit is `added` even if the word `already` appears in the noise", async () => {
227+
const plan = planMcpAdd(REMOTE, { installedSet: new Set(["claude"]) });
228+
const results = byKey(await runMcpAdd(plan, {
229+
run: async () => ({ ok: true, code: 0, output: "note: sentry already exists upstream\n" }),
230+
}));
231+
assert.equal(results.claude.status, "added", "a successful add is an add");
232+
});
233+
234+
test("runMcpAdd asks for captured output — it cannot classify what it cannot read", async () => {
235+
const seen = [];
236+
const plan = planMcpAdd(REMOTE, { installedSet: new Set(["claude"]) });
237+
await runMcpAdd(plan, {
238+
run: async (bin, argv, opts) => { seen.push(opts); return { ok: true, code: 0 }; },
239+
});
240+
assert.deepEqual(seen, [{ capture: true }]);
241+
});
242+
162243
test("mcpAddArgs itself is untouched for a supported and an unsupported key", () => {
163244
assert.equal(mcpAddArgs("aider", REMOTE).skip, "no MCP support");
164245
assert.deepEqual(mcpAddArgs("codex", REMOTE).argv, ["mcp", "add", "sentry", "--url", "https://mcp.sentry.dev/mcp"]);

test/mcp-stray-flag.test.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,66 @@ test("an unknown verb still reports an unknown verb, not an unknown flag", () =>
120120
test("a stdio install with no name still asks for --name", () => {
121121
assert.match(parseMcp(["install", "--", "npx", "srv"]).error, /explicit --name/);
122122
});
123+
124+
// ---------- flags AFTER a remote URL were swallowed too ----------
125+
//
126+
// The guard above only inspected the server name and the target. A flag typed
127+
// *after* a remote URL landed in `args`, which every engine's remote builder
128+
// discards — so it vanished with no error and the install went ahead. The case
129+
// that bites is `--dry-run`, a flag mcp does not have: someone expecting a
130+
// preview got five engines' config files written instead.
131+
132+
test("an unknown flag after a remote URL is rejected, not silently dropped", () => {
133+
const { error, spec } = parseMcp(["install", "https://mcp.example.com", "--dry-run"]);
134+
assert.equal(spec, undefined, "the install must not proceed");
135+
assert.match(error, /unknown mcp flag "--dry-run"/);
136+
assert.match(error, /no --dry-run/);
137+
});
138+
139+
test("the first stray flag is the one named, whichever follows it", () => {
140+
const { error } = parseMcp(["install", "https://mcp.example.com", "--wat", "--dry-run"]);
141+
assert.match(error, /unknown mcp flag "--wat"/);
142+
});
143+
144+
test("a stray non-flag argument after a remote URL is reported as unexpected", () => {
145+
const { error, spec } = parseMcp(["install", "https://mcp.example.com", "extra"]);
146+
assert.equal(spec, undefined);
147+
assert.match(error, /unexpected argument "extra"/);
148+
});
149+
150+
test("the same guard applies to `mcp add`, not just `install`", () => {
151+
const { error } = parseMcp(["add", "sentry", "https://mcp.sentry.dev/mcp", "--dry-run"]);
152+
assert.match(error, /unknown mcp flag "--dry-run"/);
153+
});
154+
155+
test("a legitimate remote install is unaffected", () => {
156+
const { error, spec } = parseMcp(["install", "https://mcp.sentry.dev/mcp"]);
157+
assert.equal(error, undefined);
158+
assert.equal(spec.name, "sentry");
159+
assert.deepEqual(spec.args, []);
160+
});
161+
162+
test("supported flags around a remote URL still parse", () => {
163+
const { error, spec } = parseMcp([
164+
"install", "https://mcp.example.com", "-H", "Authorization: Bearer z", "-e", "TOKEN=z", "--name", "mine",
165+
]);
166+
assert.equal(error, undefined);
167+
assert.equal(spec.name, "mine");
168+
assert.deepEqual(spec.headers, ["Authorization: Bearer z"]);
169+
assert.deepEqual(spec.env, [["TOKEN", "z"]]);
170+
});
171+
172+
test("a stdio command's own flags after `--` are still not second-guessed", () => {
173+
const { error, spec } = parseMcp(["add", "mine", "--", "npx", "-y", "my-mcp-server", "--dry-run"]);
174+
assert.equal(error, undefined);
175+
assert.equal(spec.target, "npx");
176+
assert.deepEqual(spec.args, ["-y", "my-mcp-server", "--dry-run"]);
177+
});
178+
179+
test("a stdio server given without `--` keeps its arguments", () => {
180+
// Not a remote target, so the discard guard must not fire: these args really
181+
// are spliced into the engine's argv and do reach the command.
182+
const { error, spec } = parseMcp(["add", "mine", "npx", "my-mcp-server"]);
183+
assert.equal(error, undefined);
184+
assert.deepEqual(spec.args, ["my-mcp-server"]);
185+
});

0 commit comments

Comments
 (0)