@systemdox/mcp-server 0.1.48 → 0.1.50

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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `setup`'s first step: write the SystemDox MCP server into an AI tool's
3
+ * config (Claude Code's project `.mcp.json` or `~/.claude.json`, Cursor's,
4
+ * VS Code's). The team setup (`setup-team.ts`) follows it.
5
+ *
6
+ * The key is stored once (specs/changes/team-setup/contract.md "The API key",
7
+ * proposal item 7): in `~/.claude/systemdox/credentials.json` for the key's
8
+ * workspace, readable by the member alone. The server entry this step writes
9
+ * carries no key; the local server reads it from SYSTEMDOX_API_KEY, then from
10
+ * credentials.json (`loadLocalConfig`). A key an earlier `setup` wrote into
11
+ * the entry is removed from it, and nothing else in that file changes.
12
+ */
13
+ import type { TeamSetup } from "./lib/team-setup-types.js";
14
+ export interface McpServerEntry {
15
+ command: string;
16
+ args: string[];
17
+ env?: Record<string, string>;
18
+ }
19
+ export interface McpConfigTarget {
20
+ name: string;
21
+ path: string;
22
+ detected: boolean;
23
+ }
24
+ export interface McpSetupOptions {
25
+ out: (line: string) => void;
26
+ err: (line: string) => void;
27
+ ask: (question: string) => Promise<string>;
28
+ /** The folder whose project configs are offered (default: the current folder). */
29
+ cwd?: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ /** Checks the key against the API (default: `GET /v1/health`). */
32
+ validateKey?: (apiKey: string, apiUrl: string) => Promise<boolean>;
33
+ /** Names the key's workspace (default: `GET /team-setup` with the key). */
34
+ client?: (apiKey: string, apiUrl: string) => {
35
+ getOwnTeamSetup(): Promise<TeamSetup>;
36
+ };
37
+ }
38
+ export type McpSetupResult = {
39
+ code: 0;
40
+ apiKey: string;
41
+ apiUrl: string;
42
+ workspace: string;
43
+ } | {
44
+ code: 1;
45
+ };
46
+ export declare function findConfigTargets(cwd?: string): McpConfigTarget[];
47
+ /**
48
+ * The `systemdox` server entry: the command and args, and in `env` only
49
+ * SYSTEMDOX_API_URL when it is not the default. Never the key. Other variables
50
+ * the member set on an existing entry are kept, and so is a `${NAME}`
51
+ * reference for SYSTEMDOX_API_KEY (the member's own environment, not a key).
52
+ * `workspace` is set as SYSTEMDOX_WORKSPACE when credentials.json holds keys
53
+ * for several workspaces, so the server knows which one to use.
54
+ */
55
+ export declare function serverEntry(apiUrl: string, existing?: unknown, workspace?: string): McpServerEntry;
56
+ /**
57
+ * Set the `systemdox` entry in the config at `path`, creating the file when
58
+ * absent; every other byte of an existing file is kept. Returns true when the
59
+ * entry held a key, which it no longer does.
60
+ */
61
+ export declare function writeServerEntry(path: string, apiUrl: string, workspace?: string): Promise<boolean>;
62
+ /**
63
+ * The first step of `setup`. Every line goes to `opts.out` / `opts.err`;
64
+ * code 1 means nothing was changed.
65
+ */
66
+ export declare function setupMcpServer(opts: McpSetupOptions): Promise<McpSetupResult>;
@@ -0,0 +1,630 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/setup-mcp.ts
4
+ import { existsSync as existsSync3, readFileSync } from "fs";
5
+ import { join as join4 } from "path";
6
+
7
+ // src/lib/cache.ts
8
+ var ResponseCache = class {
9
+ store = /* @__PURE__ */ new Map();
10
+ ttlMs;
11
+ constructor(ttlMs) {
12
+ this.ttlMs = ttlMs;
13
+ }
14
+ get(key) {
15
+ const entry = this.store.get(key);
16
+ if (!entry) return void 0;
17
+ if (Date.now() > entry.expiresAt) {
18
+ this.store.delete(key);
19
+ return void 0;
20
+ }
21
+ return entry.data;
22
+ }
23
+ set(key, data) {
24
+ this.store.set(key, {
25
+ data,
26
+ expiresAt: Date.now() + this.ttlMs
27
+ });
28
+ }
29
+ invalidate(key) {
30
+ this.store.delete(key);
31
+ }
32
+ clear() {
33
+ this.store.clear();
34
+ }
35
+ };
36
+
37
+ // src/lib/api-client.ts
38
+ var SystemDoxApiError = class extends Error {
39
+ constructor(status, text) {
40
+ super(`SystemDox API error ${status}: ${text}`);
41
+ this.status = status;
42
+ this.text = text;
43
+ let body;
44
+ try {
45
+ body = JSON.parse(text);
46
+ } catch {
47
+ body = void 0;
48
+ }
49
+ this.body = body;
50
+ const code = body?.code;
51
+ this.code = typeof code === "string" ? code : /\b(SYD-[A-Z]\d{3})\b/.exec(text)?.[1];
52
+ }
53
+ status;
54
+ text;
55
+ code;
56
+ body;
57
+ };
58
+ var TEAM_SETUP = "/v1/team-setup";
59
+ var seg = (s) => encodeURIComponent(String(s));
60
+ var SystemDoxApiClient = class {
61
+ config;
62
+ cache;
63
+ constructor(config) {
64
+ this.config = config;
65
+ this.cache = new ResponseCache(config.cacheTtlMs);
66
+ }
67
+ async get(path, params) {
68
+ const url = this.buildUrl(path, params);
69
+ const cacheKey = url.toString();
70
+ const cached = this.cache.get(cacheKey);
71
+ if (cached !== void 0) return cached;
72
+ const res = await this.fetchRaw(path, params);
73
+ if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
74
+ const data = await res.json();
75
+ this.cache.set(cacheKey, data);
76
+ return data;
77
+ }
78
+ /** A read that skips the cache: an install must take the version that is
79
+ * latest now, not the one cached before someone published another. */
80
+ async getFresh(path, signal) {
81
+ const res = await this.fetchRaw(path, void 0, signal);
82
+ if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
83
+ return await res.json();
84
+ }
85
+ async post(path, body) {
86
+ return this.mutate("POST", path, body);
87
+ }
88
+ async put(path, body) {
89
+ return this.mutate("PUT", path, body);
90
+ }
91
+ async mutate(method, path, body) {
92
+ const url = this.buildUrl(path);
93
+ const res = await fetch(url, {
94
+ method,
95
+ headers: {
96
+ // The API Gateway authorizer only reads the Authorization header;
97
+ // X-API-Key is kept for backwards compatibility.
98
+ Authorization: `Bearer ${this.config.apiKey}`,
99
+ "X-API-Key": this.config.apiKey,
100
+ "Content-Type": "application/json"
101
+ },
102
+ body: JSON.stringify(body)
103
+ });
104
+ if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
105
+ return await res.json();
106
+ }
107
+ // ── Team setup (ADR-217, specs/changes/team-setup/openapi.yaml) ──────────
108
+ // One method per operationId. Every read skips the cache: setup, sync and
109
+ // plugin-path act on what the workspace holds now.
110
+ /** getOwnTeamSetup: the key's own workspace, named in `workspace`. */
111
+ getOwnTeamSetup(signal) {
112
+ return this.getFresh(TEAM_SETUP, signal);
113
+ }
114
+ getTeamSetup(workspace) {
115
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}`);
116
+ }
117
+ getMarketplace(workspace) {
118
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/marketplace.json`);
119
+ }
120
+ getPart(workspace, part) {
121
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/parts/${seg(part)}`);
122
+ }
123
+ publishPartVersion(workspace, part, files) {
124
+ return this.post(`${TEAM_SETUP}/${seg(workspace)}/parts/${seg(part)}/versions`, { files });
125
+ }
126
+ getPartVersion(workspace, part, version) {
127
+ return this.getFresh(
128
+ `${TEAM_SETUP}/${seg(workspace)}/parts/${seg(part)}/versions/${seg(version)}`
129
+ );
130
+ }
131
+ getSetupSkills(workspace) {
132
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/skills`);
133
+ }
134
+ setSetupSkills(workspace, skills) {
135
+ return this.put(`${TEAM_SETUP}/${seg(workspace)}/skills`, { skills });
136
+ }
137
+ getPlugin(workspace) {
138
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/plugin`);
139
+ }
140
+ /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
141
+ * header. The caller checks the BYTES against the hash it was given. */
142
+ async getPluginZip(workspace, version) {
143
+ const res = await fetch(
144
+ this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
145
+ {
146
+ headers: {
147
+ Authorization: `Bearer ${this.config.apiKey}`,
148
+ "X-API-Key": this.config.apiKey,
149
+ Accept: "application/zip"
150
+ }
151
+ }
152
+ );
153
+ if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
154
+ return { bytes: Buffer.from(await res.arrayBuffer()), sha256: res.headers.get("x-content-sha256") };
155
+ }
156
+ listMemoryProjects(workspace) {
157
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/memory`);
158
+ }
159
+ getMemory(workspace, project) {
160
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}`);
161
+ }
162
+ /** pushMemoryVersion: a stale base throws SystemDoxApiError with status 409,
163
+ * code SYD-K010 and `body.currentVersion`. */
164
+ pushMemoryVersion(workspace, project, body) {
165
+ return this.post(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/versions`, body);
166
+ }
167
+ getMemoryVersion(workspace, project, version) {
168
+ return this.getFresh(
169
+ `${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/versions/${seg(version)}`
170
+ );
171
+ }
172
+ findMissingBlobs(workspace, project, hashes) {
173
+ return this.post(
174
+ `${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/blobs/missing`,
175
+ { hashes }
176
+ );
177
+ }
178
+ uploadBlobs(workspace, project, blobs) {
179
+ return this.post(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/blobs`, { blobs });
180
+ }
181
+ readBlobs(workspace, project, hashes) {
182
+ return this.post(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/blobs/read`, { hashes });
183
+ }
184
+ listValueAnswers(workspace) {
185
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/values`);
186
+ }
187
+ lookupValues(workspace, values) {
188
+ return this.post(`${TEAM_SETUP}/${seg(workspace)}/values`, { values });
189
+ }
190
+ answerValues(workspace, answers) {
191
+ return this.put(`${TEAM_SETUP}/${seg(workspace)}/values`, { answers });
192
+ }
193
+ fileMemoryPeople(workspace, people) {
194
+ return this.post(`${TEAM_SETUP}/${seg(workspace)}/people`, { people });
195
+ }
196
+ resolvePerson(workspace, personId) {
197
+ return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/people/${seg(personId)}`);
198
+ }
199
+ invalidateCache(path) {
200
+ if (path) {
201
+ this.cache.invalidate(this.buildUrl(path).toString());
202
+ } else {
203
+ this.cache.clear();
204
+ }
205
+ }
206
+ buildUrl(path, params) {
207
+ const base = this.config.apiUrl.replace(/\/+$/, "");
208
+ const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
209
+ if (params) {
210
+ for (const [key, value] of Object.entries(params)) {
211
+ url.searchParams.set(key, value);
212
+ }
213
+ }
214
+ return url;
215
+ }
216
+ async fetchRaw(path, params, signal) {
217
+ const url = this.buildUrl(path, params);
218
+ return fetch(url, {
219
+ headers: {
220
+ Authorization: `Bearer ${this.config.apiKey}`,
221
+ "X-API-Key": this.config.apiKey,
222
+ Accept: "application/json"
223
+ },
224
+ signal
225
+ });
226
+ }
227
+ };
228
+
229
+ // src/lib/config.ts
230
+ var API_URL_DEFAULT = "https://api.puglieseweb.com/systemdox";
231
+ function requireHttpsApiUrl(apiUrl) {
232
+ let parsed;
233
+ try {
234
+ parsed = new URL(apiUrl);
235
+ } catch {
236
+ throw new Error(
237
+ `SYSTEMDOX_API_URL is not a valid URL: ${apiUrl}`
238
+ );
239
+ }
240
+ if (parsed.protocol !== "https:") {
241
+ throw new Error(
242
+ `SYSTEMDOX_API_URL must use https:// \u2014 refusing to send the API key over ${parsed.protocol}// in cleartext. Got: ${apiUrl}`
243
+ );
244
+ }
245
+ return apiUrl;
246
+ }
247
+
248
+ // src/lib/team-setup-files.ts
249
+ import { execFile } from "child_process";
250
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
251
+ import { homedir, userInfo } from "os";
252
+ import { dirname, join } from "path";
253
+ import { promisify } from "util";
254
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
255
+ function isWorkspace(value) {
256
+ return typeof value === "string" && value.length <= 64 && SLUG_RE.test(value);
257
+ }
258
+ function homeDir() {
259
+ return homedir();
260
+ }
261
+ function systemdoxDir() {
262
+ return join(homeDir(), ".claude", "systemdox");
263
+ }
264
+ function credentialsPath() {
265
+ return join(systemdoxDir(), "credentials.json");
266
+ }
267
+ async function readJson(path, empty) {
268
+ let text;
269
+ try {
270
+ text = await readFile(path, "utf8");
271
+ } catch (e) {
272
+ if (e.code === "ENOENT") return empty;
273
+ throw e;
274
+ }
275
+ try {
276
+ return JSON.parse(text);
277
+ } catch {
278
+ throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
279
+ }
280
+ }
281
+ async function writeJsonAtomically(path, value, mode) {
282
+ await mkdir(dirname(path), { recursive: true });
283
+ const tmp = `${path}.${process.pid}.tmp`;
284
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
285
+ await rename(tmp, path);
286
+ }
287
+ async function readCredentials() {
288
+ return readJson(credentialsPath(), {});
289
+ }
290
+ var run = promisify(execFile);
291
+ async function restrictToMember(path) {
292
+ await chmod(path, 384);
293
+ if (process.platform === "win32") {
294
+ await run("icacls", [path, "/inheritance:r", "/grant:r", `${userInfo().username}:F`], { windowsHide: true });
295
+ }
296
+ }
297
+ async function writeCredentials(workspace, apiKey) {
298
+ if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
299
+ if (!apiKey) throw new Error("an API key is required");
300
+ const path = credentialsPath();
301
+ const credentials = await readCredentials();
302
+ credentials[workspace] = { apiKey };
303
+ await writeJsonAtomically(path, credentials, 384);
304
+ await restrictToMember(path);
305
+ }
306
+
307
+ // src/setup-team.ts
308
+ import { execFile as execFile2 } from "child_process";
309
+ import { createHash as createHash2 } from "crypto";
310
+ import { existsSync as existsSync2 } from "fs";
311
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
312
+ import { dirname as dirname3, join as join3, resolve as resolve2 } from "path";
313
+ import { promisify as promisify2 } from "util";
314
+
315
+ // src/lib/memory-sync.ts
316
+ import { createHash } from "crypto";
317
+
318
+ // src/lib/person-values.ts
319
+ var W = "[\\p{L}\\p{N}_]";
320
+ var D = "\\p{Nd}";
321
+ var WORD_OR = (extra) => `[\\p{L}\\p{N}_${extra}]`;
322
+ var PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
323
+ var PERSON_TOKEN = `\`${PERSON_ID}\`(?:[ \\t]*\\([^()\\n]*\\))?`;
324
+ var BEFORE = `(?<!${WORD_OR("+.:/\\\\\\-")})(?<!${D}[ -])`;
325
+ var AFTER = `(?!${WORD_OR(":/\\\\")}|\\.${D}|[ -]?${D}|-${W})`;
326
+ var E164 = `${BEFORE}\\+${D}+(?:[ -]${D}+)*${AFTER}`;
327
+ var NATIONAL = `${BEFORE}0${D}*(?:[ -]${D}+)*${AFTER}`;
328
+ var ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_{|}~\\-]";
329
+ var EMAIL_RE = "(?<![A-Za-z0-9!#$%&*+/=?^_{|}~.@\\-])[A-Za-z0-9_]" + ATEXT + "*(?:\\." + ATEXT + "+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}(?![A-Za-z0-9\\-]|\\.[A-Za-z0-9])";
330
+ var HOME_PREFIX = "(?:[A-Za-z]:\\\\+Users\\\\+|[A-Za-z]:/+Users/+|/Users/|/home/)";
331
+ var HOME = `(?<!${WORD_OR(".~:/\\\\$}\\-")})${HOME_PREFIX}[^\\\\/\\s"'\`<>|:*?]+`;
332
+ var KEY_PREFIX = `(?<!${W})(?:pweb_[a-z]+_key_|sk-ant-|sk_live_|rk_live_|github_pat_|gh[pousr]_|sntrys_|xox[baprs]-)[A-Za-z0-9_\\-]{4,}`;
333
+
334
+ // src/sync-memory.ts
335
+ import { existsSync } from "fs";
336
+ import { mkdir as mkdir2, readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
337
+ import { dirname as dirname2, join as join2, resolve } from "path";
338
+
339
+ // src/setup-team.ts
340
+ var SetupFailed = class extends Error {
341
+ };
342
+ var run2 = promisify2(execFile2);
343
+ function skipSpace(t, i) {
344
+ while (i < t.length && /\s/.test(t[i])) i++;
345
+ return i;
346
+ }
347
+ function skipString(t, i) {
348
+ for (i++; i < t.length; i++) {
349
+ if (t[i] === "\\") i++;
350
+ else if (t[i] === '"') return i + 1;
351
+ }
352
+ return i;
353
+ }
354
+ function skipValue(t, i) {
355
+ if (t[i] === '"') return skipString(t, i);
356
+ if (t[i] === "{" || t[i] === "[") {
357
+ let depth = 0;
358
+ while (i < t.length) {
359
+ const c = t[i];
360
+ if (c === '"') {
361
+ i = skipString(t, i);
362
+ continue;
363
+ }
364
+ if (c === "{" || c === "[") depth++;
365
+ else if (c === "}" || c === "]") {
366
+ depth--;
367
+ if (depth === 0) return i + 1;
368
+ }
369
+ i++;
370
+ }
371
+ return i;
372
+ }
373
+ while (i < t.length && !/[\s,}\]]/.test(t[i])) i++;
374
+ return i;
375
+ }
376
+ function objectMembers(t, open) {
377
+ const members = [];
378
+ let i = skipSpace(t, open + 1);
379
+ if (t[i] === "}") return { members, close: i };
380
+ for (; ; ) {
381
+ i = skipSpace(t, i);
382
+ const keyStart = i;
383
+ const keyEnd = skipString(t, i);
384
+ const key = JSON.parse(t.slice(keyStart, keyEnd));
385
+ i = skipSpace(t, skipSpace(t, keyEnd) + 1);
386
+ const valueEnd = skipValue(t, i);
387
+ members.push({ key, keyStart, valueStart: i, valueEnd });
388
+ i = skipSpace(t, valueEnd);
389
+ if (t[i] === ",") i++;
390
+ else return { members, close: i };
391
+ }
392
+ }
393
+ function lineIndent(t, pos) {
394
+ const start = t.lastIndexOf("\n", pos - 1) + 1;
395
+ return /^[ \t]*/.exec(t.slice(start))[0];
396
+ }
397
+ function setJsonMember(text, keys, value) {
398
+ const root = JSON.parse(text);
399
+ if (!root || typeof root !== "object" || Array.isArray(root)) throw new Error("it does not hold a JSON object");
400
+ const compact = !text.trimEnd().includes("\n");
401
+ const unit = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
402
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
403
+ const render = (v, indent) => compact ? JSON.stringify(v) : JSON.stringify(v, null, unit).split("\n").join(eol + indent);
404
+ const nest = (from) => keys.slice(from).reduceRight((acc, k) => ({ [k]: acc }), value);
405
+ let open = skipSpace(text, 0);
406
+ for (let k = 0; k < keys.length; k++) {
407
+ const { members, close } = objectMembers(text, open);
408
+ const found = members.filter((m) => m.key === keys[k]).at(-1);
409
+ if (found) {
410
+ if (k < keys.length - 1 && text[found.valueStart] === "{") {
411
+ open = found.valueStart;
412
+ continue;
413
+ }
414
+ return text.slice(0, found.valueStart) + render(nest(k + 1), lineIndent(text, found.keyStart)) + text.slice(found.valueEnd);
415
+ }
416
+ const name = JSON.stringify(keys[k]);
417
+ if (compact) {
418
+ const member = `${name}:${render(nest(k + 1), "")}`;
419
+ if (members.length) return text.slice(0, members.at(-1).valueEnd) + "," + member + text.slice(members.at(-1).valueEnd);
420
+ return text.slice(0, open + 1) + member + text.slice(close);
421
+ }
422
+ if (members.length) {
423
+ const last = members.at(-1);
424
+ const indent2 = lineIndent(text, last.keyStart);
425
+ return text.slice(0, last.valueEnd) + `,${eol}${indent2}${name}: ${render(nest(k + 1), indent2)}` + text.slice(last.valueEnd);
426
+ }
427
+ const outer = lineIndent(text, open);
428
+ const indent = outer + unit;
429
+ return text.slice(0, open + 1) + `${eol}${indent}${name}: ${render(nest(k + 1), indent)}${eol}${outer}` + text.slice(close);
430
+ }
431
+ return text;
432
+ }
433
+ function jsonAt(value, keys) {
434
+ let v = value;
435
+ for (const k of keys) v = v && typeof v === "object" ? v[k] : void 0;
436
+ return v;
437
+ }
438
+ async function setInJsonFile(path, keys, value) {
439
+ let text;
440
+ try {
441
+ text = await readFile3(path, "utf8");
442
+ } catch (e) {
443
+ if (e.code !== "ENOENT") throw e;
444
+ }
445
+ let next;
446
+ if (text === void 0 || !text.trim()) {
447
+ next = JSON.stringify(keys.reduceRight((acc, k) => ({ [k]: acc }), value), null, 2) + "\n";
448
+ } else {
449
+ let current;
450
+ try {
451
+ current = JSON.parse(text);
452
+ } catch {
453
+ throw new SetupFailed(`${path} is not valid JSON: fix it, then run setup again.`);
454
+ }
455
+ if (JSON.stringify(jsonAt(current, keys)) === JSON.stringify(value)) return;
456
+ try {
457
+ next = setJsonMember(text, keys, value);
458
+ } catch (e) {
459
+ throw new SetupFailed(`${path}: ${e.message}; fix it, then run setup again.`);
460
+ }
461
+ }
462
+ await mkdir3(dirname3(path), { recursive: true });
463
+ await writeFile3(path, next, "utf8");
464
+ }
465
+
466
+ // src/setup-mcp.ts
467
+ var isReference = (value) => typeof value === "string" && /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(value);
468
+ async function validateApiKey(apiKey, apiUrl) {
469
+ try {
470
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/health`, {
471
+ headers: {
472
+ Authorization: `Bearer ${apiKey}`,
473
+ "X-API-Key": apiKey,
474
+ Accept: "application/json"
475
+ }
476
+ });
477
+ return res.ok;
478
+ } catch {
479
+ return false;
480
+ }
481
+ }
482
+ function findConfigTargets(cwd = process.cwd()) {
483
+ const home = homeDir();
484
+ return [
485
+ // Claude Code: the project's .mcp.json, and ~/.claude.json (all projects)
486
+ { name: "Claude Code (project)", path: join4(cwd, ".mcp.json"), detected: true },
487
+ { name: "Claude Code (user \u2014 all projects)", path: join4(home, ".claude.json"), detected: true },
488
+ { name: "Cursor (project)", path: join4(cwd, ".cursor", "mcp.json"), detected: existsSync3(join4(cwd, ".cursor")) },
489
+ { name: "VS Code (project)", path: join4(cwd, ".vscode", "mcp.json"), detected: existsSync3(join4(cwd, ".vscode")) },
490
+ { name: "Cursor (global)", path: join4(home, ".cursor", "mcp.json"), detected: existsSync3(join4(home, ".cursor")) }
491
+ ];
492
+ }
493
+ function serverEntry(apiUrl, existing, workspace) {
494
+ const env = {};
495
+ const old = existing && typeof existing === "object" ? existing.env : void 0;
496
+ if (old && typeof old === "object" && !Array.isArray(old)) {
497
+ for (const [name, value] of Object.entries(old)) {
498
+ if (typeof value !== "string" || name === "SYSTEMDOX_API_URL" || name === "SYSTEMDOX_WORKSPACE") continue;
499
+ if (name === "SYSTEMDOX_API_KEY" && !isReference(value)) continue;
500
+ env[name] = value;
501
+ }
502
+ }
503
+ if (apiUrl !== API_URL_DEFAULT) env.SYSTEMDOX_API_URL = apiUrl;
504
+ if (workspace) env.SYSTEMDOX_WORKSPACE = workspace;
505
+ const entry = { command: "npx", args: ["@systemdox/mcp-server"] };
506
+ if (Object.keys(env).length) entry.env = env;
507
+ return entry;
508
+ }
509
+ function currentEntry(path) {
510
+ let text;
511
+ try {
512
+ text = readFileSync(path, "utf8");
513
+ } catch (e) {
514
+ if (e.code === "ENOENT") return void 0;
515
+ throw e;
516
+ }
517
+ if (!text.trim()) return void 0;
518
+ let config;
519
+ try {
520
+ config = JSON.parse(text);
521
+ } catch {
522
+ throw new Error(`${path} is not valid JSON: fix it, then run setup again.`);
523
+ }
524
+ const servers = config && typeof config === "object" ? config.mcpServers : void 0;
525
+ return servers && typeof servers === "object" ? servers.systemdox : void 0;
526
+ }
527
+ async function writeServerEntry(path, apiUrl, workspace) {
528
+ const existing = currentEntry(path);
529
+ const env = existing && typeof existing === "object" ? existing.env : void 0;
530
+ const heldKey = !!env && typeof env === "object" && "SYSTEMDOX_API_KEY" in env && !isReference(env.SYSTEMDOX_API_KEY);
531
+ await setInJsonFile(path, ["mcpServers", "systemdox"], serverEntry(apiUrl, existing, workspace));
532
+ return heldKey;
533
+ }
534
+ async function keyWorkspace(opts, apiKey, apiUrl) {
535
+ const client = opts.client?.(apiKey, apiUrl) ?? new SystemDoxApiClient({ apiKey, apiUrl, cacheTtlMs: 0, defaultOrg: void 0 });
536
+ try {
537
+ const { workspace } = await client.getOwnTeamSetup();
538
+ if (isWorkspace(workspace)) return workspace;
539
+ opts.out(` SystemDox named the workspace ${JSON.stringify(workspace)}, which is not a workspace name.`);
540
+ } catch (e) {
541
+ opts.out(` SystemDox did not name your key's workspace (${e.message}).`);
542
+ }
543
+ const fromEnv = (opts.env ?? process.env).SYSTEMDOX_WORKSPACE;
544
+ if (isWorkspace(fromEnv)) return fromEnv;
545
+ const typed = (await opts.ask(" Your workspace (acme in acme.systemdox.com): ")).trim().toLowerCase();
546
+ return isWorkspace(typed) ? typed : void 0;
547
+ }
548
+ async function setupMcpServer(opts) {
549
+ const { out, err, ask } = opts;
550
+ out("\n SystemDox MCP Server Setup\n");
551
+ const apiKey = (await ask(" Enter your SystemDox API key: ")).trim();
552
+ if (!apiKey) {
553
+ err("\n Error: API key is required.\n Generate one at your SystemDox settings page.\n");
554
+ return { code: 1 };
555
+ }
556
+ const apiUrl = (await ask(` API URL [${API_URL_DEFAULT}]: `)).trim() || API_URL_DEFAULT;
557
+ try {
558
+ requireHttpsApiUrl(apiUrl);
559
+ } catch (e) {
560
+ err(`
561
+ Error: ${e.message}
562
+ `);
563
+ return { code: 1 };
564
+ }
565
+ out("\n Validating API key...");
566
+ if (!await (opts.validateKey ?? validateApiKey)(apiKey, apiUrl)) {
567
+ err("\n Error: Invalid API key or cannot reach the SystemDox API.\n Check your key and try again.\n");
568
+ return { code: 1 };
569
+ }
570
+ out(" API key validated successfully.\n");
571
+ const workspace = await keyWorkspace(opts, apiKey, apiUrl);
572
+ if (!workspace) {
573
+ err("\n Error: that is not a workspace name (lower case letters, digits and -). Nothing was changed.\n");
574
+ return { code: 1 };
575
+ }
576
+ const detected = findConfigTargets(opts.cwd).filter((t) => t.detected);
577
+ out(" Available targets:");
578
+ detected.forEach((t, i) => out(` ${i + 1}. ${t.name} (${t.path})`));
579
+ const choice = (await ask(`
580
+ Which target? [1-${detected.length}, or 'all']: `)).trim();
581
+ let selected;
582
+ if (choice.toLowerCase() === "all") {
583
+ selected = detected;
584
+ } else {
585
+ const idx = parseInt(choice, 10) - 1;
586
+ if (isNaN(idx) || idx < 0 || idx >= detected.length) {
587
+ err("\n Invalid selection.\n");
588
+ return { code: 1 };
589
+ }
590
+ selected = [detected[idx]];
591
+ }
592
+ try {
593
+ if ((await readCredentials())[workspace]?.apiKey !== apiKey) await writeCredentials(workspace, apiKey);
594
+ const several = Object.keys(await readCredentials()).length > 1;
595
+ for (const target of selected) {
596
+ const removed = await writeServerEntry(target.path, apiUrl, several ? workspace : void 0);
597
+ out(` Configured: ${target.name}`);
598
+ if (removed) out(` The key now lives in ~/.claude/systemdox/credentials.json and was removed from ${target.path}.`);
599
+ }
600
+ } catch (e) {
601
+ err(`
602
+ Error: ${e.message}
603
+ `);
604
+ return { code: 1 };
605
+ }
606
+ out(
607
+ `
608
+ Your key for ${workspace} is in ${credentialsPath()} (readable by you alone), not in these configs.
609
+
610
+ Done! Restart your AI coding tool to activate the SystemDox MCP server.
611
+ The server provides 25 tools: get_context, get_team_setup, get_checks,
612
+ get_project_rules, get_prompt_templates, get_specs, get_docs,
613
+ get_tech_stack, get_planning_context, get_plan_board, get_change,
614
+ get_document_links, trace_document_path, get_graph_stats,
615
+ create_check, create_project_rule, create_prompt_template,
616
+ update_spec, report_decision, publish_skill, list_skills,
617
+ install_skill, publish_setup_part, resolve_person, sync_memory.
618
+
619
+ get_guardrails and create_guardrail remain registered as deprecated
620
+ aliases of get_checks and create_check \u2014 existing configs keep working.
621
+ `
622
+ );
623
+ return { code: 0, apiKey, apiUrl, workspace };
624
+ }
625
+ export {
626
+ findConfigTargets,
627
+ serverEntry,
628
+ setupMcpServer,
629
+ writeServerEntry
630
+ };