@systemdox/mcp-server 0.1.44 → 0.1.46

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,242 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/tools/team-setup.ts
4
+ import { z } from "zod";
5
+ import { existsSync as existsSync2 } from "fs";
6
+ import { lstat as lstat2 } from "fs/promises";
7
+ import { homedir as homedir2 } from "os";
8
+ import { join as join2, resolve as resolve2 } from "path";
9
+
10
+ // src/lib/skills.ts
11
+ import { createHash } from "crypto";
12
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
13
+ import { existsSync } from "fs";
14
+ import { homedir } from "os";
15
+ import { dirname, join, relative, resolve, sep } from "path";
16
+ var MANIFEST_FILE = ".systemdox-skill.json";
17
+ var MAX_FILES = 100;
18
+ var MAX_FILE_BYTES = 256 * 1024;
19
+ var MAX_TOTAL_BYTES = 1024 * 1024;
20
+ var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
21
+ var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
22
+ function isValidPath(path) {
23
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
24
+ }
25
+ async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
26
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
27
+ if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
28
+ const full = join(dir, entry.name);
29
+ const rel = relative(root, full).split(sep).join("/");
30
+ if (entry.isSymbolicLink()) out.links.push(rel);
31
+ else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
32
+ else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
33
+ else if (entry.isFile()) out.files.push(rel);
34
+ }
35
+ out.files.sort();
36
+ return out;
37
+ }
38
+ async function readTextFiles(root, paths, what) {
39
+ if (paths.length > MAX_FILES) {
40
+ throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
41
+ }
42
+ const decoder = new TextDecoder("utf-8", { fatal: true });
43
+ const files = [];
44
+ let total = 0;
45
+ for (const path of paths) {
46
+ if (!isValidPath(path)) {
47
+ throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
48
+ }
49
+ const raw = await readFile(join(root, ...path.split("/")));
50
+ if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
51
+ if (raw.length > MAX_FILE_BYTES) {
52
+ throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
53
+ }
54
+ total += raw.length;
55
+ if (total > MAX_TOTAL_BYTES) {
56
+ throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
57
+ }
58
+ let content;
59
+ try {
60
+ content = decoder.decode(raw);
61
+ } catch {
62
+ throw new Error(`${path} is not UTF-8 text`);
63
+ }
64
+ files.push({ path, content });
65
+ }
66
+ return files;
67
+ }
68
+
69
+ // src/lib/person-values.ts
70
+ var W = "[\\p{L}\\p{N}_]";
71
+ var D = "\\p{Nd}";
72
+ var WORD_OR = (extra) => `[\\p{L}\\p{N}_${extra}]`;
73
+ var PERSON_ID = "(?:con|ctc|usr)_[0-9A-Za-z]{10,40}";
74
+ var PERSON_TOKEN = `\`${PERSON_ID}\`(?:[ \\t]*\\([^()\\n]*\\))?`;
75
+ var BEFORE = `(?<!${WORD_OR("+.:/\\\\\\-")})(?<!${D}[ -])`;
76
+ var AFTER = `(?!${WORD_OR(":/\\\\")}|\\.${D}|[ -]?${D}|-${W})`;
77
+ var E164 = `${BEFORE}\\+${D}+(?:[ -]${D}+)*${AFTER}`;
78
+ var NATIONAL = `${BEFORE}0${D}*(?:[ -]${D}+)*${AFTER}`;
79
+ var ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_{|}~\\-]";
80
+ 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])";
81
+ var HOME_PREFIX = "(?:[A-Za-z]:\\\\+Users\\\\+|[A-Za-z]:/+Users/+|/Users/|/home/)";
82
+ var HOME = `(?<!${WORD_OR(".~:/\\\\$}\\-")})${HOME_PREFIX}[^\\\\/\\s"'\`<>|:*?]+`;
83
+ 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,}`;
84
+
85
+ // src/lib/team-setup-types.ts
86
+ var PARTS = ["hooks", "mcp", "agents", "commands", "instructions"];
87
+ var PERSON_ID_RE = new RegExp(`^${PERSON_ID}$`);
88
+
89
+ // src/tools/team-setup.ts
90
+ var LAYOUT = {
91
+ hooks: { files: ["hooks/hooks.json"], folders: [{ dir: "scripts", md: false }], required: "hooks/hooks.json" },
92
+ mcp: { files: [".mcp.json"], folders: [], required: ".mcp.json" },
93
+ agents: { files: [], folders: [{ dir: "agents", md: true }] },
94
+ commands: { files: [], folders: [{ dir: "commands", md: true }] },
95
+ instructions: { files: ["CLAUDE.md"], folders: [], required: "CLAUDE.md" }
96
+ };
97
+ function expandHome(path) {
98
+ return path.replace(/^~(?=$|[\\/])/, homedir2());
99
+ }
100
+ function text(t) {
101
+ return { content: [{ type: "text", text: t }] };
102
+ }
103
+ async function isLink(path) {
104
+ try {
105
+ return (await lstat2(path)).isSymbolicLink();
106
+ } catch (e) {
107
+ if (e.code === "ENOENT") return false;
108
+ throw e;
109
+ }
110
+ }
111
+ async function readPartDirectory(part, directory) {
112
+ const root = resolve2(directory);
113
+ const layout = LAYOUT[part];
114
+ const paths = [];
115
+ const leftOut = [];
116
+ const links = [];
117
+ for (const path of layout.files) {
118
+ const segments = path.split("/");
119
+ let present = true;
120
+ for (let i = 1; i <= segments.length; i++) {
121
+ const rel = segments.slice(0, i).join("/");
122
+ if (await isLink(join2(root, ...segments.slice(0, i)))) {
123
+ links.push(rel);
124
+ present = false;
125
+ break;
126
+ }
127
+ }
128
+ if (present && existsSync2(join2(root, ...segments))) paths.push(path);
129
+ }
130
+ for (const { dir, md } of layout.folders) {
131
+ const full = join2(root, dir);
132
+ if (await isLink(full)) {
133
+ links.push(dir);
134
+ continue;
135
+ }
136
+ if (!existsSync2(full)) continue;
137
+ const walked = await walk(full, { skipHidden: true });
138
+ links.push(...walked.links.map((p) => `${dir}/${p}`));
139
+ leftOut.push(...walked.hidden.map((p) => `${dir}/${p}`));
140
+ for (const p of walked.files) {
141
+ if (md && !p.endsWith(".md")) leftOut.push(`${dir}/${p}`);
142
+ else paths.push(`${dir}/${p}`);
143
+ }
144
+ }
145
+ if (links.length) {
146
+ throw new Error(
147
+ `${links.join(", ")}: links are not followed; copy the file into the folder. Nothing was sent.`
148
+ );
149
+ }
150
+ if (layout.required && !paths.includes(layout.required)) {
151
+ throw new Error(`${root} has no ${layout.required}: the ${part} part needs it. Nothing was sent.`);
152
+ }
153
+ if (!paths.length) {
154
+ throw new Error(
155
+ `${root} has no ${layout.folders.map((f) => `${f.dir}/*.md`).join(" or ")}: nothing to publish for ${part}.`
156
+ );
157
+ }
158
+ return { files: await readTextFiles(root, paths.sort(), `the ${part} part`), leftOut };
159
+ }
160
+ function describePerson(id, person) {
161
+ const lines = [`**${person.label ?? "(no label)"}**, \`${id}\``];
162
+ if (person.mergedInto) {
163
+ lines.push(`\`${id}\` was merged into \`${person.mergedInto}\`: these are \`${person.mergedInto}\`'s details.`);
164
+ }
165
+ lines.push(`- Name: ${person.name ?? "(none held)"}`);
166
+ lines.push(`- Phone number: ${person.phone ?? "(none held)"}`);
167
+ lines.push(`- Email address: ${person.email ?? "(none held)"}`);
168
+ const token = `\`${person.mergedInto ?? id}\`${person.label ? ` (${person.label})` : ""}`;
169
+ lines.push(
170
+ "",
171
+ `These details are for this session only. Do not write them into a memory file, a note or a commit: a memory names this person by the identifier, as ${token}. A memory file that holds the details is offered for rewriting again at the next sync.`
172
+ );
173
+ return lines.join("\n");
174
+ }
175
+ function registerTeamSetupTools(server, apiClient, { localFiles }) {
176
+ const fileSchema = z.object({
177
+ path: z.string().describe(`Path inside the part's folder, forward slashes, e.g. "hooks/hooks.json"`),
178
+ content: z.string().describe("The file's full text, exactly as it is")
179
+ });
180
+ server.registerTool(
181
+ "publish_setup_part",
182
+ {
183
+ title: "Publish Setup Part",
184
+ description: "Publish one part of your team's Claude Code setup to SystemDox: `hooks` (hooks/hooks.json and the scripts/ it calls), `mcp` (.mcp.json), `agents` (agents/*.md), `commands` (commands/*.md) or `instructions` (CLAUDE.md). Every part but instructions makes a new version of the team plugin, which each member accepts at their next update. Publishing hooks, mcp, agents or commands needs the admin role. " + (localFiles ? "Pass `directory`: the server reads only the files the part accepts, byte for byte, and refuses a link, a binary file or a file too large before anything is sent. Never retype the files into `files` when the folder is on this computer." : "Pass every file of the part in `files`, exactly as it is."),
185
+ inputSchema: {
186
+ part: z.enum(PARTS).describe("Which part of the setup"),
187
+ ...localFiles ? {
188
+ directory: z.string().optional().describe(
189
+ "The folder on this computer: for hooks, the one holding hooks/hooks.json and scripts/; for mcp, the one holding .mcp.json; for instructions, the one holding CLAUDE.md; for agents or commands, the one holding agents/ or commands/"
190
+ )
191
+ } : {},
192
+ files: z.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the part")
193
+ },
194
+ annotations: { readOnlyHint: false }
195
+ },
196
+ async (args) => {
197
+ let files;
198
+ let leftOut = [];
199
+ if (localFiles && args.directory) {
200
+ ({ files, leftOut } = await readPartDirectory(args.part, expandHome(args.directory)));
201
+ } else if (args.files?.length) {
202
+ files = args.files;
203
+ } else {
204
+ throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
205
+ }
206
+ const { workspace } = await apiClient.getOwnTeamSetup();
207
+ const saved = await apiClient.publishPartVersion(workspace, args.part, files);
208
+ const lines = [`Published **${saved.part}** version ${saved.version}.`];
209
+ if (saved.part !== "instructions" && saved.pluginVersion !== void 0) {
210
+ lines.push(`Team plugin version ${saved.pluginVersion}; each member accepts it at their next update.`);
211
+ }
212
+ if (saved.replacedValues?.length) {
213
+ lines.push(
214
+ "Replaced by a reference to each member's own environment variable:",
215
+ ...saved.replacedValues.map((r) => `- ${r.server} ${r.field} ${r.name}: \${${r.name}}`)
216
+ );
217
+ }
218
+ if (leftOut.length) lines.push(`Left out (not part of ${args.part}): ${leftOut.join(", ")}`);
219
+ return text(lines.join("\n"));
220
+ }
221
+ );
222
+ server.registerTool(
223
+ "resolve_person",
224
+ {
225
+ title: "Resolve Person",
226
+ description: "Read back a person that a team memory names by identifier, written as `con_\u2026` (label), `ctc_\u2026` or `usr_\u2026`: their label, name, phone number and email address, from the vault that holds them. The details are for this session only: never write them into a memory file; the memory keeps the identifier. Writes nothing.",
227
+ inputSchema: {
228
+ id: z.string().regex(PERSON_ID_RE, "a con_, ctc_ or usr_ identifier, as written in the memory").describe("The identifier, e.g. con_01JC2Q8Y4M7T0X3K5N9P2R6S8V")
229
+ },
230
+ annotations: { readOnlyHint: true }
231
+ },
232
+ async (args) => {
233
+ const { workspace } = await apiClient.getOwnTeamSetup();
234
+ const person = await apiClient.resolvePerson(workspace, args.id);
235
+ return text(describePerson(args.id, person));
236
+ }
237
+ );
238
+ }
239
+ export {
240
+ readPartDirectory,
241
+ registerTeamSetupTools
242
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@systemdox/mcp-server",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
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",