@systemdox/mcp-server 0.1.47 → 0.1.49

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.
@@ -650,6 +650,27 @@ async function readCredentials() {
650
650
  return readJson(credentialsPath(), {});
651
651
  }
652
652
  var run = promisify(execFile);
653
+ async function localApiKey(env = process.env) {
654
+ if (env.SYSTEMDOX_API_KEY) return env.SYSTEMDOX_API_KEY;
655
+ const keys = Object.entries(await readCredentials()).filter(
656
+ (e) => typeof e[1]?.apiKey === "string" && e[1].apiKey.length > 0
657
+ );
658
+ const wanted = env.SYSTEMDOX_WORKSPACE;
659
+ if (wanted) {
660
+ const key = keys.find(([ws]) => ws === wanted)?.[1].apiKey;
661
+ if (key) return key;
662
+ throw new Error(
663
+ `~/.claude/systemdox/credentials.json holds no key for ${wanted} (SYSTEMDOX_WORKSPACE). Set SYSTEMDOX_API_KEY, or run npx -y @systemdox/mcp-server setup.`
664
+ );
665
+ }
666
+ if (keys.length === 1) return keys[0][1].apiKey;
667
+ if (keys.length > 1) {
668
+ throw new Error(
669
+ `~/.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.`
670
+ );
671
+ }
672
+ throw new Error("No SystemDox key. Set SYSTEMDOX_API_KEY, or run npx -y @systemdox/mcp-server setup.");
673
+ }
653
674
  function apiUrlFrom(env = process.env) {
654
675
  return requireHttpsApiUrl(env.SYSTEMDOX_API_URL || API_URL_DEFAULT);
655
676
  }
@@ -1055,17 +1076,6 @@ async function upload(client, ws, project, plan, team, report) {
1055
1076
  }
1056
1077
  await send();
1057
1078
  }
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
1079
  async function syncMemory(opts) {
1070
1080
  const { client, out } = opts;
1071
1081
  const now = opts.now?.() ?? /* @__PURE__ */ new Date();
@@ -1201,7 +1211,7 @@ async function runSyncMemory(argv, io, env = process.env) {
1201
1211
  args = parseSyncMemoryArgs(argv);
1202
1212
  if (!client) {
1203
1213
  const apiUrl = apiUrlFrom(env);
1204
- const apiKey = await keyFromEnvironment(env);
1214
+ const apiKey = await localApiKey(env);
1205
1215
  client = new SystemDoxApiClient({ apiKey, apiUrl, cacheTtlMs: 0, defaultOrg: void 0 });
1206
1216
  }
1207
1217
  } catch (e) {
@@ -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
+ };
@@ -291,7 +291,7 @@ Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
291
291
  const { skills } = await apiClient.get(SKILLS);
292
292
  if (!skills.length) {
293
293
  return text(
294
- `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient))}`
294
+ `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/team-setup/skills", await workspaceBase(apiClient))}`
295
295
  );
296
296
  }
297
297
  return text(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@systemdox/mcp-server",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
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",