@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.
@@ -17,7 +17,8 @@
17
17
  * 4. Finish: the two steps a person must take in Claude Code.
18
18
  *
19
19
  * It never runs `claude plugin marketplace add` and never sets
20
- * `enabledPlugins`. The key is written to credentials.json only.
20
+ * `enabledPlugins`. The key is written to credentials.json only (the MCP
21
+ * server step, setup-mcp.ts, has stored it there already).
21
22
  *
22
23
  * Exit codes: 0 done; 1 usage, no key or the API refused; 2 an edited team
23
24
  * CLAUDE.md would be replaced; 3 Claude Code missing or too old; 4 not
@@ -60,6 +61,9 @@ export declare function claudeCodeVersion(): Promise<string | undefined>;
60
61
  * last one in the file's own indentation and line ending.
61
62
  */
62
63
  export declare function setJsonMember(text: string, keys: string[], value: unknown): string;
64
+ /** Set `keys` to `value` in the JSON file at `path`, creating it when absent;
65
+ * nothing is written when it already holds that value. */
66
+ export declare function setInJsonFile(path: string, keys: string[], value: unknown): Promise<void>;
63
67
  export declare function importBlock(workspace: string, eol?: string): string;
64
68
  /** `text` with the workspace's marked block added at the end, or replaced
65
69
  * where it is; nothing outside the block changes. */
@@ -68,14 +68,6 @@ var SystemDoxApiClient = class {
68
68
  this.config = config;
69
69
  this.cache = new ResponseCache(config.cacheTtlMs);
70
70
  }
71
- async validate() {
72
- const res = await this.fetchRaw("/v1/health");
73
- if (!res.ok) {
74
- throw new Error(
75
- `SystemDox API authentication failed (${res.status}). Check your SYSTEMDOX_API_KEY.`
76
- );
77
- }
78
- }
79
71
  async get(path, params) {
80
72
  const url = this.buildUrl(path, params);
81
73
  const cacheKey = url.toString();
@@ -89,8 +81,8 @@ var SystemDoxApiClient = class {
89
81
  }
90
82
  /** A read that skips the cache: an install must take the version that is
91
83
  * latest now, not the one cached before someone published another. */
92
- async getFresh(path) {
93
- const res = await this.fetchRaw(path);
84
+ async getFresh(path, signal) {
85
+ const res = await this.fetchRaw(path, void 0, signal);
94
86
  if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
95
87
  return await res.json();
96
88
  }
@@ -120,8 +112,8 @@ var SystemDoxApiClient = class {
120
112
  // One method per operationId. Every read skips the cache: setup, sync and
121
113
  // plugin-path act on what the workspace holds now.
122
114
  /** getOwnTeamSetup: the key's own workspace, named in `workspace`. */
123
- getOwnTeamSetup() {
124
- return this.getFresh(TEAM_SETUP);
115
+ getOwnTeamSetup(signal) {
116
+ return this.getFresh(TEAM_SETUP, signal);
125
117
  }
126
118
  getTeamSetup(workspace) {
127
119
  return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}`);
@@ -225,14 +217,15 @@ var SystemDoxApiClient = class {
225
217
  }
226
218
  return url;
227
219
  }
228
- async fetchRaw(path, params) {
220
+ async fetchRaw(path, params, signal) {
229
221
  const url = this.buildUrl(path, params);
230
222
  return fetch(url, {
231
223
  headers: {
232
224
  Authorization: `Bearer ${this.config.apiKey}`,
233
225
  "X-API-Key": this.config.apiKey,
234
226
  Accept: "application/json"
235
- }
227
+ },
228
+ signal
236
229
  });
237
230
  }
238
231
  };
@@ -1588,6 +1581,7 @@ export {
1588
1581
  parseSetupArgs,
1589
1582
  runTeamSetup,
1590
1583
  runTeamSetupCommand,
1584
+ setInJsonFile,
1591
1585
  setJsonMember,
1592
1586
  withImportBlock
1593
1587
  };
@@ -65,14 +65,6 @@ var SystemDoxApiClient = class {
65
65
  this.config = config;
66
66
  this.cache = new ResponseCache(config.cacheTtlMs);
67
67
  }
68
- async validate() {
69
- const res = await this.fetchRaw("/v1/health");
70
- if (!res.ok) {
71
- throw new Error(
72
- `SystemDox API authentication failed (${res.status}). Check your SYSTEMDOX_API_KEY.`
73
- );
74
- }
75
- }
76
68
  async get(path, params) {
77
69
  const url = this.buildUrl(path, params);
78
70
  const cacheKey = url.toString();
@@ -86,8 +78,8 @@ var SystemDoxApiClient = class {
86
78
  }
87
79
  /** A read that skips the cache: an install must take the version that is
88
80
  * latest now, not the one cached before someone published another. */
89
- async getFresh(path) {
90
- const res = await this.fetchRaw(path);
81
+ async getFresh(path, signal) {
82
+ const res = await this.fetchRaw(path, void 0, signal);
91
83
  if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
92
84
  return await res.json();
93
85
  }
@@ -117,8 +109,8 @@ var SystemDoxApiClient = class {
117
109
  // One method per operationId. Every read skips the cache: setup, sync and
118
110
  // plugin-path act on what the workspace holds now.
119
111
  /** getOwnTeamSetup: the key's own workspace, named in `workspace`. */
120
- getOwnTeamSetup() {
121
- return this.getFresh(TEAM_SETUP);
112
+ getOwnTeamSetup(signal) {
113
+ return this.getFresh(TEAM_SETUP, signal);
122
114
  }
123
115
  getTeamSetup(workspace) {
124
116
  return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}`);
@@ -222,14 +214,15 @@ var SystemDoxApiClient = class {
222
214
  }
223
215
  return url;
224
216
  }
225
- async fetchRaw(path, params) {
217
+ async fetchRaw(path, params, signal) {
226
218
  const url = this.buildUrl(path, params);
227
219
  return fetch(url, {
228
220
  headers: {
229
221
  Authorization: `Bearer ${this.config.apiKey}`,
230
222
  "X-API-Key": this.config.apiKey,
231
223
  Accept: "application/json"
232
- }
224
+ },
225
+ signal
233
226
  });
234
227
  }
235
228
  };
@@ -650,6 +643,27 @@ async function readCredentials() {
650
643
  return readJson(credentialsPath(), {});
651
644
  }
652
645
  var run = promisify(execFile);
646
+ async function localApiKey(env = process.env) {
647
+ if (env.SYSTEMDOX_API_KEY) return env.SYSTEMDOX_API_KEY;
648
+ const keys = Object.entries(await readCredentials()).filter(
649
+ (e) => typeof e[1]?.apiKey === "string" && e[1].apiKey.length > 0
650
+ );
651
+ const wanted = env.SYSTEMDOX_WORKSPACE;
652
+ if (wanted) {
653
+ const key = keys.find(([ws]) => ws === wanted)?.[1].apiKey;
654
+ if (key) return key;
655
+ throw new Error(
656
+ `~/.claude/systemdox/credentials.json holds no key for ${wanted} (SYSTEMDOX_WORKSPACE). Set SYSTEMDOX_API_KEY, or run npx -y @systemdox/mcp-server setup.`
657
+ );
658
+ }
659
+ if (keys.length === 1) return keys[0][1].apiKey;
660
+ if (keys.length > 1) {
661
+ throw new Error(
662
+ `~/.claude/systemdox/credentials.json holds keys for ${keys.map(([ws]) => ws).join(", ")}: set SYSTEMDOX_WORKSPACE to the workspace to use, or SYSTEMDOX_API_KEY to its key.`
663
+ );
664
+ }
665
+ throw new Error("No SystemDox key. Set SYSTEMDOX_API_KEY, or run npx -y @systemdox/mcp-server setup.");
666
+ }
653
667
  function apiUrlFrom(env = process.env) {
654
668
  return requireHttpsApiUrl(env.SYSTEMDOX_API_URL || API_URL_DEFAULT);
655
669
  }
@@ -1055,17 +1069,6 @@ async function upload(client, ws, project, plan, team, report) {
1055
1069
  }
1056
1070
  await send();
1057
1071
  }
1058
- async function keyFromEnvironment(env) {
1059
- if (env.SYSTEMDOX_API_KEY) return env.SYSTEMDOX_API_KEY;
1060
- const entries2 = Object.entries(await readCredentials()).filter(([, v]) => typeof v?.apiKey === "string" && v.apiKey);
1061
- if (entries2.length === 1) return entries2[0][1].apiKey;
1062
- if (entries2.length > 1) {
1063
- throw new SyncFailed(
1064
- `~/.claude/systemdox/credentials.json holds keys for ${entries2.map(([w]) => w).join(", ")}: set SYSTEMDOX_API_KEY to the key of the workspace to sync.`
1065
- );
1066
- }
1067
- throw new SyncFailed("No SystemDox key. Set SYSTEMDOX_API_KEY, or run npx -y @systemdox/mcp-server setup, then run this again.");
1068
- }
1069
1072
  async function syncMemory(opts) {
1070
1073
  const { client, out } = opts;
1071
1074
  const now = opts.now?.() ?? /* @__PURE__ */ new Date();
@@ -1201,7 +1204,7 @@ async function runSyncMemory(argv, io, env = process.env) {
1201
1204
  args = parseSyncMemoryArgs(argv);
1202
1205
  if (!client) {
1203
1206
  const apiUrl = apiUrlFrom(env);
1204
- const apiKey = await keyFromEnvironment(env);
1207
+ const apiKey = await localApiKey(env);
1205
1208
  client = new SystemDoxApiClient({ apiKey, apiUrl, cacheTtlMs: 0, defaultOrg: void 0 });
1206
1209
  }
1207
1210
  } catch (e) {
@@ -0,0 +1,87 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { SystemDoxApiClient } from "../lib/api-client.js";
3
+ /**
4
+ * The document graph over MCP (ADR-224, specs/changes/document-graph/contract.md §5).
5
+ * Three read-only tools on the hosted and the local server. Every answer says
6
+ * how each link is known, and one that was cut says so.
7
+ */
8
+ interface GraphNode {
9
+ id: string;
10
+ label: string;
11
+ type: string;
12
+ repo: string;
13
+ path: string;
14
+ }
15
+ interface NeighbourLink {
16
+ direction: "out" | "in";
17
+ relation: string;
18
+ provenance: string;
19
+ confidence: number;
20
+ file: string;
21
+ line: number;
22
+ other: GraphNode;
23
+ }
24
+ interface AmbiguousLink {
25
+ reference: string;
26
+ file: string;
27
+ line: number;
28
+ candidates: GraphNode[];
29
+ }
30
+ interface Neighbours {
31
+ doc: GraphNode;
32
+ links: NeighbourLink[];
33
+ ambiguous: AmbiguousLink[];
34
+ total: number;
35
+ truncated: boolean;
36
+ }
37
+ interface PathStep {
38
+ from: GraphNode;
39
+ to: GraphNode;
40
+ relation: string;
41
+ provenance: string;
42
+ confidence: number;
43
+ file: string;
44
+ line: number;
45
+ reversed: boolean;
46
+ }
47
+ interface GraphPath {
48
+ found: boolean;
49
+ hops: number;
50
+ confidence: number;
51
+ steps: PathStep[];
52
+ }
53
+ interface RepoFreshness {
54
+ repo: string;
55
+ documents: number;
56
+ links: number;
57
+ indexedCommit: string;
58
+ indexedAt: string;
59
+ headCommit: string;
60
+ pushedAt: string;
61
+ behind: boolean;
62
+ }
63
+ interface GraphStats {
64
+ nodeCount: number;
65
+ edgeCount: number;
66
+ links: {
67
+ declared: number;
68
+ extracted: number;
69
+ inferred: number;
70
+ ambiguous: number;
71
+ };
72
+ repos: RepoFreshness[];
73
+ }
74
+ interface RepoLink {
75
+ source: string;
76
+ target: string;
77
+ relation: string;
78
+ provenance: string;
79
+ confidence: number;
80
+ file: string;
81
+ line: number;
82
+ }
83
+ export declare function formatNeighbours(answer: Neighbours): string;
84
+ export declare function formatPath(answer: GraphPath, maxHops: number): string;
85
+ export declare function formatStats(stats: GraphStats, repoLinks: RepoLink[]): string;
86
+ export declare function registerDocumentGraph(server: McpServer, apiClient: SystemDoxApiClient): void;
87
+ export {};
@@ -0,0 +1,134 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { SystemDoxApiClient } from "../lib/api-client.js";
3
+ /**
4
+ * get_change (ADR-218, specs/changes/change-page/contract.md §5.1): one change
5
+ * as SystemDox last synced it, in one read of GET /v1/changes/{owner}/{repo}/{slug}
6
+ * (openapi.yaml, operationId getChange). The answer is Markdown — the title,
7
+ * key and size; the parts with their states and pull requests; the decision;
8
+ * the contract files; every feature file with its scenarios as
9
+ * `id · state · title`; the plan; the release pull requests — then the Change
10
+ * object whole in a fenced JSON block, and last the brief's verdict. A build
11
+ * session calls it first and stops when the brief is not passed (the
12
+ * *Implement story* prompt template's first step).
13
+ */
14
+ /** ADR-218 §2.3.2's five words, read from GitHub, never written by SystemDox. */
15
+ export type State = "not yet" | "waiting for your review" | "passed" | "being built" | "done";
16
+ export interface PullRequest {
17
+ number: number;
18
+ title: string;
19
+ state: "open" | "merged";
20
+ base: string;
21
+ repo: string;
22
+ openedAt: string;
23
+ mergedAt: string | null;
24
+ url: string;
25
+ approvingReview: {
26
+ by: string;
27
+ at: string;
28
+ } | null;
29
+ }
30
+ export interface TrackedFile {
31
+ path: string;
32
+ docId: string;
33
+ pullRequest: PullRequest | null;
34
+ /** The file's last commit belongs to no pull request; it counts as merged. */
35
+ direct: boolean;
36
+ }
37
+ export interface ScenarioCounts {
38
+ proposed: number;
39
+ built: number;
40
+ defect: number;
41
+ unbuilt: number;
42
+ open: number;
43
+ untagged: number;
44
+ total: number;
45
+ }
46
+ export interface Scenario {
47
+ id: string;
48
+ title: string;
49
+ /** The state tag without `@`; empty when untagged. */
50
+ state: "proposed" | "built" | "defect" | "unbuilt" | "open" | "";
51
+ steps: string[];
52
+ }
53
+ export interface FeatureFile extends TrackedFile {
54
+ title: string;
55
+ runner: "pytest-bdd" | "vitest" | "node:test" | "";
56
+ scenarios: Scenario[];
57
+ counts: ScenarioCounts;
58
+ }
59
+ export interface Board {
60
+ number: number;
61
+ title: string;
62
+ url: string;
63
+ items: {
64
+ todo: number;
65
+ inProgress: number;
66
+ done: number;
67
+ truncated: boolean;
68
+ };
69
+ }
70
+ export interface Change {
71
+ key: string;
72
+ owner: string;
73
+ repo: string;
74
+ slug: string;
75
+ title: string;
76
+ size: "small" | "ordinary" | "foundational";
77
+ archived: boolean;
78
+ /** `decision` is null when the change has no decision (a small change). */
79
+ states: {
80
+ decision: State | null;
81
+ contract: State;
82
+ behaviour: State;
83
+ plan: State;
84
+ release: State;
85
+ };
86
+ counts: ScenarioCounts;
87
+ waitingForReview: PullRequest[];
88
+ lastActivity: string;
89
+ syncedAt: string;
90
+ proposal: TrackedFile;
91
+ decision: {
92
+ adr: string;
93
+ title: string;
94
+ status: string;
95
+ docId: string;
96
+ pullRequest: PullRequest | null;
97
+ } | null;
98
+ contract: {
99
+ files: TrackedFile[];
100
+ operations: {
101
+ method: string;
102
+ path: string;
103
+ operationId: string;
104
+ summary: string;
105
+ }[];
106
+ channels: {
107
+ name: string;
108
+ address: string;
109
+ messages: string[];
110
+ }[];
111
+ };
112
+ behaviour: {
113
+ files: FeatureFile[];
114
+ counts: ScenarioCounts;
115
+ };
116
+ plan: {
117
+ file: TrackedFile | null;
118
+ draftId: string | null;
119
+ board: Board | null;
120
+ };
121
+ release: {
122
+ prs: PullRequest[];
123
+ };
124
+ }
125
+ /**
126
+ * The answer's last line (contract.md §5.1): "The brief is passed", or "The
127
+ * brief is not passed: <part> is <state>", naming each part that waits in the
128
+ * page's order. A change without a decision (`states.decision` null) is not
129
+ * held up by one.
130
+ */
131
+ export declare function briefVerdict(states: Change["states"]): string;
132
+ /** The answer of contract.md §5.1, its verdict on the last line. */
133
+ export declare function formatChange(change: Change): string;
134
+ export declare function registerGetChange(server: McpServer, apiClient: Pick<SystemDoxApiClient, "getFresh">): void;
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/tools/get-change.ts
4
+ import { z } from "zod";
5
+ var GITHUB_NAME = "[A-Za-z0-9][A-Za-z0-9._-]{0,99}";
6
+ var REPO_RE = new RegExp(`^${GITHUB_NAME}/${GITHUB_NAME}$`);
7
+ var SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
8
+ var SLUG_MAX = 64;
9
+ var PARTS = ["decision", "contract", "behaviour", "plan", "release"];
10
+ var BRIEF = ["decision", "contract", "behaviour", "plan"];
11
+ var PAST_REVIEW = /* @__PURE__ */ new Set(["passed", "being built", "done"]);
12
+ function briefVerdict(states) {
13
+ const waiting = BRIEF.filter(
14
+ (part) => !(part === "decision" && states.decision === null) && !PAST_REVIEW.has(String(states[part]))
15
+ ).map((part) => `${part} is ${states[part] ?? "unknown"}`);
16
+ return waiting.length === 0 ? "The brief is passed" : `The brief is not passed: ${waiting.join(", ")}`;
17
+ }
18
+ function prLink(pr) {
19
+ return `[${pr.repo}#${pr.number}](${pr.url}) ${pr.state}`;
20
+ }
21
+ function fileSource(file) {
22
+ if (file.pullRequest) return prLink(file.pullRequest);
23
+ return file.direct ? "committed directly" : "\u2014";
24
+ }
25
+ function partPullRequests(change, part) {
26
+ const files = part === "contract" ? change.contract.files : part === "behaviour" ? change.behaviour.files : part === "plan" ? [change.plan.file] : [];
27
+ const prs = part === "decision" ? [change.decision?.pullRequest ?? null] : part === "release" ? change.release.prs : files.map((file) => file?.pullRequest ?? null);
28
+ const unique = /* @__PURE__ */ new Map();
29
+ for (const pr of prs) {
30
+ if (pr && !unique.has(`${pr.repo}#${pr.number}`)) unique.set(`${pr.repo}#${pr.number}`, pr);
31
+ }
32
+ return [...unique.values()].sort((a, b) => Number(b.state === "open") - Number(a.state === "open"));
33
+ }
34
+ function countsLine(counts) {
35
+ const tags = ["proposed", "built", "defect", "unbuilt", "open", "untagged"].filter((tag) => counts[tag] > 0).map((tag) => `${counts[tag]} ${tag}`);
36
+ return `${counts.total} scenario(s)` + (tags.length > 0 ? `: ${tags.join(", ")}` : "");
37
+ }
38
+ function partsTable(change) {
39
+ const rows = PARTS.filter((part) => !(part === "decision" && change.states.decision === null)).map((part) => {
40
+ const prs = partPullRequests(change, part);
41
+ return `| ${part} | ${change.states[part]} | ${prs.length > 0 ? prs.map(prLink).join(", ") : "\u2014"} |`;
42
+ });
43
+ return ["| Part | State | Pull request |", "|---|---|---|", ...rows].join("\n");
44
+ }
45
+ function decisionSection(change) {
46
+ const decision = change.decision;
47
+ if (decision === null) return "## Decision\n\nNone: the proposal names no ADR (a small change).";
48
+ const status = decision.status || "no record in a connected repository";
49
+ return `## Decision
50
+
51
+ ${decision.adr} \xB7 ${status}${decision.title ? ` \u2014 ${decision.title}` : ""}`;
52
+ }
53
+ function contractSection(change) {
54
+ const files = change.contract.files;
55
+ return "## Contract\n\n" + (files.length > 0 ? files.map((file) => `- ${file.path} \u2014 ${fileSource(file)}`).join("\n") : "No contract file yet (openapi.yaml, asyncapi.yaml or contract.md).");
56
+ }
57
+ function behaviourSection(change) {
58
+ const { files, counts } = change.behaviour;
59
+ if (files.length === 0) {
60
+ return `## Behaviour
61
+
62
+ No feature file names this change. A feature file joins it when its path or its first ten lines name \`${change.slug}\`${change.decision ? ` or ${change.decision.adr}` : ""}.`;
63
+ }
64
+ const blocks = files.map(
65
+ (file) => [
66
+ `### ${file.path}`,
67
+ "",
68
+ [file.title, file.runner ? `runner ${file.runner}` : "", fileSource(file), countsLine(file.counts)].filter(Boolean).join(" \xB7 "),
69
+ "",
70
+ ...file.scenarios.map((s) => `- ${s.id || "(no id)"} \xB7 ${s.state || "untagged"} \xB7 ${s.title}`)
71
+ ].join("\n")
72
+ );
73
+ return ["## Behaviour", countsLine(counts), ...blocks].join("\n\n");
74
+ }
75
+ function planSection(change) {
76
+ const { file, draftId, board } = change.plan;
77
+ const boardLine = board ? `[#${board.number} ${board.title}](${board.url}) \u2014 todo ${board.items.todo} \xB7 in progress ${board.items.inProgress} \xB7 done ${board.items.done}` + (board.items.truncated ? " (counted over its first 500 items)" : "") : "none";
78
+ return [
79
+ "## Plan",
80
+ "",
81
+ `- plan.md: ${file ? `${file.path} \u2014 ${fileSource(file)}` : "none"}`,
82
+ `- Draft: ${draftId ?? "none"}`,
83
+ `- Board: ${boardLine}`
84
+ ].join("\n");
85
+ }
86
+ function releaseSection(change) {
87
+ const lines = change.release.prs.map(
88
+ (pr) => `- ${prLink(pr)} \xB7 ${pr.title} \xB7 ` + (pr.approvingReview ? `approved by ${pr.approvingReview.by} at ${pr.approvingReview.at}` : "no approving review on its head yet")
89
+ );
90
+ return `## Release
91
+
92
+ ${lines.length > 0 ? lines.join("\n") : "No release pull request is open."}`;
93
+ }
94
+ function formatChange(change) {
95
+ const header = `# ${change.title}
96
+
97
+ \`${change.key}\` \xB7 size: ${change.size}` + (change.archived ? " \xB7 archived: read-only, done" : "") + ` \xB7 synced ${change.syncedAt}`;
98
+ return [
99
+ header,
100
+ partsTable(change),
101
+ decisionSection(change),
102
+ contractSection(change),
103
+ behaviourSection(change),
104
+ planSection(change),
105
+ releaseSection(change),
106
+ // Compact: a change's every scenario with its steps rides in this block.
107
+ "```json\n" + JSON.stringify(change) + "\n```",
108
+ briefVerdict(change.states)
109
+ ].join("\n\n");
110
+ }
111
+ function registerGetChange(server, apiClient) {
112
+ server.registerTool(
113
+ "get_change",
114
+ {
115
+ title: "Get Change",
116
+ description: `Read one change (a specs/changes/<slug>/ folder of a connected repository) as SystemDox last synced it from GitHub: the state of each part of its brief (decision, contract, behaviour, plan) and of its release, each with its pull request; the decision's ADR and status; the contract files; every feature file that names the change, with its scenarios as id \xB7 state \xB7 title; the plan (plan.md, the draft, the board); the release pull requests; and the Change object as JSON. The last line says "The brief is passed" or "The brief is not passed: <part> is <state>". Call it before building a story, and stop if the brief is not passed or the story's scenarios are not proposed. Read-only.`,
117
+ inputSchema: {
118
+ repo: z.string().regex(REPO_RE, "the repository as owner/name, e.g. acme/booking-api").describe("The repository that holds the change, as owner/name"),
119
+ slug: z.string().max(SLUG_MAX).regex(SLUG_RE, "a change folder's name: lower-case words joined by -, e.g. customer-reminders").describe("The change's folder name: specs/changes/<slug>/")
120
+ },
121
+ annotations: { readOnlyHint: true }
122
+ },
123
+ async ({ repo, slug }) => {
124
+ const [owner, name] = repo.split("/");
125
+ let change;
126
+ try {
127
+ change = await apiClient.getFresh(
128
+ `/v1/changes/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${encodeURIComponent(slug)}`
129
+ );
130
+ } catch (err) {
131
+ if (err.status === 404) {
132
+ return {
133
+ content: [{ type: "text", text: `No such change ${repo}/${slug}` }],
134
+ isError: true
135
+ };
136
+ }
137
+ throw err;
138
+ }
139
+ return { content: [{ type: "text", text: formatChange(change) }] };
140
+ }
141
+ );
142
+ }
143
+ export {
144
+ briefVerdict,
145
+ formatChange,
146
+ registerGetChange
147
+ };
@@ -0,0 +1,6 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { SystemDoxApiClient } from "../lib/api-client.js";
3
+ export declare function registerGetTeamSetup(server: McpServer, apiClient: SystemDoxApiClient, { localFiles, keyProblem }: {
4
+ localFiles: boolean;
5
+ keyProblem?: string;
6
+ }): void;
@@ -29,6 +29,7 @@ import { homedir } from "os";
29
29
  import { dirname, join, relative, resolve, sep } from "path";
30
30
  var MANIFEST_FILE = ".systemdox-skill.json";
31
31
  var ENTRY_FILE = "SKILL.md";
32
+ var PLUGIN_MANIFEST_FOLDER = ".claude-plugin";
32
33
  var MAX_FILES = 100;
33
34
  var MAX_FILE_BYTES = 256 * 1024;
34
35
  var MAX_TOTAL_BYTES = 1024 * 1024;
@@ -53,7 +54,7 @@ function isValidName(name) {
53
54
  return name.length <= 64 && NAME_RE.test(name);
54
55
  }
55
56
  function isValidPath(path) {
56
- return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
57
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== ".." && s !== PLUGIN_MANIFEST_FOLDER);
57
58
  }
58
59
  function frontmatterName(skillMd) {
59
60
  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
@@ -291,7 +292,7 @@ Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
291
292
  const { skills } = await apiClient.get(SKILLS);
292
293
  if (!skills.length) {
293
294
  return text(
294
- `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient))}`
295
+ `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/team-setup/skills", await workspaceBase(apiClient))}`
295
296
  );
296
297
  }
297
298
  return text(
@@ -14,13 +14,14 @@ import { existsSync } from "fs";
14
14
  import { homedir } from "os";
15
15
  import { dirname, join, relative, resolve, sep } from "path";
16
16
  var MANIFEST_FILE = ".systemdox-skill.json";
17
+ var PLUGIN_MANIFEST_FOLDER = ".claude-plugin";
17
18
  var MAX_FILES = 100;
18
19
  var MAX_FILE_BYTES = 256 * 1024;
19
20
  var MAX_TOTAL_BYTES = 1024 * 1024;
20
21
  var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
21
22
  var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
22
23
  function isValidPath(path) {
23
- return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
24
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== ".." && s !== PLUGIN_MANIFEST_FOLDER);
24
25
  }
25
26
  async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
26
27
  for (const entry of await readdir(dir, { withFileTypes: true })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@systemdox/mcp-server",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "mcpName": "io.github.puglieseweb/systemdox",
5
5
  "type": "module",
6
6
  "description": "Give AI coding agents your architecture context over MCP — checks, specs, ADRs, project rules, and docs from SystemDox, delivered at write time",