@systemdox/mcp-server 0.1.54 → 0.1.56

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/dist/index.js CHANGED
@@ -54,6 +54,17 @@ var SystemDoxApiError = class extends Error {
54
54
  code;
55
55
  body;
56
56
  };
57
+ function unescapedPath(path) {
58
+ let current = path.split(/[?#]/, 1)[0];
59
+ for (; ; ) {
60
+ const next = current.replace(/[\t\n\r]/g, "").replace(/%(?:2e|2f|5c|25|09|0a|0d)/gi, (escape) => decodeURIComponent(escape));
61
+ if (next === current) return current;
62
+ current = next;
63
+ }
64
+ }
65
+ function walksThePath(path) {
66
+ return unescapedPath(path).split(/[/\\]/).some((segment) => segment === "." || segment === "..");
67
+ }
57
68
  var TEAM_SETUP = "/v1/team-setup";
58
69
  var seg = (s) => encodeURIComponent(String(s));
59
70
  var SystemDoxApiClient = class {
@@ -89,7 +100,7 @@ var SystemDoxApiClient = class {
89
100
  }
90
101
  async mutate(method, path, body) {
91
102
  const url = this.buildUrl(path);
92
- const res = await fetch(url, {
103
+ const res = await this.call(url, {
93
104
  method,
94
105
  headers: {
95
106
  // The API Gateway authorizer only reads the Authorization header;
@@ -139,7 +150,7 @@ var SystemDoxApiClient = class {
139
150
  /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
140
151
  * header. The caller checks the BYTES against the hash it was given. */
141
152
  async getPluginZip(workspace, version) {
142
- const res = await fetch(
153
+ const res = await this.call(
143
154
  this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
144
155
  {
145
156
  headers: {
@@ -205,6 +216,10 @@ var SystemDoxApiClient = class {
205
216
  buildUrl(path, params) {
206
217
  const base = this.config.apiUrl.replace(/\/+$/, "");
207
218
  const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
219
+ const { origin, pathname } = new URL(base);
220
+ if (walksThePath(path) || url.origin !== origin || !url.pathname.startsWith(`${pathname.replace(/\/+$/, "")}/`)) {
221
+ throw new Error("Refused: this id leaves the API's own path.");
222
+ }
208
223
  if (params) {
209
224
  for (const [key, value] of Object.entries(params)) {
210
225
  url.searchParams.set(key, value);
@@ -214,7 +229,7 @@ var SystemDoxApiClient = class {
214
229
  }
215
230
  async fetchRaw(path, params, signal) {
216
231
  const url = this.buildUrl(path, params);
217
- return fetch(url, {
232
+ return this.call(url, {
218
233
  headers: {
219
234
  Authorization: `Bearer ${this.config.apiKey}`,
220
235
  "X-API-Key": this.config.apiKey,
@@ -223,6 +238,16 @@ var SystemDoxApiClient = class {
223
238
  signal
224
239
  });
225
240
  }
241
+ /** Every call of this client, so `onAnswer` hears each answer. */
242
+ async call(url, init) {
243
+ const res = await fetch(url, init);
244
+ const { onAnswer } = this.config;
245
+ if (onAnswer) {
246
+ const refused2 = res.ok ? void 0 : new SystemDoxApiError(res.status, await res.clone().text());
247
+ onAnswer({ status: res.status, code: refused2?.code });
248
+ }
249
+ return res;
250
+ }
226
251
  };
227
252
 
228
253
  // src/lib/config.ts
@@ -896,7 +921,7 @@ function registerGetSpecs(server, apiClient2) {
896
921
  async ({ draft_id, status, search }) => {
897
922
  if (draft_id) {
898
923
  const spec = await apiClient2.get(
899
- `/v1/specs/${draft_id}`
924
+ `/v1/specs/${encodeURIComponent(draft_id)}`
900
925
  );
901
926
  const sections = [`# ${spec.name}
902
927
 
@@ -2546,7 +2571,8 @@ function registerUpdateSpec(server, apiClient2) {
2546
2571
  }
2547
2572
  },
2548
2573
  async ({ draft_id, section, content, requirement_name }) => {
2549
- const spec = await apiClient2.get(`/v1/specs/${draft_id}`);
2574
+ const specPath = `/v1/specs/${encodeURIComponent(draft_id)}`;
2575
+ const spec = await apiClient2.get(specPath);
2550
2576
  if (spec.status !== "draft") {
2551
2577
  return {
2552
2578
  content: [
@@ -2571,8 +2597,8 @@ function registerUpdateSpec(server, apiClient2) {
2571
2597
  };
2572
2598
  body.requirements = [...existing, newReq];
2573
2599
  }
2574
- await apiClient2.put(`/v1/specs/${draft_id}`, body);
2575
- apiClient2.invalidateCache(`/v1/specs/${draft_id}`);
2600
+ await apiClient2.put(specPath, body);
2601
+ apiClient2.invalidateCache(specPath);
2576
2602
  apiClient2.invalidateCache("/v1/specs");
2577
2603
  return {
2578
2604
  content: [
@@ -2631,7 +2657,7 @@ ${decision}
2631
2657
  ## Consequences
2632
2658
 
2633
2659
  ${consequences}` + relatedSection;
2634
- await apiClient2.put(`/v1/specs/${created.draftId}`, {
2660
+ await apiClient2.put(`/v1/specs/${encodeURIComponent(created.draftId)}`, {
2635
2661
  proposalContent: adrContent
2636
2662
  });
2637
2663
  apiClient2.invalidateCache("/v1/specs");
@@ -4013,15 +4039,56 @@ function registerTeamSetupTools(server, apiClient2, { localFiles }) {
4013
4039
  }
4014
4040
 
4015
4041
  // src/server.ts
4016
- var SERVER_VERSION = true ? "0.1.54" : "0.0.0-dev";
4017
- function createServer(apiClient2, { localFiles = false, instructions, keyProblem: keyProblem2 } = {}) {
4018
- const server = new McpServer(
4042
+ var SERVER_VERSION = true ? "0.1.56" : "0.0.0-dev";
4043
+ var READ_TOOLS = [
4044
+ "get_context",
4045
+ "get_checks",
4046
+ "get_guardrails",
4047
+ "get_project_rules",
4048
+ "get_prompt_templates",
4049
+ "get_specs",
4050
+ "get_docs",
4051
+ "get_tech_stack",
4052
+ "get_planning_context",
4053
+ "get_plan_board",
4054
+ "get_change",
4055
+ "get_team_setup",
4056
+ "get_document_links",
4057
+ "trace_document_path",
4058
+ "get_graph_stats",
4059
+ "list_skills",
4060
+ "install_skill",
4061
+ "resolve_person"
4062
+ ];
4063
+ var WRITE_TOOLS = [
4064
+ "create_check",
4065
+ "create_guardrail",
4066
+ "create_project_rule",
4067
+ "create_prompt_template",
4068
+ "update_spec",
4069
+ "report_decision",
4070
+ "publish_skill",
4071
+ "publish_setup_part"
4072
+ ];
4073
+ function offering(server, access) {
4074
+ const offered = new Set(access === "write" ? [...READ_TOOLS, ...WRITE_TOOLS] : READ_TOOLS);
4075
+ return new Proxy(server, {
4076
+ get(target, property) {
4077
+ const value = Reflect.get(target, property, target);
4078
+ if (property !== "registerTool") return typeof value === "function" ? value.bind(target) : value;
4079
+ return (name, ...rest) => offered.has(name) ? target.registerTool(name, ...rest) : void 0;
4080
+ }
4081
+ });
4082
+ }
4083
+ function createServer(apiClient2, { localFiles = false, instructions, keyProblem: keyProblem2, access } = {}) {
4084
+ const mcpServer = new McpServer(
4019
4085
  {
4020
4086
  name: "systemdox-mcp-server",
4021
4087
  version: SERVER_VERSION
4022
4088
  },
4023
4089
  instructions ? { instructions } : void 0
4024
4090
  );
4091
+ const server = access ? offering(mcpServer, access) : mcpServer;
4025
4092
  registerGetGuardrails(server, apiClient2);
4026
4093
  registerGetSpecs(server, apiClient2);
4027
4094
  registerGetDocs(server, apiClient2);
@@ -4041,7 +4108,7 @@ function createServer(apiClient2, { localFiles = false, instructions, keyProblem
4041
4108
  registerCreatePromptTemplate(server, apiClient2);
4042
4109
  registerSkillTools(server, apiClient2, { localFiles });
4043
4110
  registerTeamSetupTools(server, apiClient2, { localFiles });
4044
- return server;
4111
+ return mcpServer;
4045
4112
  }
4046
4113
 
4047
4114
  // src/index.ts
@@ -54,6 +54,17 @@ var SystemDoxApiError = class extends Error {
54
54
  code;
55
55
  body;
56
56
  };
57
+ function unescapedPath(path) {
58
+ let current = path.split(/[?#]/, 1)[0];
59
+ for (; ; ) {
60
+ const next = current.replace(/[\t\n\r]/g, "").replace(/%(?:2e|2f|5c|25|09|0a|0d)/gi, (escape) => decodeURIComponent(escape));
61
+ if (next === current) return current;
62
+ current = next;
63
+ }
64
+ }
65
+ function walksThePath(path) {
66
+ return unescapedPath(path).split(/[/\\]/).some((segment) => segment === "." || segment === "..");
67
+ }
57
68
  var TEAM_SETUP = "/v1/team-setup";
58
69
  var seg = (s) => encodeURIComponent(String(s));
59
70
  var SystemDoxApiClient = class {
@@ -89,7 +100,7 @@ var SystemDoxApiClient = class {
89
100
  }
90
101
  async mutate(method, path, body) {
91
102
  const url = this.buildUrl(path);
92
- const res = await fetch(url, {
103
+ const res = await this.call(url, {
93
104
  method,
94
105
  headers: {
95
106
  // The API Gateway authorizer only reads the Authorization header;
@@ -139,7 +150,7 @@ var SystemDoxApiClient = class {
139
150
  /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
140
151
  * header. The caller checks the BYTES against the hash it was given. */
141
152
  async getPluginZip(workspace, version) {
142
- const res = await fetch(
153
+ const res = await this.call(
143
154
  this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
144
155
  {
145
156
  headers: {
@@ -205,6 +216,10 @@ var SystemDoxApiClient = class {
205
216
  buildUrl(path, params) {
206
217
  const base = this.config.apiUrl.replace(/\/+$/, "");
207
218
  const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
219
+ const { origin, pathname } = new URL(base);
220
+ if (walksThePath(path) || url.origin !== origin || !url.pathname.startsWith(`${pathname.replace(/\/+$/, "")}/`)) {
221
+ throw new Error("Refused: this id leaves the API's own path.");
222
+ }
208
223
  if (params) {
209
224
  for (const [key, value] of Object.entries(params)) {
210
225
  url.searchParams.set(key, value);
@@ -214,7 +229,7 @@ var SystemDoxApiClient = class {
214
229
  }
215
230
  async fetchRaw(path, params, signal) {
216
231
  const url = this.buildUrl(path, params);
217
- return fetch(url, {
232
+ return this.call(url, {
218
233
  headers: {
219
234
  Authorization: `Bearer ${this.config.apiKey}`,
220
235
  "X-API-Key": this.config.apiKey,
@@ -223,6 +238,16 @@ var SystemDoxApiClient = class {
223
238
  signal
224
239
  });
225
240
  }
241
+ /** Every call of this client, so `onAnswer` hears each answer. */
242
+ async call(url, init) {
243
+ const res = await fetch(url, init);
244
+ const { onAnswer } = this.config;
245
+ if (onAnswer) {
246
+ const refused = res.ok ? void 0 : new SystemDoxApiError(res.status, await res.clone().text());
247
+ onAnswer({ status: res.status, code: refused?.code });
248
+ }
249
+ return res;
250
+ }
226
251
  };
227
252
 
228
253
  // src/lib/config.ts
@@ -13,6 +13,8 @@ export declare class SystemDoxApiError extends Error {
13
13
  readonly body: unknown;
14
14
  constructor(status: number, text: string);
15
15
  }
16
+ /** One answer of the REST API, with the `code` of a refusal that named one. */
17
+ export type ApiAnswer = Parameters<NonNullable<ServerConfig["onAnswer"]>>[0];
16
18
  export declare class SystemDoxApiClient {
17
19
  private config;
18
20
  private cache;
@@ -94,4 +96,6 @@ export declare class SystemDoxApiClient {
94
96
  invalidateCache(path?: string): void;
95
97
  private buildUrl;
96
98
  private fetchRaw;
99
+ /** Every call of this client, so `onAnswer` hears each answer. */
100
+ private call;
97
101
  }
@@ -51,6 +51,17 @@ var SystemDoxApiError = class extends Error {
51
51
  code;
52
52
  body;
53
53
  };
54
+ function unescapedPath(path) {
55
+ let current = path.split(/[?#]/, 1)[0];
56
+ for (; ; ) {
57
+ const next = current.replace(/[\t\n\r]/g, "").replace(/%(?:2e|2f|5c|25|09|0a|0d)/gi, (escape) => decodeURIComponent(escape));
58
+ if (next === current) return current;
59
+ current = next;
60
+ }
61
+ }
62
+ function walksThePath(path) {
63
+ return unescapedPath(path).split(/[/\\]/).some((segment) => segment === "." || segment === "..");
64
+ }
54
65
  var TEAM_SETUP = "/v1/team-setup";
55
66
  var seg = (s) => encodeURIComponent(String(s));
56
67
  var SystemDoxApiClient = class {
@@ -86,7 +97,7 @@ var SystemDoxApiClient = class {
86
97
  }
87
98
  async mutate(method, path, body) {
88
99
  const url = this.buildUrl(path);
89
- const res = await fetch(url, {
100
+ const res = await this.call(url, {
90
101
  method,
91
102
  headers: {
92
103
  // The API Gateway authorizer only reads the Authorization header;
@@ -136,7 +147,7 @@ var SystemDoxApiClient = class {
136
147
  /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
137
148
  * header. The caller checks the BYTES against the hash it was given. */
138
149
  async getPluginZip(workspace, version) {
139
- const res = await fetch(
150
+ const res = await this.call(
140
151
  this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
141
152
  {
142
153
  headers: {
@@ -202,6 +213,10 @@ var SystemDoxApiClient = class {
202
213
  buildUrl(path, params) {
203
214
  const base = this.config.apiUrl.replace(/\/+$/, "");
204
215
  const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
216
+ const { origin, pathname } = new URL(base);
217
+ if (walksThePath(path) || url.origin !== origin || !url.pathname.startsWith(`${pathname.replace(/\/+$/, "")}/`)) {
218
+ throw new Error("Refused: this id leaves the API's own path.");
219
+ }
205
220
  if (params) {
206
221
  for (const [key, value] of Object.entries(params)) {
207
222
  url.searchParams.set(key, value);
@@ -211,7 +226,7 @@ var SystemDoxApiClient = class {
211
226
  }
212
227
  async fetchRaw(path, params, signal) {
213
228
  const url = this.buildUrl(path, params);
214
- return fetch(url, {
229
+ return this.call(url, {
215
230
  headers: {
216
231
  Authorization: `Bearer ${this.config.apiKey}`,
217
232
  "X-API-Key": this.config.apiKey,
@@ -220,6 +235,16 @@ var SystemDoxApiClient = class {
220
235
  signal
221
236
  });
222
237
  }
238
+ /** Every call of this client, so `onAnswer` hears each answer. */
239
+ async call(url, init) {
240
+ const res = await fetch(url, init);
241
+ const { onAnswer } = this.config;
242
+ if (onAnswer) {
243
+ const refused = res.ok ? void 0 : new SystemDoxApiError(res.status, await res.clone().text());
244
+ onAnswer({ status: res.status, code: refused?.code });
245
+ }
246
+ return res;
247
+ }
223
248
  };
224
249
  export {
225
250
  SystemDoxApiClient,
@@ -3,6 +3,12 @@ export interface ServerConfig {
3
3
  apiUrl: string;
4
4
  cacheTtlMs: number;
5
5
  defaultOrg: string | undefined;
6
+ /** Hears every answer of the REST API, because a tool turns a refusal into a
7
+ * tool error and session-brief.ts hides it (ADR-229). */
8
+ onAnswer?: (answer: {
9
+ status: number;
10
+ code: string | undefined;
11
+ }) => void;
6
12
  }
7
13
  export declare const API_URL_DEFAULT = "https://api.puglieseweb.com/systemdox";
8
14
  /**
@@ -0,0 +1,18 @@
1
+ /**
2
+ * One MCP request, answered by a fresh server over a web-standard transport (no
3
+ * sessions): what `POST /mcp` and `POST /connect` have in common. It fits Lambda's
4
+ * execution model, and each caller keeps its own way of proving who is asking.
5
+ */
6
+ import { type ServerOptions } from "../server.js";
7
+ import type { SystemDoxApiClient } from "./api-client.js";
8
+ interface Answer {
9
+ statusCode: number;
10
+ headers: Record<string, string>;
11
+ body: string;
12
+ }
13
+ export declare function answerMcpRequest(apiClient: SystemDoxApiClient, request: {
14
+ url: string;
15
+ headers: Record<string, string>;
16
+ rawBody: string;
17
+ }, options?: Pick<ServerOptions, "access">): Promise<Answer>;
18
+ export {};
@@ -58,6 +58,17 @@ var SystemDoxApiError = class extends Error {
58
58
  code;
59
59
  body;
60
60
  };
61
+ function unescapedPath(path) {
62
+ let current = path.split(/[?#]/, 1)[0];
63
+ for (; ; ) {
64
+ const next = current.replace(/[\t\n\r]/g, "").replace(/%(?:2e|2f|5c|25|09|0a|0d)/gi, (escape) => decodeURIComponent(escape));
65
+ if (next === current) return current;
66
+ current = next;
67
+ }
68
+ }
69
+ function walksThePath(path) {
70
+ return unescapedPath(path).split(/[/\\]/).some((segment) => segment === "." || segment === "..");
71
+ }
61
72
  var TEAM_SETUP = "/v1/team-setup";
62
73
  var seg = (s) => encodeURIComponent(String(s));
63
74
  var SystemDoxApiClient = class {
@@ -93,7 +104,7 @@ var SystemDoxApiClient = class {
93
104
  }
94
105
  async mutate(method, path, body) {
95
106
  const url = this.buildUrl(path);
96
- const res = await fetch(url, {
107
+ const res = await this.call(url, {
97
108
  method,
98
109
  headers: {
99
110
  // The API Gateway authorizer only reads the Authorization header;
@@ -143,7 +154,7 @@ var SystemDoxApiClient = class {
143
154
  /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
144
155
  * header. The caller checks the BYTES against the hash it was given. */
145
156
  async getPluginZip(workspace, version) {
146
- const res = await fetch(
157
+ const res = await this.call(
147
158
  this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
148
159
  {
149
160
  headers: {
@@ -209,6 +220,10 @@ var SystemDoxApiClient = class {
209
220
  buildUrl(path, params) {
210
221
  const base = this.config.apiUrl.replace(/\/+$/, "");
211
222
  const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
223
+ const { origin, pathname } = new URL(base);
224
+ if (walksThePath(path) || url.origin !== origin || !url.pathname.startsWith(`${pathname.replace(/\/+$/, "")}/`)) {
225
+ throw new Error("Refused: this id leaves the API's own path.");
226
+ }
212
227
  if (params) {
213
228
  for (const [key, value] of Object.entries(params)) {
214
229
  url.searchParams.set(key, value);
@@ -218,7 +233,7 @@ var SystemDoxApiClient = class {
218
233
  }
219
234
  async fetchRaw(path, params, signal) {
220
235
  const url = this.buildUrl(path, params);
221
- return fetch(url, {
236
+ return this.call(url, {
222
237
  headers: {
223
238
  Authorization: `Bearer ${this.config.apiKey}`,
224
239
  "X-API-Key": this.config.apiKey,
@@ -227,6 +242,16 @@ var SystemDoxApiClient = class {
227
242
  signal
228
243
  });
229
244
  }
245
+ /** Every call of this client, so `onAnswer` hears each answer. */
246
+ async call(url, init) {
247
+ const res = await fetch(url, init);
248
+ const { onAnswer } = this.config;
249
+ if (onAnswer) {
250
+ const refused = res.ok ? void 0 : new SystemDoxApiError(res.status, await res.clone().text());
251
+ onAnswer({ status: res.status, code: refused?.code });
252
+ }
253
+ return res;
254
+ }
230
255
  };
231
256
 
232
257
  // src/lib/team-setup-files.ts
package/dist/server.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { SystemDoxApiClient } from "./lib/api-client.js";
3
+ export type ConnectionAccess = "read" | "write";
3
4
  export interface ServerOptions {
4
5
  /** True only on the user's own computer (stdio): the skill tools may read
5
6
  * and write the skill folder there. The hosted server never touches a disk
@@ -9,10 +10,13 @@ export interface ServerOptions {
9
10
  instructions?: string;
10
11
  /** Why the local server has no key, when it has none. */
11
12
  keyProblem?: string;
13
+ /** A connection's access (ADR-229): `tools/list` holds only these. Left out,
14
+ * every tool is offered, as on `/mcp` and the local server. */
15
+ access?: ConnectionAccess;
12
16
  }
13
17
  /**
14
18
  * Build a SystemDox MCP server with all tools registered against the given
15
19
  * API client. Shared by the stdio entry point (index.ts) and the hosted
16
20
  * Streamable HTTP entry point (http.ts).
17
21
  */
18
- export declare function createServer(apiClient: SystemDoxApiClient, { localFiles, instructions, keyProblem }?: ServerOptions): McpServer;
22
+ export declare function createServer(apiClient: SystemDoxApiClient, { localFiles, instructions, keyProblem, access }?: ServerOptions): McpServer;
package/dist/server.js CHANGED
@@ -214,7 +214,7 @@ function registerGetSpecs(server, apiClient) {
214
214
  async ({ draft_id, status, search }) => {
215
215
  if (draft_id) {
216
216
  const spec = await apiClient.get(
217
- `/v1/specs/${draft_id}`
217
+ `/v1/specs/${encodeURIComponent(draft_id)}`
218
218
  );
219
219
  const sections = [`# ${spec.name}
220
220
 
@@ -2184,7 +2184,8 @@ function registerUpdateSpec(server, apiClient) {
2184
2184
  }
2185
2185
  },
2186
2186
  async ({ draft_id, section, content, requirement_name }) => {
2187
- const spec = await apiClient.get(`/v1/specs/${draft_id}`);
2187
+ const specPath = `/v1/specs/${encodeURIComponent(draft_id)}`;
2188
+ const spec = await apiClient.get(specPath);
2188
2189
  if (spec.status !== "draft") {
2189
2190
  return {
2190
2191
  content: [
@@ -2209,8 +2210,8 @@ function registerUpdateSpec(server, apiClient) {
2209
2210
  };
2210
2211
  body.requirements = [...existing, newReq];
2211
2212
  }
2212
- await apiClient.put(`/v1/specs/${draft_id}`, body);
2213
- apiClient.invalidateCache(`/v1/specs/${draft_id}`);
2213
+ await apiClient.put(specPath, body);
2214
+ apiClient.invalidateCache(specPath);
2214
2215
  apiClient.invalidateCache("/v1/specs");
2215
2216
  return {
2216
2217
  content: [
@@ -2269,7 +2270,7 @@ ${decision}
2269
2270
  ## Consequences
2270
2271
 
2271
2272
  ${consequences}` + relatedSection;
2272
- await apiClient.put(`/v1/specs/${created.draftId}`, {
2273
+ await apiClient.put(`/v1/specs/${encodeURIComponent(created.draftId)}`, {
2273
2274
  proposalContent: adrContent
2274
2275
  });
2275
2276
  apiClient.invalidateCache("/v1/specs");
@@ -3651,15 +3652,56 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3651
3652
  }
3652
3653
 
3653
3654
  // src/server.ts
3654
- var SERVER_VERSION = true ? "0.1.54" : "0.0.0-dev";
3655
- function createServer(apiClient, { localFiles = false, instructions, keyProblem } = {}) {
3656
- const server = new McpServer(
3655
+ var SERVER_VERSION = true ? "0.1.56" : "0.0.0-dev";
3656
+ var READ_TOOLS = [
3657
+ "get_context",
3658
+ "get_checks",
3659
+ "get_guardrails",
3660
+ "get_project_rules",
3661
+ "get_prompt_templates",
3662
+ "get_specs",
3663
+ "get_docs",
3664
+ "get_tech_stack",
3665
+ "get_planning_context",
3666
+ "get_plan_board",
3667
+ "get_change",
3668
+ "get_team_setup",
3669
+ "get_document_links",
3670
+ "trace_document_path",
3671
+ "get_graph_stats",
3672
+ "list_skills",
3673
+ "install_skill",
3674
+ "resolve_person"
3675
+ ];
3676
+ var WRITE_TOOLS = [
3677
+ "create_check",
3678
+ "create_guardrail",
3679
+ "create_project_rule",
3680
+ "create_prompt_template",
3681
+ "update_spec",
3682
+ "report_decision",
3683
+ "publish_skill",
3684
+ "publish_setup_part"
3685
+ ];
3686
+ function offering(server, access) {
3687
+ const offered = new Set(access === "write" ? [...READ_TOOLS, ...WRITE_TOOLS] : READ_TOOLS);
3688
+ return new Proxy(server, {
3689
+ get(target, property) {
3690
+ const value = Reflect.get(target, property, target);
3691
+ if (property !== "registerTool") return typeof value === "function" ? value.bind(target) : value;
3692
+ return (name, ...rest) => offered.has(name) ? target.registerTool(name, ...rest) : void 0;
3693
+ }
3694
+ });
3695
+ }
3696
+ function createServer(apiClient, { localFiles = false, instructions, keyProblem, access } = {}) {
3697
+ const mcpServer = new McpServer(
3657
3698
  {
3658
3699
  name: "systemdox-mcp-server",
3659
3700
  version: SERVER_VERSION
3660
3701
  },
3661
3702
  instructions ? { instructions } : void 0
3662
3703
  );
3704
+ const server = access ? offering(mcpServer, access) : mcpServer;
3663
3705
  registerGetGuardrails(server, apiClient);
3664
3706
  registerGetSpecs(server, apiClient);
3665
3707
  registerGetDocs(server, apiClient);
@@ -3679,7 +3721,7 @@ function createServer(apiClient, { localFiles = false, instructions, keyProblem
3679
3721
  registerCreatePromptTemplate(server, apiClient);
3680
3722
  registerSkillTools(server, apiClient, { localFiles });
3681
3723
  registerTeamSetupTools(server, apiClient, { localFiles });
3682
- return server;
3724
+ return mcpServer;
3683
3725
  }
3684
3726
  export {
3685
3727
  createServer
package/dist/setup-mcp.js CHANGED
@@ -56,6 +56,17 @@ var SystemDoxApiError = class extends Error {
56
56
  code;
57
57
  body;
58
58
  };
59
+ function unescapedPath(path) {
60
+ let current = path.split(/[?#]/, 1)[0];
61
+ for (; ; ) {
62
+ const next = current.replace(/[\t\n\r]/g, "").replace(/%(?:2e|2f|5c|25|09|0a|0d)/gi, (escape) => decodeURIComponent(escape));
63
+ if (next === current) return current;
64
+ current = next;
65
+ }
66
+ }
67
+ function walksThePath(path) {
68
+ return unescapedPath(path).split(/[/\\]/).some((segment) => segment === "." || segment === "..");
69
+ }
59
70
  var TEAM_SETUP = "/v1/team-setup";
60
71
  var seg = (s) => encodeURIComponent(String(s));
61
72
  var SystemDoxApiClient = class {
@@ -91,7 +102,7 @@ var SystemDoxApiClient = class {
91
102
  }
92
103
  async mutate(method, path, body) {
93
104
  const url = this.buildUrl(path);
94
- const res = await fetch(url, {
105
+ const res = await this.call(url, {
95
106
  method,
96
107
  headers: {
97
108
  // The API Gateway authorizer only reads the Authorization header;
@@ -141,7 +152,7 @@ var SystemDoxApiClient = class {
141
152
  /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
142
153
  * header. The caller checks the BYTES against the hash it was given. */
143
154
  async getPluginZip(workspace, version) {
144
- const res = await fetch(
155
+ const res = await this.call(
145
156
  this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
146
157
  {
147
158
  headers: {
@@ -207,6 +218,10 @@ var SystemDoxApiClient = class {
207
218
  buildUrl(path, params) {
208
219
  const base = this.config.apiUrl.replace(/\/+$/, "");
209
220
  const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
221
+ const { origin, pathname } = new URL(base);
222
+ if (walksThePath(path) || url.origin !== origin || !url.pathname.startsWith(`${pathname.replace(/\/+$/, "")}/`)) {
223
+ throw new Error("Refused: this id leaves the API's own path.");
224
+ }
210
225
  if (params) {
211
226
  for (const [key, value] of Object.entries(params)) {
212
227
  url.searchParams.set(key, value);
@@ -216,7 +231,7 @@ var SystemDoxApiClient = class {
216
231
  }
217
232
  async fetchRaw(path, params, signal) {
218
233
  const url = this.buildUrl(path, params);
219
- return fetch(url, {
234
+ return this.call(url, {
220
235
  headers: {
221
236
  Authorization: `Bearer ${this.config.apiKey}`,
222
237
  "X-API-Key": this.config.apiKey,
@@ -225,6 +240,16 @@ var SystemDoxApiClient = class {
225
240
  signal
226
241
  });
227
242
  }
243
+ /** Every call of this client, so `onAnswer` hears each answer. */
244
+ async call(url, init) {
245
+ const res = await fetch(url, init);
246
+ const { onAnswer } = this.config;
247
+ if (onAnswer) {
248
+ const refused = res.ok ? void 0 : new SystemDoxApiError(res.status, await res.clone().text());
249
+ onAnswer({ status: res.status, code: refused?.code });
250
+ }
251
+ return res;
252
+ }
228
253
  };
229
254
 
230
255
  // src/lib/config.ts