@yagni-app/code 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -6
- package/dist/claudePlugins.d.ts +3 -1
- package/dist/claudePlugins.js +3 -1
- package/dist/cli.js +12 -0
- package/dist/doctor.d.ts +28 -3
- package/dist/doctor.js +117 -7
- package/dist/extension/index.d.ts +5 -5
- package/dist/extension/index.js +89 -29
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +131 -0
- package/dist/extension/mcp/config.js +309 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +98 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +34 -0
- package/dist/extension/mcp/panel.js +258 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +150 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +7 -0
- package/dist/extension/permission/gate.js +12 -5
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/pipeline/personas.js +5 -0
- package/dist/mcpCommand.d.ts +113 -0
- package/dist/mcpCommand.js +755 -0
- package/dist/otel.d.ts +36 -7
- package/dist/otel.js +90 -12
- package/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +3 -2
- package/dist/extension/mcpTools.d.ts +0 -57
- package/dist/extension/mcpTools.js +0 -132
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni mcp …` — configure MCP servers for YAGNI Code, mirroring Claude
|
|
3
|
+
* Code's CLI surface (add / add-json / remove / get / list /
|
|
4
|
+
* reset-project-choices / add-from-claude).
|
|
5
|
+
*
|
|
6
|
+
* The config module itself lives in pi-extension-yagni (`src/mcp/config.ts`)
|
|
7
|
+
* so the extension session and this CLI read and write the exact same files
|
|
8
|
+
* through the exact same code — loaded here by file path, the same pattern as
|
|
9
|
+
* the session-worktree and headless-go entries. `list` and `get` run real
|
|
10
|
+
* health-check connections (stdio servers are spawned), so they carry the
|
|
11
|
+
* same caution Claude Code prints: only run in directories you trust.
|
|
12
|
+
*
|
|
13
|
+
* Not implemented (deliberate deviations from Claude Code):
|
|
14
|
+
* serve — the YAGNI backend already exposes an MCP endpoint
|
|
15
|
+
* login — enterprise-IdP only in Claude Code; not applicable
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { DISTRIBUTION } from "./distribution.js";
|
|
22
|
+
export function resolveMcpConfigPath() {
|
|
23
|
+
const bundled = fileURLToPath(new URL("./extension/mcp/cliConfig.js", import.meta.url));
|
|
24
|
+
if (existsSync(bundled))
|
|
25
|
+
return bundled;
|
|
26
|
+
return fileURLToPath(new URL("../../pi-extension-yagni/dist/mcp/cliConfig.js", import.meta.url));
|
|
27
|
+
}
|
|
28
|
+
async function defaultLoadMcpModule() {
|
|
29
|
+
const mod = (await import(resolveMcpConfigPath()));
|
|
30
|
+
if (typeof mod?.loadMcpServers !== "function") {
|
|
31
|
+
throw new Error("The bundled extension is missing its MCP config entry point (is the CLI up to date?).");
|
|
32
|
+
}
|
|
33
|
+
return mod;
|
|
34
|
+
}
|
|
35
|
+
const USAGE = `Usage:
|
|
36
|
+
${DISTRIBUTION.commandName} mcp add <name> <commandOrUrl> [args...] [options]
|
|
37
|
+
${DISTRIBUTION.commandName} mcp add-json <name> <json> [-s scope]
|
|
38
|
+
${DISTRIBUTION.commandName} mcp remove <name> [-s scope]
|
|
39
|
+
${DISTRIBUTION.commandName} mcp list
|
|
40
|
+
${DISTRIBUTION.commandName} mcp get <name>
|
|
41
|
+
${DISTRIBUTION.commandName} mcp reset-project-choices
|
|
42
|
+
${DISTRIBUTION.commandName} mcp add-from-claude [-s scope]
|
|
43
|
+
|
|
44
|
+
Options (add/add-json):
|
|
45
|
+
-s, --scope <scope> local | user | project (default: local)
|
|
46
|
+
-t, --transport <transport> stdio | sse | http (default: stdio)
|
|
47
|
+
-e, --env <KEY=VALUE...> environment variables for stdio servers
|
|
48
|
+
-H, --header <HEADER...> headers for http/sse servers
|
|
49
|
+
|
|
50
|
+
Scopes:
|
|
51
|
+
local private to you, this project (~/.yagni-code/mcp.json)
|
|
52
|
+
user available in all your projects (~/.yagni-code/mcp.json)
|
|
53
|
+
project shared via .mcp.json at the repo root (approval-gated)
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
${DISTRIBUTION.commandName} mcp add --transport http sentry https://mcp.sentry.dev/mcp
|
|
57
|
+
${DISTRIBUTION.commandName} mcp add -e API_KEY=xxx my-server -- npx my-mcp-server
|
|
58
|
+
`;
|
|
59
|
+
const ENV_PAIR = /^[^=\s]+=.+$/;
|
|
60
|
+
const HEADER_PAIR = /^[^:\s]+:.+$/;
|
|
61
|
+
export function parseMcpArgs(argv) {
|
|
62
|
+
const [subcommand, ...after] = argv;
|
|
63
|
+
const rest = [];
|
|
64
|
+
let scope = "local";
|
|
65
|
+
let scopeExplicit = false;
|
|
66
|
+
let transport = "stdio";
|
|
67
|
+
let transportExplicit = false;
|
|
68
|
+
const env = {};
|
|
69
|
+
const headers = {};
|
|
70
|
+
let clientId;
|
|
71
|
+
let clientSecret = false;
|
|
72
|
+
let callbackPort;
|
|
73
|
+
let flag = null;
|
|
74
|
+
let flagSawValue = false;
|
|
75
|
+
const addEnv = (pair) => {
|
|
76
|
+
const eq = pair.indexOf("=");
|
|
77
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
78
|
+
};
|
|
79
|
+
const addHeader = (pair) => {
|
|
80
|
+
const colon = pair.indexOf(":");
|
|
81
|
+
headers[pair.slice(0, colon).trim()] = pair.slice(colon + 1).trim();
|
|
82
|
+
};
|
|
83
|
+
for (let i = 0; i < after.length; i++) {
|
|
84
|
+
const arg = after[i];
|
|
85
|
+
if (arg === "--") {
|
|
86
|
+
// Everything after -- is a raw stdio argument, not ours to parse.
|
|
87
|
+
rest.push(...after.slice(i + 1));
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (flag === "s" || flag === "t" || flag === "client-id" || flag === "callback-port") {
|
|
91
|
+
if (flag === "s") {
|
|
92
|
+
scope = scopeFrom(arg);
|
|
93
|
+
scopeExplicit = true;
|
|
94
|
+
}
|
|
95
|
+
else if (flag === "t") {
|
|
96
|
+
transport = transportFrom(arg);
|
|
97
|
+
transportExplicit = true;
|
|
98
|
+
}
|
|
99
|
+
else if (flag === "client-id") {
|
|
100
|
+
clientId = arg;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const port = Number(arg);
|
|
104
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
105
|
+
throw new Error(`Invalid --callback-port ${arg} — expected a positive integer.`);
|
|
106
|
+
}
|
|
107
|
+
callbackPort = port;
|
|
108
|
+
}
|
|
109
|
+
flag = null;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// -e/-H are variadic with a shape gate: consume following tokens only
|
|
113
|
+
// while they look like KEY=VALUE / "Header: value" pairs, so both the
|
|
114
|
+
// `-e A=1 B=2 -- run` and the `-e KEY=val my-server -- npx` forms work.
|
|
115
|
+
if (flag === "e") {
|
|
116
|
+
if (!flagSawValue && !ENV_PAIR.test(arg)) {
|
|
117
|
+
throw new Error(`Invalid --env value "${arg}" — expected KEY=VALUE.`);
|
|
118
|
+
}
|
|
119
|
+
if (ENV_PAIR.test(arg)) {
|
|
120
|
+
addEnv(arg);
|
|
121
|
+
flagSawValue = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
flag = null; // falls through: this token is a positional
|
|
125
|
+
}
|
|
126
|
+
else if (flag === "H") {
|
|
127
|
+
if (!flagSawValue && !HEADER_PAIR.test(arg)) {
|
|
128
|
+
throw new Error(`Invalid --header value "${arg}" — expected "Header-Name: value".`);
|
|
129
|
+
}
|
|
130
|
+
if (HEADER_PAIR.test(arg)) {
|
|
131
|
+
addHeader(arg);
|
|
132
|
+
flagSawValue = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
flag = null;
|
|
136
|
+
}
|
|
137
|
+
if (arg === "-s" || arg === "--scope") {
|
|
138
|
+
flag = "s";
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (arg.startsWith("--scope=")) {
|
|
142
|
+
scope = scopeFrom(arg.slice("--scope=".length));
|
|
143
|
+
scopeExplicit = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (arg === "-t" || arg === "--transport") {
|
|
147
|
+
flag = "t";
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (arg.startsWith("--transport=")) {
|
|
151
|
+
transport = transportFrom(arg.slice("--transport=".length));
|
|
152
|
+
transportExplicit = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (arg === "-e" || arg === "--env") {
|
|
156
|
+
flag = "e";
|
|
157
|
+
flagSawValue = false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (arg.startsWith("--env=")) {
|
|
161
|
+
const pair = arg.slice("--env=".length);
|
|
162
|
+
if (!ENV_PAIR.test(pair))
|
|
163
|
+
throw new Error(`Invalid --env value "${pair}" — expected KEY=VALUE.`);
|
|
164
|
+
addEnv(pair);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (arg === "-H" || arg === "--header") {
|
|
168
|
+
flag = "H";
|
|
169
|
+
flagSawValue = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (arg.startsWith("--header=")) {
|
|
173
|
+
const pair = arg.slice("--header=".length);
|
|
174
|
+
if (!HEADER_PAIR.test(pair))
|
|
175
|
+
throw new Error(`Invalid --header value "${pair}" — expected "Header-Name: value".`);
|
|
176
|
+
addHeader(pair);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (arg === "--client-secret") {
|
|
180
|
+
clientSecret = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (arg === "--client-id") {
|
|
184
|
+
flag = "client-id";
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (arg.startsWith("--client-id=")) {
|
|
188
|
+
clientId = arg.slice("--client-id=".length);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (arg === "--callback-port") {
|
|
192
|
+
flag = "callback-port";
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (arg.startsWith("--callback-port=")) {
|
|
196
|
+
const port = Number(arg.slice("--callback-port=".length));
|
|
197
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
198
|
+
throw new Error(`Invalid --callback-port — expected a positive integer.`);
|
|
199
|
+
callbackPort = port;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
rest.push(arg);
|
|
203
|
+
}
|
|
204
|
+
if (flag === "s" || flag === "t" || flag === "client-id" || flag === "callback-port") {
|
|
205
|
+
throw new Error("Missing flag value.");
|
|
206
|
+
}
|
|
207
|
+
const [name, ...commandArgs] = rest;
|
|
208
|
+
return { subcommand, name, rest, scope, scopeExplicit, transport, transportExplicit, env, headers, commandArgs, clientId, clientSecret, callbackPort };
|
|
209
|
+
}
|
|
210
|
+
function scopeFrom(value) {
|
|
211
|
+
if (value === "local" || value === "user" || value === "project")
|
|
212
|
+
return value;
|
|
213
|
+
throw new Error(`Invalid scope "${value}" — expected local, user, or project.`);
|
|
214
|
+
}
|
|
215
|
+
function transportFrom(value) {
|
|
216
|
+
if (value === "stdio" || value === "sse" || value === "http")
|
|
217
|
+
return value;
|
|
218
|
+
throw new Error(`Invalid transport "${value}" — expected stdio, sse, or http.`);
|
|
219
|
+
}
|
|
220
|
+
function looksLikeUrl(value) {
|
|
221
|
+
return (value.startsWith("http://") ||
|
|
222
|
+
value.startsWith("https://") ||
|
|
223
|
+
value.startsWith("localhost") ||
|
|
224
|
+
value.endsWith("/sse") ||
|
|
225
|
+
value.endsWith("/mcp"));
|
|
226
|
+
}
|
|
227
|
+
function describeScopePath(scope, cwd) {
|
|
228
|
+
if (scope === "project")
|
|
229
|
+
return join(cwd, ".mcp.json");
|
|
230
|
+
return "~/.yagni-code/mcp.json";
|
|
231
|
+
}
|
|
232
|
+
export async function mcpCommand(args, deps = {}) {
|
|
233
|
+
const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
|
|
234
|
+
const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
|
|
235
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
236
|
+
let mod;
|
|
237
|
+
try {
|
|
238
|
+
mod = await (deps.loadMcpModule ?? defaultLoadMcpModule)();
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
stderr(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
let parsed;
|
|
245
|
+
try {
|
|
246
|
+
parsed = parseMcpArgs(args);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
stderr(`${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
switch (parsed.subcommand) {
|
|
253
|
+
case undefined:
|
|
254
|
+
case "help":
|
|
255
|
+
stdout(USAGE);
|
|
256
|
+
return 0;
|
|
257
|
+
case "add":
|
|
258
|
+
return mcpAdd(mod, parsed, { cwd, stdout, stderr });
|
|
259
|
+
case "add-json":
|
|
260
|
+
return mcpAddJson(mod, parsed, { cwd, stdout, stderr });
|
|
261
|
+
case "remove":
|
|
262
|
+
return mcpRemove(mod, parsed, { cwd, stdout, stderr });
|
|
263
|
+
case "list":
|
|
264
|
+
return mcpList(mod, { cwd, stdout, stderr, env: deps.env ?? process.env, probeServer: deps.probeServer });
|
|
265
|
+
case "get":
|
|
266
|
+
return mcpGet(mod, parsed, { cwd, stdout, stderr, env: deps.env ?? process.env, probeServer: deps.probeServer });
|
|
267
|
+
case "reset-project-choices":
|
|
268
|
+
return mcpResetChoices(mod, { cwd, stdout, stderr });
|
|
269
|
+
case "add-from-claude":
|
|
270
|
+
return mcpAddFromClaude(mod, parsed, { cwd, stdout, stderr, home: deps.home ?? homedir() });
|
|
271
|
+
default:
|
|
272
|
+
stderr(`Unknown mcp subcommand "${parsed.subcommand}".\n${USAGE}`);
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function mcpAdd(mod, parsed, io) {
|
|
277
|
+
const { name, rest } = parsed;
|
|
278
|
+
const commandOrUrl = rest[1];
|
|
279
|
+
const stdioArgs = rest.slice(2);
|
|
280
|
+
if (!name || !commandOrUrl) {
|
|
281
|
+
io.stderr(`Error: name and command/URL are required.\nUsage: ${DISTRIBUTION.commandName} mcp add <name> <commandOrUrl> [args...]\n`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
let serverConfig;
|
|
285
|
+
if (parsed.transport === "http" || parsed.transport === "sse") {
|
|
286
|
+
serverConfig = {
|
|
287
|
+
type: parsed.transport,
|
|
288
|
+
url: commandOrUrl,
|
|
289
|
+
...(Object.keys(parsed.headers).length > 0 ? { headers: parsed.headers } : {}),
|
|
290
|
+
...(oauthBlock(parsed) ? { oauth: oauthBlock(parsed) } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
if (parsed.clientId || parsed.clientSecret || parsed.callbackPort) {
|
|
295
|
+
io.stderr(`Warning: --client-id, --client-secret, and --callback-port are only supported for HTTP/SSE transports and will be ignored for stdio.\n`);
|
|
296
|
+
}
|
|
297
|
+
if (!parsed.transportExplicit && looksLikeUrl(commandOrUrl)) {
|
|
298
|
+
io.stderr(`Warning: "${commandOrUrl}" looks like a URL but is being interpreted as a stdio command (--transport not specified).\n` +
|
|
299
|
+
`If this is an HTTP server: ${DISTRIBUTION.commandName} mcp add --transport http ${name} ${commandOrUrl}\n` +
|
|
300
|
+
`If this is an SSE server: ${DISTRIBUTION.commandName} mcp add --transport sse ${name} ${commandOrUrl}\n`);
|
|
301
|
+
}
|
|
302
|
+
serverConfig = {
|
|
303
|
+
type: "stdio",
|
|
304
|
+
command: commandOrUrl,
|
|
305
|
+
...(stdioArgs.length > 0 ? { args: stdioArgs } : {}),
|
|
306
|
+
...(Object.keys(parsed.env).length > 0 ? { env: parsed.env } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return persistAdd(mod, name, serverConfig, parsed, io);
|
|
310
|
+
}
|
|
311
|
+
function mcpAddJson(mod, parsed, io) {
|
|
312
|
+
const { name } = parsed;
|
|
313
|
+
const json = parsed.rest[1];
|
|
314
|
+
if (!name || !json) {
|
|
315
|
+
io.stderr(`Error: name and JSON are required.\nUsage: ${DISTRIBUTION.commandName} mcp add-json <name> '<json>'\n`);
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
let parsedJson;
|
|
319
|
+
try {
|
|
320
|
+
parsedJson = JSON.parse(json);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
io.stderr(`Error: invalid JSON (${err instanceof Error ? err.message : String(err)})\n`);
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
const type = typeof parsedJson["type"] === "string" ? parsedJson["type"] : "stdio";
|
|
327
|
+
io.stdout(`Adding ${type} MCP server ${name} to ${parsed.scope} config\n`);
|
|
328
|
+
return persistAdd(mod, name, parsedJson, parsed, io);
|
|
329
|
+
}
|
|
330
|
+
function persistAdd(mod, name, serverConfig, parsed, io) {
|
|
331
|
+
const validation = validateConfigShape(serverConfig);
|
|
332
|
+
if (!validation.ok) {
|
|
333
|
+
io.stderr(`Error: ${validation.message}\n`);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
writeServerToScope(mod, name, serverConfig, parsed.scope, io.cwd);
|
|
338
|
+
if (parsed.clientSecret) {
|
|
339
|
+
const secret = readClientSecret(io);
|
|
340
|
+
if (secret) {
|
|
341
|
+
mod.updateStoredOAuthEntry(name, serverConfig, (entry) => {
|
|
342
|
+
entry.clientSecret = secret;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
io.stderr(`Warning: no client secret supplied — it will be prompted at connect time if the server requires one.\n`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
io.stdout(`Added ${serverConfig["type"] ?? "stdio"} MCP server ${name} to ${parsed.scope} config\n`);
|
|
350
|
+
io.stdout(`File modified: ${describeScopePath(parsed.scope, io.cwd)}\n`);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
io.stderr(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function oauthBlock(parsed) {
|
|
359
|
+
if (!parsed.clientId && !parsed.callbackPort)
|
|
360
|
+
return undefined;
|
|
361
|
+
return {
|
|
362
|
+
...(parsed.clientId ? { clientId: parsed.clientId } : {}),
|
|
363
|
+
...(parsed.callbackPort ? { callbackPort: parsed.callbackPort } : {}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function readClientSecret(io) {
|
|
367
|
+
// Secret comes from MCP_CLIENT_SECRET env when present; otherwise from stdin
|
|
368
|
+
// (TTY prompt). The CLI has no interactive password input, so env is the
|
|
369
|
+
// documented path, mirroring Claude Code's readClientSecret.
|
|
370
|
+
const envSecret = process.env.MCP_CLIENT_SECRET;
|
|
371
|
+
if (envSecret)
|
|
372
|
+
return envSecret;
|
|
373
|
+
io.stderr(`Set MCP_CLIENT_SECRET to supply the client secret (a plain stdin prompt is not supported in this CLI).\n`);
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
function validateConfigShape(config) {
|
|
377
|
+
const type = config["type"];
|
|
378
|
+
if (type === undefined || type === "stdio") {
|
|
379
|
+
if (typeof config["command"] !== "string" || config["command"].length === 0) {
|
|
380
|
+
return { ok: false, message: 'stdio server requires a non-empty "command"' };
|
|
381
|
+
}
|
|
382
|
+
return { ok: true };
|
|
383
|
+
}
|
|
384
|
+
if (type === "http" || type === "sse") {
|
|
385
|
+
if (typeof config["url"] !== "string" || config["url"].length === 0) {
|
|
386
|
+
return { ok: false, message: `${type} server requires a non-empty "url"` };
|
|
387
|
+
}
|
|
388
|
+
return { ok: true };
|
|
389
|
+
}
|
|
390
|
+
return { ok: false, message: 'unknown "type" — expected stdio, http, or sse' };
|
|
391
|
+
}
|
|
392
|
+
function writeServerToScope(mod, name, config, scope, cwd) {
|
|
393
|
+
if (scope === "project") {
|
|
394
|
+
const path = join(cwd, mod.PROJECT_CONFIG_FILENAME);
|
|
395
|
+
let file;
|
|
396
|
+
try {
|
|
397
|
+
file = JSON.parse(readFileSync(path, "utf-8"));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
file = {};
|
|
401
|
+
}
|
|
402
|
+
file.mcpServers = { ...(file.mcpServers ?? {}), [name]: config };
|
|
403
|
+
// Project .mcp.json is hand-shareable: no atomic rename dance, match the
|
|
404
|
+
// simple write the repo would expect from a git-managed file.
|
|
405
|
+
writeFileSync(path, JSON.stringify(file, null, 2) + "\n");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const { file, errors } = mod.readUserMcpConfig();
|
|
409
|
+
if (errors.length > 0)
|
|
410
|
+
throw new Error(`could not read ${mod.mcpConfigPath()} — fix or remove the file first`);
|
|
411
|
+
const userFile = file;
|
|
412
|
+
if (scope === "user") {
|
|
413
|
+
userFile.mcpServers = { ...(userFile.mcpServers ?? {}), [name]: config };
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
const projects = userFile.projects ?? {};
|
|
417
|
+
const entry = projects[cwd] ?? {};
|
|
418
|
+
entry.mcpServers = { ...(entry.mcpServers ?? {}), [name]: config };
|
|
419
|
+
projects[cwd] = entry;
|
|
420
|
+
userFile.projects = projects;
|
|
421
|
+
}
|
|
422
|
+
mod.writeUserMcpConfig(userFile);
|
|
423
|
+
}
|
|
424
|
+
async function mcpRemove(mod, parsed, io) {
|
|
425
|
+
const { name } = parsed;
|
|
426
|
+
if (!name) {
|
|
427
|
+
io.stderr(`Error: name is required.\nUsage: ${DISTRIBUTION.commandName} mcp remove <name> [-s scope]\n`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
// An explicit -s selects that scope. Without one, remove the name from
|
|
431
|
+
// every scope file it exists in (raw reads, not the merged view, since a
|
|
432
|
+
// name defined in several scopes only surfaces once after merging).
|
|
433
|
+
// An explicit -s acts on the raw scope file, not the merged view — a
|
|
434
|
+
// local server can shadow a user one with the same name, and the merged
|
|
435
|
+
// view would then hide the very scope being targeted.
|
|
436
|
+
if (parsed.scopeExplicit) {
|
|
437
|
+
let removed = false;
|
|
438
|
+
try {
|
|
439
|
+
removed = removeServerFromScope(mod, name, parsed.scope, io.cwd);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
io.stderr(`Error removing ${name} from ${parsed.scope}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
443
|
+
return 1;
|
|
444
|
+
}
|
|
445
|
+
if (!removed) {
|
|
446
|
+
io.stderr(`No MCP server "${name}" in ${parsed.scope} scope.\n`);
|
|
447
|
+
return 1;
|
|
448
|
+
}
|
|
449
|
+
await revokeOnRemove(mod, name, io);
|
|
450
|
+
io.stdout(`Removed ${name} from ${parsed.scope} config\n`);
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
await revokeOnRemove(mod, name, io);
|
|
454
|
+
let removed = 0;
|
|
455
|
+
const removedScopes = [];
|
|
456
|
+
for (const scope of ["local", "user", "project"]) {
|
|
457
|
+
try {
|
|
458
|
+
if (removeServerFromScope(mod, name, scope, io.cwd)) {
|
|
459
|
+
removed++;
|
|
460
|
+
removedScopes.push(scope);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// unreadable scope file — treat as absent
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (removed === 0) {
|
|
468
|
+
io.stderr(`No MCP server found with name: ${name}\n`);
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
for (const scope of removedScopes)
|
|
472
|
+
io.stdout(`Removed ${name} from ${scope} config\n`);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Revoke OAuth tokens for a server before it is removed. Reads the merged
|
|
477
|
+
* server config for `name` to get its type/url, and only acts on http/sse
|
|
478
|
+
* servers (stdio has no OAuth). Best-effort and never throws — removal must
|
|
479
|
+
* proceed even if the server's auth endpoint is unreachable.
|
|
480
|
+
*/
|
|
481
|
+
async function revokeOnRemove(mod, name, io) {
|
|
482
|
+
try {
|
|
483
|
+
const loaded = mod.loadMcpServers(io.cwd, process.env);
|
|
484
|
+
const found = loaded.servers.find((s) => s.name === name);
|
|
485
|
+
if (!found)
|
|
486
|
+
return;
|
|
487
|
+
const config = found.config;
|
|
488
|
+
if (config.type !== "http" && config.type !== "sse")
|
|
489
|
+
return;
|
|
490
|
+
await mod.revokeTokensOnRemove(name, config);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// best-effort
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function removeServerFromScope(mod, name, scope, cwd) {
|
|
497
|
+
let removed = false;
|
|
498
|
+
if (scope === "project") {
|
|
499
|
+
const path = join(cwd, mod.PROJECT_CONFIG_FILENAME);
|
|
500
|
+
const file = JSON.parse(readFileSync(path, "utf-8"));
|
|
501
|
+
if (file.mcpServers && name in file.mcpServers) {
|
|
502
|
+
delete file.mcpServers[name];
|
|
503
|
+
writeFileSync(path, JSON.stringify(file, null, 2) + "\n");
|
|
504
|
+
removed = true;
|
|
505
|
+
}
|
|
506
|
+
return removed;
|
|
507
|
+
}
|
|
508
|
+
const { file, errors } = mod.readUserMcpConfig();
|
|
509
|
+
if (errors.length > 0)
|
|
510
|
+
throw new Error(`could not read ${mod.mcpConfigPath()}`);
|
|
511
|
+
const userFile = file;
|
|
512
|
+
if (scope === "user" && userFile.mcpServers && name in userFile.mcpServers) {
|
|
513
|
+
delete userFile.mcpServers[name];
|
|
514
|
+
if (Object.keys(userFile.mcpServers).length === 0)
|
|
515
|
+
delete userFile.mcpServers;
|
|
516
|
+
removed = true;
|
|
517
|
+
}
|
|
518
|
+
if (scope === "local") {
|
|
519
|
+
const projects = userFile.projects ?? {};
|
|
520
|
+
const entry = projects[cwd];
|
|
521
|
+
if (entry?.mcpServers && name in entry.mcpServers) {
|
|
522
|
+
delete entry.mcpServers[name];
|
|
523
|
+
if (Object.keys(entry.mcpServers).length === 0)
|
|
524
|
+
delete entry.mcpServers;
|
|
525
|
+
if (Object.keys(entry).length === 0)
|
|
526
|
+
delete projects[cwd];
|
|
527
|
+
else
|
|
528
|
+
projects[cwd] = entry;
|
|
529
|
+
userFile.projects = projects;
|
|
530
|
+
removed = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (removed)
|
|
534
|
+
mod.writeUserMcpConfig(userFile);
|
|
535
|
+
return removed;
|
|
536
|
+
}
|
|
537
|
+
async function mcpGet(mod, parsed, io) {
|
|
538
|
+
const { name } = parsed;
|
|
539
|
+
if (!name) {
|
|
540
|
+
io.stderr(`Error: name is required.\nUsage: ${DISTRIBUTION.commandName} mcp get <name>\n`);
|
|
541
|
+
return 1;
|
|
542
|
+
}
|
|
543
|
+
const loaded = mod.loadMcpServers(io.cwd, io.env);
|
|
544
|
+
const server = loaded.servers.find((s) => s.name === name);
|
|
545
|
+
if (!server) {
|
|
546
|
+
io.stderr(`No MCP server found with name: ${name}\n`);
|
|
547
|
+
return 1;
|
|
548
|
+
}
|
|
549
|
+
io.stdout(`${name}:\n`);
|
|
550
|
+
io.stdout(` Scope: ${scopeLabel(server.scope)}\n`);
|
|
551
|
+
const config = server.config;
|
|
552
|
+
if (config["type"] === "http" || config["type"] === "sse") {
|
|
553
|
+
io.stdout(` Type: ${config["type"]}\n`);
|
|
554
|
+
io.stdout(` URL: ${config["url"]}\n`);
|
|
555
|
+
for (const [key, value] of Object.entries(config["headers"] ?? {})) {
|
|
556
|
+
io.stdout(` Header: ${key}: ${value}\n`);
|
|
557
|
+
}
|
|
558
|
+
printOAuthDetail(mod, name, server.config, io.stdout);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
io.stdout(` Type: stdio\n`);
|
|
562
|
+
io.stdout(` Command: ${config["command"]}\n`);
|
|
563
|
+
const args = Array.isArray(config["args"]) ? config["args"] : [];
|
|
564
|
+
if (args.length > 0)
|
|
565
|
+
io.stdout(` Args: ${args.join(" ")}\n`);
|
|
566
|
+
for (const [key, value] of Object.entries(config["env"] ?? {})) {
|
|
567
|
+
io.stdout(` Env: ${key}=${value}\n`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (server.scope === "project") {
|
|
571
|
+
const { state } = mod.readProjectApproval(mod.resolveProjectRoot(io.cwd));
|
|
572
|
+
const decision = mod.decisionFor(state, name);
|
|
573
|
+
io.stdout(` Approved: ${decision === "enabled" ? "yes" : decision === "disabled" ? "no" : "not yet decided"}\n`);
|
|
574
|
+
}
|
|
575
|
+
io.stdout(` Config file: ${server.sourcePath}\n`);
|
|
576
|
+
io.stdout(await healthLine(mod, server, io));
|
|
577
|
+
io.stdout(`\nTo remove this server, run: ${DISTRIBUTION.commandName} mcp remove "${name}"\n`);
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
function printOAuthDetail(mod, name, config, stdout) {
|
|
581
|
+
const cfg = config;
|
|
582
|
+
const oauth = cfg["oauth"] ?? {};
|
|
583
|
+
const clientId = typeof oauth["clientId"] === "string" ? oauth["clientId"] : undefined;
|
|
584
|
+
const callbackPort = typeof oauth["callbackPort"] === "number" ? oauth["callbackPort"] : undefined;
|
|
585
|
+
const stored = mod.getStoredOAuthEntry(name, config);
|
|
586
|
+
if (clientId || callbackPort || stored?.clientSecret) {
|
|
587
|
+
stdout(` OAuth: client_id ${clientId ? "configured" : "(DCR)"}, client_secret ${stored?.clientSecret ? "configured" : "not set"}${callbackPort ? `, callback_port ${callbackPort}` : ""}\n`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async function healthLine(mod, server, io) {
|
|
591
|
+
// Approval gate: an undecided/disabled project server is reported WITHOUT
|
|
592
|
+
// connecting (fail-closed — never spawn an unapproved process).
|
|
593
|
+
if (server.scope === "project") {
|
|
594
|
+
const { state } = mod.readProjectApproval(mod.resolveProjectRoot(io.cwd));
|
|
595
|
+
const decision = mod.decisionFor(state, server.name);
|
|
596
|
+
if (decision === "disabled")
|
|
597
|
+
return ` Status: ! disabled (not approved for this project)\n`;
|
|
598
|
+
if (decision === "undecided")
|
|
599
|
+
return ` Status: ! not yet approved — run /mcp to approve it\n`;
|
|
600
|
+
}
|
|
601
|
+
const result = await (io.probeServer ?? mod.probeServer)(server.name, server.config, { env: io.env });
|
|
602
|
+
if (result.status === "connected")
|
|
603
|
+
return ` Status: ✓ connected\n`;
|
|
604
|
+
if (result.status === "needs_auth")
|
|
605
|
+
return ` Status: ! needs authentication\n`;
|
|
606
|
+
return ` Status: ✗ failed to connect${result.error ? ` — ${result.error}` : ""}\n`;
|
|
607
|
+
}
|
|
608
|
+
async function mcpList(mod, io) {
|
|
609
|
+
const { servers, errors } = mod.loadMcpServers(io.cwd, io.env);
|
|
610
|
+
if (servers.length === 0 && errors.length === 0) {
|
|
611
|
+
io.stdout("No MCP servers configured.\n");
|
|
612
|
+
io.stdout(`Use \`${DISTRIBUTION.commandName} mcp add\` to add a server.\n`);
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
const lines = [];
|
|
616
|
+
const byScope = { user: [], project: [], local: [] };
|
|
617
|
+
for (const s of servers)
|
|
618
|
+
(byScope[s.scope] ??= []).push(s);
|
|
619
|
+
for (const scope of ["local", "project", "user"]) {
|
|
620
|
+
const group = byScope[scope];
|
|
621
|
+
if (!group?.length)
|
|
622
|
+
continue;
|
|
623
|
+
lines.push(`${scope === "local" ? "Local" : scope === "project" ? "Project" : "User"} servers:`);
|
|
624
|
+
for (const s of group) {
|
|
625
|
+
const status = await listHealthLine(mod, s, io);
|
|
626
|
+
lines.push(` ${s.name} · ${status}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const err of errors)
|
|
630
|
+
lines.push(` ⚠ ${err.serverName ? `${err.serverName}: ` : ""}${err.message}`);
|
|
631
|
+
lines.push("");
|
|
632
|
+
lines.push(`Run \`${DISTRIBUTION.commandName} mcp get <name>\` for a server's full config.`);
|
|
633
|
+
io.stdout(lines.join("\n"));
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
async function listHealthLine(mod, server, io) {
|
|
637
|
+
if (server.scope === "project") {
|
|
638
|
+
const { state } = mod.readProjectApproval(mod.resolveProjectRoot(io.cwd));
|
|
639
|
+
const decision = mod.decisionFor(state, server.name);
|
|
640
|
+
if (decision === "disabled")
|
|
641
|
+
return "! disabled";
|
|
642
|
+
if (decision === "undecided")
|
|
643
|
+
return "! not approved";
|
|
644
|
+
}
|
|
645
|
+
const result = await (io.probeServer ?? mod.probeServer)(server.name, server.config, { env: io.env });
|
|
646
|
+
if (result.status === "connected")
|
|
647
|
+
return "✓ connected";
|
|
648
|
+
if (result.status === "needs_auth")
|
|
649
|
+
return "! needs auth";
|
|
650
|
+
return `✗ ${result.error ?? "failed"}`;
|
|
651
|
+
}
|
|
652
|
+
function scopeLabel(scope) {
|
|
653
|
+
if (scope === "local")
|
|
654
|
+
return "Local config (private to you in this project)";
|
|
655
|
+
if (scope === "project")
|
|
656
|
+
return "Project config (shared via .mcp.json)";
|
|
657
|
+
return "User config (available in all your projects)";
|
|
658
|
+
}
|
|
659
|
+
function mcpResetChoices(mod, io) {
|
|
660
|
+
const repoRoot = mod.resolveProjectRoot(io.cwd);
|
|
661
|
+
const { errors } = mod.resetProjectChoices(repoRoot);
|
|
662
|
+
if (errors.length > 0) {
|
|
663
|
+
io.stderr(`Error: could not reset project choices (${errors[0]?.message ?? "unknown"}).\n`);
|
|
664
|
+
return 1;
|
|
665
|
+
}
|
|
666
|
+
io.stdout(`Reset all approved and rejected project-scoped (.mcp.json) servers in this project.\n`);
|
|
667
|
+
return 0;
|
|
668
|
+
}
|
|
669
|
+
function mcpAddFromClaude(mod, parsed, io) {
|
|
670
|
+
const claudePath = join(io.home, ".claude.json");
|
|
671
|
+
let claude;
|
|
672
|
+
try {
|
|
673
|
+
claude = JSON.parse(readFileSync(claudePath, "utf-8"));
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
io.stderr(`No Claude Code config found at ${claudePath} — nothing to import.\n`);
|
|
677
|
+
return 1;
|
|
678
|
+
}
|
|
679
|
+
const userServers = claude.mcpServers ?? {};
|
|
680
|
+
const localServers = (claude.projects ?? {})[io.cwd]?.mcpServers ?? {};
|
|
681
|
+
const total = Object.keys(userServers).length + Object.keys(localServers).length;
|
|
682
|
+
if (total === 0) {
|
|
683
|
+
io.stdout(`No MCP servers found in Claude Code config (user or this project).\n`);
|
|
684
|
+
return 0;
|
|
685
|
+
}
|
|
686
|
+
let imported = 0;
|
|
687
|
+
const skipped = [];
|
|
688
|
+
const warned = [];
|
|
689
|
+
/**
|
|
690
|
+
* An imported config may reference ${VAR} env vars that Claude Code expanded
|
|
691
|
+
* in its own shell. We import verbatim (the raw reference is the correct
|
|
692
|
+
* stored form) but warn when a var is not set in THIS shell, with the export
|
|
693
|
+
* line to fix it — the var may legitimately be set only in the shell that
|
|
694
|
+
* runs sessions. The canonical expandServerEnv walks every expandable field
|
|
695
|
+
* (url, headers, stdio command/args/env), so the warning can never diverge
|
|
696
|
+
* from what the session will actually fail on.
|
|
697
|
+
*/
|
|
698
|
+
const warnUnsetEnvVars = (name, config) => {
|
|
699
|
+
if (typeof config !== "object" || config === null)
|
|
700
|
+
return;
|
|
701
|
+
if (typeof mod.expandServerEnv !== "function")
|
|
702
|
+
return;
|
|
703
|
+
const { missingVars } = mod.expandServerEnv(config, process.env);
|
|
704
|
+
if (missingVars.length > 0) {
|
|
705
|
+
const names = missingVars.join(", ");
|
|
706
|
+
io.stderr(`Note: ${name} references ${names} — not currently set in this shell. ` +
|
|
707
|
+
`It will expand at connect time; export it (e.g. export ${missingVars[0]}=... in your shell profile) before your next session, or the server will report a missing-variable error.\n`);
|
|
708
|
+
warned.push(name);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
// Claude Code's user scope maps to yagni user scope (unless -s local is
|
|
712
|
+
// explicit); its per-project entries map to yagni local scope for this cwd.
|
|
713
|
+
const claudeUserTarget = parsed.scopeExplicit && parsed.scope === "local" ? "local" : "user";
|
|
714
|
+
for (const [name, config] of Object.entries(userServers)) {
|
|
715
|
+
if (!isImportable(config)) {
|
|
716
|
+
skipped.push(name);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
writeServerToScope(mod, name, config, claudeUserTarget, io.cwd);
|
|
721
|
+
warnUnsetEnvVars(name, config);
|
|
722
|
+
io.stdout(`Imported ${name} (Claude Code user scope → ${claudeUserTarget})\n`);
|
|
723
|
+
imported++;
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
io.stderr(`Skipped ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
727
|
+
skipped.push(name);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
for (const [name, config] of Object.entries(localServers)) {
|
|
731
|
+
if (!isImportable(config)) {
|
|
732
|
+
skipped.push(name);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
writeServerToScope(mod, name, config, "local", io.cwd);
|
|
737
|
+
warnUnsetEnvVars(name, config);
|
|
738
|
+
io.stdout(`Imported ${name} (this project, from Claude Code → local)\n`);
|
|
739
|
+
imported++;
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
io.stderr(`Skipped ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
743
|
+
skipped.push(name);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (skipped.length > 0)
|
|
747
|
+
io.stderr(`Skipped (unsupported config shape): ${skipped.join(", ")}\n`);
|
|
748
|
+
io.stdout(`Imported ${imported} server(s) from Claude Code${warned.length > 0 ? ` (${warned.length} with env-var notes)` : ""}.\n`);
|
|
749
|
+
return imported > 0 ? 0 : 1;
|
|
750
|
+
}
|
|
751
|
+
function isImportable(config) {
|
|
752
|
+
const validation = validateConfigShape((typeof config === "object" && config !== null ? config : {}));
|
|
753
|
+
return validation.ok;
|
|
754
|
+
}
|
|
755
|
+
//# sourceMappingURL=mcpCommand.js.map
|