@yagni-app/code 1.0.4 → 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 +94 -31
- 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/execPolicy.js +17 -2
- 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,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server configuration for YAGNI Code: three scopes, Claude Code-compatible.
|
|
3
|
+
*
|
|
4
|
+
* - project: `.mcp.json` at the repo root — the exact Claude Code schema
|
|
5
|
+
* (`{"mcpServers": {...}}`), VCS-shared, approval-gated (see approval.ts).
|
|
6
|
+
* A repo configured for Claude Code works here with zero changes.
|
|
7
|
+
* - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
|
|
8
|
+
* - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
|
|
9
|
+
*
|
|
10
|
+
* Merge precedence (Claude Code parity): user < project < local — later wins.
|
|
11
|
+
* Writes are atomic (temp file + rename) so two concurrent sessions can never
|
|
12
|
+
* interleave into a corrupt config. Validation errors are collected and
|
|
13
|
+
* surfaced, never fatal: one malformed server entry must not hide the rest.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { codeStateHome } from "../stateHome.js";
|
|
18
|
+
export const PROJECT_CONFIG_FILENAME = ".mcp.json";
|
|
19
|
+
/**
|
|
20
|
+
* Expand `${VAR}` / `${VAR:-default}` in a config string (Claude Code parity —
|
|
21
|
+
* .mcp.json files are VCS-shared, so secrets come from the environment).
|
|
22
|
+
* Missing vars with no default are reported, left in place, and skipped at
|
|
23
|
+
* connect time so a half-expanded command never spawns.
|
|
24
|
+
*/
|
|
25
|
+
export function expandEnvVarsInString(value, env = process.env) {
|
|
26
|
+
const missingVars = [];
|
|
27
|
+
const expanded = value.replace(/\$\{([^}]+)\}/g, (match, varContent) => {
|
|
28
|
+
const separator = varContent.indexOf(":-");
|
|
29
|
+
const varName = separator === -1 ? varContent : varContent.slice(0, separator);
|
|
30
|
+
const defaultValue = separator === -1 ? undefined : varContent.slice(separator + 2);
|
|
31
|
+
const envValue = env[varName];
|
|
32
|
+
if (envValue !== undefined)
|
|
33
|
+
return envValue;
|
|
34
|
+
if (defaultValue !== undefined)
|
|
35
|
+
return defaultValue;
|
|
36
|
+
missingVars.push(varName);
|
|
37
|
+
return match;
|
|
38
|
+
});
|
|
39
|
+
return { expanded, missingVars };
|
|
40
|
+
}
|
|
41
|
+
/** Where env expansion applies within one server config (stdio: command,
|
|
42
|
+
* args, env values; http/sse: url and header values). */
|
|
43
|
+
export function expandServerEnv(config, env = process.env) {
|
|
44
|
+
const missingVars = [];
|
|
45
|
+
if (config.type === "stdio" || config.type === undefined) {
|
|
46
|
+
const command = expandEnvVarsInString(config.command, env);
|
|
47
|
+
missingVars.push(...command.missingVars);
|
|
48
|
+
const args = (config.args ?? []).map((a) => {
|
|
49
|
+
const r = expandEnvVarsInString(a, env);
|
|
50
|
+
missingVars.push(...r.missingVars);
|
|
51
|
+
return r.expanded;
|
|
52
|
+
});
|
|
53
|
+
const envValues = {};
|
|
54
|
+
for (const [k, v] of Object.entries(config.env ?? {})) {
|
|
55
|
+
const r = expandEnvVarsInString(v, env);
|
|
56
|
+
missingVars.push(...r.missingVars);
|
|
57
|
+
envValues[k] = r.expanded;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
config: { ...config, command: command.expanded, args, env: Object.keys(envValues).length > 0 ? envValues : undefined },
|
|
61
|
+
missingVars: [...new Set(missingVars)],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// http/sse: url + header values. Used as a load-time gate (missing vars
|
|
65
|
+
// → server skipped with a clear error) and at connect time to build the
|
|
66
|
+
// actual transport. The stored config keeps the raw ${VAR} references —
|
|
67
|
+
// display and the OAuth auth-store key (sha256 of type+url+headers) must
|
|
68
|
+
// see the raw form, so env changes never orphan stored tokens.
|
|
69
|
+
if (config.type === "http" || config.type === "sse") {
|
|
70
|
+
const url = expandEnvVarsInString(config.url, env);
|
|
71
|
+
missingVars.push(...url.missingVars);
|
|
72
|
+
const headerEntries = Object.entries(config.headers ?? {}).map(([k, v]) => {
|
|
73
|
+
const r = expandEnvVarsInString(v, env);
|
|
74
|
+
missingVars.push(...r.missingVars);
|
|
75
|
+
return [k, r.expanded];
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
config: {
|
|
79
|
+
...config,
|
|
80
|
+
url: url.expanded,
|
|
81
|
+
headers: headerEntries.length > 0 ? Object.fromEntries(headerEntries) : config.headers,
|
|
82
|
+
},
|
|
83
|
+
missingVars: [...new Set(missingVars)],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { config, missingVars };
|
|
87
|
+
}
|
|
88
|
+
/** Test seam: point the state home at a tmpdir, mirroring errorSink's pattern. */
|
|
89
|
+
let homeOverride = null;
|
|
90
|
+
export function _setMcpHomeForTest(dir) {
|
|
91
|
+
homeOverride = dir;
|
|
92
|
+
}
|
|
93
|
+
export function mcpConfigPath() {
|
|
94
|
+
return join(codeStateHome(homeOverride), "mcp.json");
|
|
95
|
+
}
|
|
96
|
+
function projectEntry(file, projectPath) {
|
|
97
|
+
return (file.projects ?? {})[projectPath] ?? {};
|
|
98
|
+
}
|
|
99
|
+
/** Read + parse `~/.yagni-code/mcp.json`; unreadable/missing → empty with error collected. */
|
|
100
|
+
export function readUserMcpConfig() {
|
|
101
|
+
const path = mcpConfigPath();
|
|
102
|
+
if (!existsSync(path))
|
|
103
|
+
return { file: {}, errors: [] };
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
106
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
107
|
+
return { file: {}, errors: [{ sourcePath: path, message: "not a JSON object at the top level" }] };
|
|
108
|
+
}
|
|
109
|
+
return { file: parsed, errors: [] };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return {
|
|
113
|
+
file: {},
|
|
114
|
+
errors: [
|
|
115
|
+
{
|
|
116
|
+
sourcePath: path,
|
|
117
|
+
message: `could not parse (${err instanceof Error ? err.message : String(err)})`,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Atomic write: temp file in the same directory, then rename over the target. */
|
|
124
|
+
export function writeUserMcpConfig(file) {
|
|
125
|
+
const path = mcpConfigPath();
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
127
|
+
const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}`);
|
|
128
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", "utf-8");
|
|
129
|
+
renameSync(tmp, path);
|
|
130
|
+
}
|
|
131
|
+
function basename(p) {
|
|
132
|
+
return p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
|
133
|
+
}
|
|
134
|
+
/** Read + parse a project `.mcp.json`; missing → empty (not an error). */
|
|
135
|
+
export function readProjectMcpConfig(repoRoot, read = defaultReadFile) {
|
|
136
|
+
const path = join(repoRoot, PROJECT_CONFIG_FILENAME);
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = read(path);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
servers: {},
|
|
144
|
+
errors: [{ sourcePath: path, message: `could not read (${err instanceof Error ? err.message : String(err)})` }],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (raw === undefined)
|
|
148
|
+
return { servers: {}, errors: [] };
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(raw);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return {
|
|
155
|
+
servers: {},
|
|
156
|
+
errors: [{ sourcePath: path, message: `could not parse (${err instanceof Error ? err.message : String(err)})` }],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
160
|
+
return { servers: {}, errors: [{ sourcePath: path, message: "not a JSON object at the top level" }] };
|
|
161
|
+
}
|
|
162
|
+
const mcpServers = parsed.mcpServers;
|
|
163
|
+
if (mcpServers !== undefined && (typeof mcpServers !== "object" || mcpServers === null)) {
|
|
164
|
+
return { servers: {}, errors: [{ sourcePath: path, message: `"mcpServers" must be an object` }] };
|
|
165
|
+
}
|
|
166
|
+
const servers = {};
|
|
167
|
+
const errors = [];
|
|
168
|
+
for (const [name, value] of Object.entries(mcpServers ?? {})) {
|
|
169
|
+
const validation = validateServerConfig(value);
|
|
170
|
+
if (validation.ok)
|
|
171
|
+
servers[name] = value;
|
|
172
|
+
else
|
|
173
|
+
errors.push({ sourcePath: path, serverName: name, message: validation.message });
|
|
174
|
+
}
|
|
175
|
+
return { servers, errors };
|
|
176
|
+
}
|
|
177
|
+
function defaultReadFile(path) {
|
|
178
|
+
return existsSync(path) ? readFileSync(path, "utf-8") : undefined;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Structural validation of one server entry. Claude Code uses zod; we don't
|
|
182
|
+
* depend on zod, so this hand-checks the same union: stdio (no type / "stdio",
|
|
183
|
+
* non-empty command) vs http/sse (type + url).
|
|
184
|
+
*/
|
|
185
|
+
export function validateServerConfig(value) {
|
|
186
|
+
if (typeof value !== "object" || value === null) {
|
|
187
|
+
return { ok: false, message: "server entry must be an object" };
|
|
188
|
+
}
|
|
189
|
+
const v = value;
|
|
190
|
+
const type = v["type"];
|
|
191
|
+
if (type === undefined || type === "stdio") {
|
|
192
|
+
if (typeof v["command"] !== "string" || v["command"].length === 0) {
|
|
193
|
+
return { ok: false, message: 'stdio server requires a non-empty "command"' };
|
|
194
|
+
}
|
|
195
|
+
if (v["args"] !== undefined && !Array.isArray(v["args"])) {
|
|
196
|
+
return { ok: false, message: '"args" must be an array of strings' };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true };
|
|
199
|
+
}
|
|
200
|
+
if (type === "http" || type === "sse") {
|
|
201
|
+
if (typeof v["url"] !== "string" || v["url"].length === 0) {
|
|
202
|
+
return { ok: false, message: `${type} server requires a non-empty "url"` };
|
|
203
|
+
}
|
|
204
|
+
return { ok: true };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
message: 'unknown "type" — expected "stdio", "http", or "sse" (Claude Code also accepts ws/sdk; YAGNI Code v1 does not)',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The repo root for project-scope lookup: the nearest ancestor of `cwd` that
|
|
213
|
+
* looks like a repo (has `.git`, `.mcp.json`, or is a known project root).
|
|
214
|
+
* Falls back to cwd itself. CC walks ALL ancestors and merges nearest-wins;
|
|
215
|
+
* we take the nearest repo boundary — same result for the normal case of one
|
|
216
|
+
* repo, and it keeps the approval prompt anchored to one file.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveProjectRoot(cwd, exists = defaultExists) {
|
|
219
|
+
let current = cwd;
|
|
220
|
+
for (;;) {
|
|
221
|
+
if (exists(join(current, ".git")) || exists(join(current, PROJECT_CONFIG_FILENAME)))
|
|
222
|
+
return current;
|
|
223
|
+
const parent = dirname(current);
|
|
224
|
+
if (parent === current)
|
|
225
|
+
return cwd;
|
|
226
|
+
current = parent;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function defaultExists(p) {
|
|
230
|
+
return existsSync(p);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Load all three scopes and merge with precedence user < project < local.
|
|
234
|
+
* Invalid entries are dropped (with errors) rather than failing the load; a
|
|
235
|
+
* same-name entry in a higher-precedence scope replaces the lower one.
|
|
236
|
+
* Project servers are returned regardless of approval state — the caller
|
|
237
|
+
* (session startup, panel, CLI) applies the approval gate via approval.ts.
|
|
238
|
+
*/
|
|
239
|
+
export function loadMcpServers(cwd, env = process.env) {
|
|
240
|
+
const repoRoot = resolveProjectRoot(cwd);
|
|
241
|
+
const errors = [];
|
|
242
|
+
const byScope = { user: [], project: [], local: [] };
|
|
243
|
+
const { file: userFile, errors: userErrors } = readUserMcpConfig();
|
|
244
|
+
errors.push(...userErrors);
|
|
245
|
+
const userServers = userFile.mcpServers ?? {};
|
|
246
|
+
for (const [name, value] of Object.entries(userServers)) {
|
|
247
|
+
const validation = validateServerConfig(value);
|
|
248
|
+
if (validation.ok) {
|
|
249
|
+
// Gate: a ${VAR} reference with no matching env var (and no default)
|
|
250
|
+
// skips the server with a clear error, rather than sending a literal
|
|
251
|
+
// "${VAR}" header on the wire. The pushed config stays raw.
|
|
252
|
+
const expanded = expandServerEnv(value, env);
|
|
253
|
+
if (expanded.missingVars.length > 0) {
|
|
254
|
+
errors.push({
|
|
255
|
+
sourcePath: mcpConfigPath(),
|
|
256
|
+
serverName: name,
|
|
257
|
+
message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
byScope.user.push({ name, config: value, scope: "user", sourcePath: mcpConfigPath() });
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
errors.push({ sourcePath: mcpConfigPath(), serverName: name, message: validation.message });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const projectPath = join(repoRoot, PROJECT_CONFIG_FILENAME);
|
|
268
|
+
const { servers: projectServers, errors: projectErrors } = readProjectMcpConfig(repoRoot);
|
|
269
|
+
errors.push(...projectErrors);
|
|
270
|
+
for (const [name, raw] of Object.entries(projectServers)) {
|
|
271
|
+
const expanded = expandServerEnv(raw, env);
|
|
272
|
+
if (expanded.missingVars.length > 0) {
|
|
273
|
+
errors.push({
|
|
274
|
+
sourcePath: projectPath,
|
|
275
|
+
serverName: name,
|
|
276
|
+
message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
byScope.project.push({ name, config: raw, scope: "project", sourcePath: projectPath });
|
|
281
|
+
}
|
|
282
|
+
const localEntry = projectEntry(userFile, repoRoot);
|
|
283
|
+
const localServers = localEntry.mcpServers ?? {};
|
|
284
|
+
for (const [name, value] of Object.entries(localServers)) {
|
|
285
|
+
const validation = validateServerConfig(value);
|
|
286
|
+
if (validation.ok) {
|
|
287
|
+
const expanded = expandServerEnv(value, env);
|
|
288
|
+
if (expanded.missingVars.length > 0) {
|
|
289
|
+
errors.push({
|
|
290
|
+
sourcePath: mcpConfigPath(),
|
|
291
|
+
serverName: name,
|
|
292
|
+
message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
byScope.local.push({ name, config: value, scope: "local", sourcePath: mcpConfigPath() });
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
errors.push({ sourcePath: mcpConfigPath(), serverName: name, message: validation.message });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const merged = new Map();
|
|
303
|
+
for (const scope of ["user", "project", "local"]) {
|
|
304
|
+
for (const server of byScope[scope])
|
|
305
|
+
merged.set(server.name, server);
|
|
306
|
+
}
|
|
307
|
+
return { servers: [...merged.values()], errors };
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP logging primitives shared by session wiring (startup.ts) and the OAuth
|
|
3
|
+
* flow (auth.ts). Kept as a standalone module so neither import site drags in
|
|
4
|
+
* the other (startup → manager → auth is already a chain; an auth → startup
|
|
5
|
+
* edge for the log helper would make it cyclic).
|
|
6
|
+
*
|
|
7
|
+
* `appendMcpLogLine` writes the capped/rotated `~/.yagni-code/logs/mcp.log`
|
|
8
|
+
* (fail-soft — logging never blocks the session or the CLI). The OAuth helpers
|
|
9
|
+
* here are redaction-first: authorization URLs drop `state` / `nonce` /
|
|
10
|
+
* `code_challenge` / `code_verifier` / `code`, and error text scrubs any
|
|
11
|
+
* credential-looking token, so nothing secret reaches the on-disk log.
|
|
12
|
+
*/
|
|
13
|
+
export declare function _setMcpLogHomeForTest(dir: string | null): void;
|
|
14
|
+
/** Fail-soft append with size cap + rotation, mirroring errorSink's pattern. */
|
|
15
|
+
export declare function appendMcpLogLine(line: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Redact sensitive OAuth query params from a URL for safe logging (Claude Code
|
|
18
|
+
* parity — see its `redactSensitiveUrlParams`). Returns the URL unchanged when
|
|
19
|
+
* it does not parse.
|
|
20
|
+
*/
|
|
21
|
+
export declare function redactSensitiveUrlParams(url: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Append one structured MCP event to the log. `fields` are caller-controlled
|
|
24
|
+
* and expected to already be redacted; only the `server` / `event` / timestamp
|
|
25
|
+
* are synthesized here.
|
|
26
|
+
*/
|
|
27
|
+
export declare function logMcpEvent(serverName: string, event: string, fields?: Record<string, unknown>): void;
|
|
28
|
+
//# sourceMappingURL=log.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP logging primitives shared by session wiring (startup.ts) and the OAuth
|
|
3
|
+
* flow (auth.ts). Kept as a standalone module so neither import site drags in
|
|
4
|
+
* the other (startup → manager → auth is already a chain; an auth → startup
|
|
5
|
+
* edge for the log helper would make it cyclic).
|
|
6
|
+
*
|
|
7
|
+
* `appendMcpLogLine` writes the capped/rotated `~/.yagni-code/logs/mcp.log`
|
|
8
|
+
* (fail-soft — logging never blocks the session or the CLI). The OAuth helpers
|
|
9
|
+
* here are redaction-first: authorization URLs drop `state` / `nonce` /
|
|
10
|
+
* `code_challenge` / `code_verifier` / `code`, and error text scrubs any
|
|
11
|
+
* credential-looking token, so nothing secret reaches the on-disk log.
|
|
12
|
+
*/
|
|
13
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { codeStateHome } from "../stateHome.js";
|
|
16
|
+
const MCP_LOG_MAX_BYTES = 256 * 1024;
|
|
17
|
+
const MCP_LOG_ROTATIONS = 2;
|
|
18
|
+
let mcpLogHomeOverride = null;
|
|
19
|
+
export function _setMcpLogHomeForTest(dir) {
|
|
20
|
+
mcpLogHomeOverride = dir;
|
|
21
|
+
}
|
|
22
|
+
function mcpLogDir() {
|
|
23
|
+
return join(codeStateHome(mcpLogHomeOverride), "logs");
|
|
24
|
+
}
|
|
25
|
+
/** Fail-soft append with size cap + rotation, mirroring errorSink's pattern. */
|
|
26
|
+
export function appendMcpLogLine(line) {
|
|
27
|
+
try {
|
|
28
|
+
const dir = mcpLogDir();
|
|
29
|
+
const path = join(dir, "mcp.log");
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
try {
|
|
32
|
+
if (statSync(path).isFile() && statSync(path).size >= MCP_LOG_MAX_BYTES) {
|
|
33
|
+
for (let i = MCP_LOG_ROTATIONS; i >= 1; i--) {
|
|
34
|
+
const from = i === 1 ? path : `${path}.${i - 1}`;
|
|
35
|
+
try {
|
|
36
|
+
renameSync(from, `${path}.${i}`);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* absent source — fine */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* stat on missing file — fine */
|
|
46
|
+
}
|
|
47
|
+
appendFileSync(path, line.endsWith("\n") ? line : line + "\n", "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// logging must never take the session down
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** OAuth query params that must never reach a log line (Claude Code parity). */
|
|
54
|
+
const SENSITIVE_OAUTH_PARAMS = ["state", "nonce", "code_challenge", "code_verifier", "code"];
|
|
55
|
+
/**
|
|
56
|
+
* Redact sensitive OAuth query params from a URL for safe logging (Claude Code
|
|
57
|
+
* parity — see its `redactSensitiveUrlParams`). Returns the URL unchanged when
|
|
58
|
+
* it does not parse.
|
|
59
|
+
*/
|
|
60
|
+
export function redactSensitiveUrlParams(url) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = new URL(url);
|
|
63
|
+
for (const param of SENSITIVE_OAUTH_PARAMS) {
|
|
64
|
+
if (parsed.searchParams.has(param)) {
|
|
65
|
+
parsed.searchParams.set(param, "[REDACTED]");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parsed.toString();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return url;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Append one structured MCP event to the log. `fields` are caller-controlled
|
|
76
|
+
* and expected to already be redacted; only the `server` / `event` / timestamp
|
|
77
|
+
* are synthesized here.
|
|
78
|
+
*/
|
|
79
|
+
export function logMcpEvent(serverName, event, fields = {}) {
|
|
80
|
+
appendMcpLogLine(JSON.stringify({ ts: new Date().toISOString(), server: serverName, event, ...fields }));
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=log.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP manager: owns one live client per configured server, tracks
|
|
3
|
+
* connection state, and exposes connect/reconnect/disconnect operations used
|
|
4
|
+
* by both the /mcp panel and session startup. Connection state is what the
|
|
5
|
+
* panel renders; tool/prompt registration is layered on top by tools.ts /
|
|
6
|
+
* prompts.ts once a server connects.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is fail-soft: a server that fails to connect lands in
|
|
9
|
+
* `failed` with its error message, never blocking the session or the other
|
|
10
|
+
* servers. `MCP_TIMEOUT` (connect, ms) and `MCP_TOOL_TIMEOUT` (call, ms) are
|
|
11
|
+
* honored per Claude Code's env parity.
|
|
12
|
+
*/
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
15
|
+
import type { McpStdioServerConfig, McpHttpServerConfig, McpScope } from "./config.js";
|
|
16
|
+
import { type AuthDeps } from "./auth.js";
|
|
17
|
+
export type McpServerStatus = "connecting" | "connected" | "failed" | "needs_auth" | "disabled" | "reconnecting";
|
|
18
|
+
export interface ManagedServer {
|
|
19
|
+
name: string;
|
|
20
|
+
scope: McpScope;
|
|
21
|
+
config: McpStdioServerConfig | McpHttpServerConfig;
|
|
22
|
+
status: McpServerStatus;
|
|
23
|
+
/** Failure detail for the failed/needs_auth states (panel + logs). */
|
|
24
|
+
error?: string;
|
|
25
|
+
client?: Client;
|
|
26
|
+
transport?: Transport;
|
|
27
|
+
/** Reconnect attempt count while in `reconnecting`. */
|
|
28
|
+
reconnectAttempt?: number;
|
|
29
|
+
connectedAt?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ManagerEvents {
|
|
32
|
+
onStateChange?: (server: ManagedServer) => void;
|
|
33
|
+
/** Structured line for ~/.yagni-code/logs/mcp.log (already sanitized). */
|
|
34
|
+
onLog?: (line: string) => void;
|
|
35
|
+
}
|
|
36
|
+
export interface ManagerOpts {
|
|
37
|
+
connectTimeoutMs?: number;
|
|
38
|
+
events?: ManagerEvents;
|
|
39
|
+
/** Environment for ${VAR} expansion at connect time (default: process.env). */
|
|
40
|
+
env?: NodeJS.ProcessEnv;
|
|
41
|
+
}
|
|
42
|
+
export declare function connectTimeoutFromEnv(env: NodeJS.ProcessEnv, fallback?: number): number;
|
|
43
|
+
export declare function toolTimeoutFromEnv(env: NodeJS.ProcessEnv): number | undefined;
|
|
44
|
+
export declare class McpManager {
|
|
45
|
+
private servers;
|
|
46
|
+
private events;
|
|
47
|
+
private connectTimeoutMs;
|
|
48
|
+
private env;
|
|
49
|
+
private closed;
|
|
50
|
+
constructor(opts?: ManagerOpts);
|
|
51
|
+
list(): ManagedServer[];
|
|
52
|
+
get(name: string): ManagedServer | undefined;
|
|
53
|
+
/** Registers a server without connecting (panel listing, kill-switch states). */
|
|
54
|
+
register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus): ManagedServer;
|
|
55
|
+
connect(name: string): Promise<ManagedServer | undefined>;
|
|
56
|
+
disconnect(name: string): Promise<void>;
|
|
57
|
+
/** Reconnect with bounded attempts, panel-visible progress. */
|
|
58
|
+
reconnect(name: string, maxAttempts?: number): Promise<ManagedServer | undefined>;
|
|
59
|
+
/**
|
|
60
|
+
* Drive the interactive OAuth flow for a `needs_auth` server, then reconnect.
|
|
61
|
+
* The flow opens a browser and returns once tokens are persisted; a cancelled
|
|
62
|
+
* or failed flow leaves the server in `needs_auth` and returns its error.
|
|
63
|
+
* `signal` cancels the loopback wait (Esc / session abort) instead of holding
|
|
64
|
+
* the command handler for the full 5-minute timeout. `authDeps` is the same
|
|
65
|
+
* seam `authenticate` takes (openUrl/fetch) so tests drive the real path
|
|
66
|
+
* without a browser or network.
|
|
67
|
+
*/
|
|
68
|
+
authenticateServer(name: string, signal?: AbortSignal, authDeps?: AuthDeps): Promise<ManagedServer | undefined>;
|
|
69
|
+
/** Closes every connected client. Called on session_shutdown (any reason). */
|
|
70
|
+
closeAll(): Promise<void>;
|
|
71
|
+
private setStatus;
|
|
72
|
+
}
|
|
73
|
+
export type McpHealthStatus = "connected" | "needs_auth" | "failed";
|
|
74
|
+
export interface McpHealthResult {
|
|
75
|
+
status: McpHealthStatus;
|
|
76
|
+
/** Sanitized failure detail for needs_auth/failed. */
|
|
77
|
+
error?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One-off health check for a configured server, used by `yagni mcp list` /
|
|
81
|
+
* `get` and `yagni doctor`. Does NOT mutate manager state: it builds a fresh
|
|
82
|
+
* transport + client, connect()s against it, classifies the result, and closes
|
|
83
|
+
* the client. OAuth-capable (http/sse) servers get an auth provider so a fresh
|
|
84
|
+
* token (or the redirect-required `needs_auth`) is reported faithfully.
|
|
85
|
+
*
|
|
86
|
+
* Callers apply the approval gate themselves: a project-scope server that is
|
|
87
|
+
* undecided/disabled must be reported as such WITHOUT connecting (fail-closed,
|
|
88
|
+
* never spawn a process the user has not approved), so `probeServer` never
|
|
89
|
+
* asks about approval — it only ever connects what it is given.
|
|
90
|
+
*/
|
|
91
|
+
export declare function probeServer(name: string, config: McpStdioServerConfig | McpHttpServerConfig, opts?: {
|
|
92
|
+
connectTimeoutMs?: number;
|
|
93
|
+
env?: NodeJS.ProcessEnv;
|
|
94
|
+
}): Promise<McpHealthResult>;
|
|
95
|
+
/** 401/403 → needs_auth; everything else → failed. */
|
|
96
|
+
export declare function classifyFailure(message: string): "needs_auth" | "failed";
|
|
97
|
+
export declare function sanitizeError(message: string): string;
|
|
98
|
+
//# sourceMappingURL=manager.d.ts.map
|