@systemdox/mcp-server 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -20,13 +20,21 @@ HTTP. Point any HTTP-capable MCP client at it — nothing to install or update.
20
20
  "type": "http",
21
21
  "url": "https://<your-workspace>.mcp.systemdox.com/mcp",
22
22
  "headers": {
23
- "Authorization": "Bearer pweb_prod_key_..."
23
+ // Reference the key via an env var — never commit the literal key.
24
+ "Authorization": "Bearer ${SYSTEMDOX_API_KEY}"
24
25
  }
25
26
  }
26
27
  }
27
28
  }
28
29
  ```
29
30
 
31
+ > **Never commit the literal key.** The `pweb_` prefix is deliberately scanner-friendly,
32
+ > and a key in a committed `.mcp.json` is a long-lived read/write credential retained in
33
+ > git history across every clone, fork, and CI log. MCP clients that expand environment
34
+ > variables in `.mcp.json` (e.g. Claude Code) resolve `${SYSTEMDOX_API_KEY}` at load time;
35
+ > deliver the value from your shell, a secret manager, or your CI secret store. For clients
36
+ > that don't expand variables, inject the key at deploy time and keep the file untracked.
37
+
30
38
  - **URL** — `https://<your-workspace>.mcp.systemdox.com/mcp`. `<your-workspace>`
31
39
  is your tenant's **slug**; it must match the tenant that owns the API key, or
32
40
  the request is rejected `403`. Set a slug on the workspace first (owner/admin →
@@ -35,6 +43,11 @@ HTTP. Point any HTTP-capable MCP client at it — nothing to install or update.
35
43
  `X-API-Key`-only request is rejected at the gateway.)
36
44
  - **Transport** — stateless request/response (JSON); each call is independent,
37
45
  which suits serverless hosting. There is no long-lived SSE stream.
46
+ - **Scope** — the key is the boundary. A key bound to a single space (a
47
+ "department" key) sees only that space's context; org-wide planner endpoints
48
+ (`get_planning_context`) are barred to it and answer `403`. Give each
49
+ department's repo its own key via `${SYSTEMDOX_API_KEY}`, and an agent in one
50
+ department never sees another's checks, specs, or docs.
38
51
 
39
52
  ## Option B — Local (stdio) via npx
40
53
 
@@ -54,7 +67,8 @@ npx @systemdox/mcp-server setup
54
67
  "command": "npx",
55
68
  "args": ["@systemdox/mcp-server"],
56
69
  "env": {
57
- "SYSTEMDOX_API_KEY": "pweb_prod_key_..."
70
+ // Provided by your shell / secret store — not committed in the file.
71
+ "SYSTEMDOX_API_KEY": "${SYSTEMDOX_API_KEY}"
58
72
  }
59
73
  }
60
74
  }
package/dist/cli.js CHANGED
@@ -444,20 +444,28 @@ function registerGetDocs(server2, apiClient2) {
444
444
  "get_docs",
445
445
  {
446
446
  title: "Get Docs",
447
- description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search.",
447
+ description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
448
448
  inputSchema: {
449
449
  repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
450
450
  path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
451
451
  search: z3.string().optional().describe("Keyword search across document content"),
452
452
  doc_id: z3.string().optional().describe(
453
453
  "Specific document ID to retrieve full content"
454
+ ),
455
+ section: z3.string().optional().describe(
456
+ "Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
457
+ ),
458
+ chunk_id: z3.string().optional().describe(
459
+ "Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
454
460
  )
455
461
  }
456
462
  },
457
- async ({ repo, path, search, doc_id }) => {
463
+ async ({ repo, path, search, doc_id, section, chunk_id }) => {
458
464
  if (doc_id) {
465
+ const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
459
466
  const doc = await apiClient2.get(
460
- `/v1/docs/${encodeURIComponent(doc_id)}/content`
467
+ `/v1/docs/${encodeURIComponent(doc_id)}/content`,
468
+ anchor ? { section: anchor } : void 0
461
469
  );
462
470
  if (doc.error) {
463
471
  return {
@@ -470,8 +478,18 @@ function registerGetDocs(server2, apiClient2) {
470
478
  isError: true
471
479
  };
472
480
  }
481
+ let text2 = doc.content;
482
+ if (anchor && doc.section_resolved === false) {
483
+ text2 = `_Section "${anchor}" not found \u2014 returning the full document._
484
+
485
+ ` + text2;
486
+ } else if (anchor && doc.heading) {
487
+ text2 = `## ${doc.heading}
488
+
489
+ ${doc.content}`;
490
+ }
473
491
  return {
474
- content: [{ type: "text", text: doc.content }]
492
+ content: [{ type: "text", text: text2 }]
475
493
  };
476
494
  }
477
495
  const params = {};
@@ -495,10 +513,14 @@ function registerGetDocs(server2, apiClient2) {
495
513
  ]
496
514
  };
497
515
  }
498
- const text = items.map(
499
- (d) => `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})` + (d.snippet ? `
500
- > ${d.snippet}` : "")
501
- ).join("\n");
516
+ const text = items.map((d) => {
517
+ const sections = (d.outline ?? []).slice(0, 8);
518
+ const sectionHint = sections.length > 0 ? `
519
+ sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
520
+ return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
521
+ doc_id: \`${d.id}\`` + (d.snippet ? `
522
+ > ${d.snippet}` : "") + sectionHint;
523
+ }).join("\n");
502
524
  return {
503
525
  content: [
504
526
  {
@@ -507,7 +529,7 @@ function registerGetDocs(server2, apiClient2) {
507
529
 
508
530
  ${text}
509
531
 
510
- Use doc_id to retrieve full content.`
532
+ Use doc_id for full content, or doc_id + section to fetch one section.`
511
533
  }
512
534
  ]
513
535
  };
@@ -908,18 +930,41 @@ ${spec.taskContent}`);
908
930
  if (!body) throw new Error("spec has no content");
909
931
  return body;
910
932
  }
911
- async function fetchDocBody(api, docId) {
933
+ async function fetchDocBody(api, docId, anchor) {
912
934
  const doc = await api.get(
913
- `/v1/docs/${encodeURIComponent(docId)}/content`
935
+ `/v1/docs/${encodeURIComponent(docId)}/content`,
936
+ anchor ? { section: anchor } : void 0
914
937
  );
915
938
  if (doc.error) throw new Error(doc.error);
916
939
  const body = (doc.content ?? "").trim();
917
940
  if (!body) throw new Error("document is empty");
918
941
  return body;
919
942
  }
943
+ function githubDeepLink(repoFullName, path, anchor) {
944
+ return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
945
+ }
946
+ function formatSectionPointer(repoFullName, path, s) {
947
+ return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
948
+ }
949
+ function selectSection(outline, taskDescription) {
950
+ if (!outline || outline.length === 0) return void 0;
951
+ const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
952
+ let best = outline[0];
953
+ let bestScore = -1;
954
+ for (const s of outline) {
955
+ const heading = s.heading.toLowerCase();
956
+ let score = 0;
957
+ for (const kw of keywords) if (heading.includes(kw)) score++;
958
+ if (score > bestScore) {
959
+ best = s;
960
+ bestScore = score;
961
+ }
962
+ }
963
+ return best;
964
+ }
920
965
  async function hydrate(api, item, meta) {
921
966
  try {
922
- const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
967
+ const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
923
968
  return { item, meta, body };
924
969
  } catch (err) {
925
970
  return {
@@ -967,12 +1012,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
967
1012
  };
968
1013
  }),
969
1014
  ...docs.items.map((d) => {
1015
+ const section = selectSection(d.outline, taskDescription);
1016
+ const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
970
1017
  refs.set(key("doc", d.id), {
971
1018
  type: "doc",
972
1019
  id: d.id,
973
1020
  title: d.title,
974
- listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)`,
975
- fetchHint: `get_docs(doc_id="${d.id}")`
1021
+ section,
1022
+ pointer,
1023
+ listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
1024
+ \u21B3 ${pointer}` : ""),
1025
+ fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
976
1026
  });
977
1027
  return {
978
1028
  type: "doc",
@@ -1045,6 +1095,8 @@ ${formatStack(stack)}`;
1045
1095
  const blocks = [];
1046
1096
  for (const h of hydrated) {
1047
1097
  itemsLeft--;
1098
+ const headline = h.meta.pointer ? `${h.item.content}
1099
+ ${h.meta.pointer}` : h.item.content;
1048
1100
  const emit = (block) => {
1049
1101
  bodyBudget -= Math.max(
1050
1102
  0,
@@ -1055,7 +1107,7 @@ ${formatStack(stack)}`;
1055
1107
  if (h.error !== void 0) {
1056
1108
  bodyFailures++;
1057
1109
  emit(
1058
- `${h.item.content}
1110
+ `${headline}
1059
1111
 
1060
1112
  _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
1061
1113
  );
@@ -1068,7 +1120,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
1068
1120
  );
1069
1121
  if (allowance < MIN_BODY_TOKENS) {
1070
1122
  emit(
1071
- `${h.item.content}
1123
+ `${headline}
1072
1124
 
1073
1125
  _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
1074
1126
  );
@@ -1080,7 +1132,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
1080
1132
  );
1081
1133
  itemsWithBody++;
1082
1134
  emit(
1083
- `${h.item.content}
1135
+ `${headline}
1084
1136
 
1085
1137
  ${body2}` + (truncated ? `
1086
1138
 
@@ -1099,7 +1151,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
1099
1151
  sections.push(
1100
1152
  `## Relevant Specs & Docs (${blocks.length})
1101
1153
 
1102
- _Full text of the highest-ranked items. Every item carries the ID to fetch it in full._
1154
+ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
1103
1155
 
1104
1156
  ` + blocks.join("\n\n---\n\n")
1105
1157
  );
@@ -1142,7 +1194,7 @@ _Ranked lower \u2014 fetch with \`get_specs\` / \`get_docs\` if you need them._
1142
1194
  _Context: ${itemsWithBody} full text, ${itemsListed} listed, ${stats.itemsOmitted} omitted \xB7 ~${tokensUsed} of ${budget} tokens` + (bodyFailures > 0 ? ` \xB7 ${bodyFailures} body fetch(es) failed` : "") + `_`;
1143
1195
  return { text: body + footer, stats };
1144
1196
  }
1145
- var DEFAULT_BUDGET, RESERVE_TOKENS, GUARDRAIL_SHARE, MIN_BODY_TOKENS, MAX_HYDRATED, MAX_TAIL, TAIL_SHARE, TRUNCATION_NOTICE_TOKENS;
1197
+ var DEFAULT_BUDGET, RESERVE_TOKENS, GUARDRAIL_SHARE, MIN_BODY_TOKENS, MAX_HYDRATED, MAX_TAIL, TAIL_SHARE, TRUNCATION_NOTICE_TOKENS, SECTION_STOP_WORDS;
1146
1198
  var init_context_builder = __esm({
1147
1199
  "src/lib/context-builder.ts"() {
1148
1200
  "use strict";
@@ -1156,6 +1208,28 @@ var init_context_builder = __esm({
1156
1208
  MAX_TAIL = 10;
1157
1209
  TAIL_SHARE = 0.15;
1158
1210
  TRUNCATION_NOTICE_TOKENS = 30;
1211
+ SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
1212
+ "the",
1213
+ "a",
1214
+ "an",
1215
+ "of",
1216
+ "to",
1217
+ "and",
1218
+ "or",
1219
+ "for",
1220
+ "in",
1221
+ "on",
1222
+ "with",
1223
+ "how",
1224
+ "why",
1225
+ "what",
1226
+ "add",
1227
+ "new",
1228
+ "use",
1229
+ "using",
1230
+ "update",
1231
+ "fix"
1232
+ ]);
1159
1233
  }
1160
1234
  });
1161
1235
 
package/dist/http.js CHANGED
@@ -384,20 +384,28 @@ function registerGetDocs(server, apiClient) {
384
384
  "get_docs",
385
385
  {
386
386
  title: "Get Docs",
387
- description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search.",
387
+ description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
388
388
  inputSchema: {
389
389
  repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
390
390
  path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
391
391
  search: z3.string().optional().describe("Keyword search across document content"),
392
392
  doc_id: z3.string().optional().describe(
393
393
  "Specific document ID to retrieve full content"
394
+ ),
395
+ section: z3.string().optional().describe(
396
+ "Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
397
+ ),
398
+ chunk_id: z3.string().optional().describe(
399
+ "Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
394
400
  )
395
401
  }
396
402
  },
397
- async ({ repo, path, search, doc_id }) => {
403
+ async ({ repo, path, search, doc_id, section, chunk_id }) => {
398
404
  if (doc_id) {
405
+ const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
399
406
  const doc = await apiClient.get(
400
- `/v1/docs/${encodeURIComponent(doc_id)}/content`
407
+ `/v1/docs/${encodeURIComponent(doc_id)}/content`,
408
+ anchor ? { section: anchor } : void 0
401
409
  );
402
410
  if (doc.error) {
403
411
  return {
@@ -410,8 +418,18 @@ function registerGetDocs(server, apiClient) {
410
418
  isError: true
411
419
  };
412
420
  }
421
+ let text2 = doc.content;
422
+ if (anchor && doc.section_resolved === false) {
423
+ text2 = `_Section "${anchor}" not found \u2014 returning the full document._
424
+
425
+ ` + text2;
426
+ } else if (anchor && doc.heading) {
427
+ text2 = `## ${doc.heading}
428
+
429
+ ${doc.content}`;
430
+ }
413
431
  return {
414
- content: [{ type: "text", text: doc.content }]
432
+ content: [{ type: "text", text: text2 }]
415
433
  };
416
434
  }
417
435
  const params = {};
@@ -435,10 +453,14 @@ function registerGetDocs(server, apiClient) {
435
453
  ]
436
454
  };
437
455
  }
438
- const text = items.map(
439
- (d) => `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})` + (d.snippet ? `
440
- > ${d.snippet}` : "")
441
- ).join("\n");
456
+ const text = items.map((d) => {
457
+ const sections = (d.outline ?? []).slice(0, 8);
458
+ const sectionHint = sections.length > 0 ? `
459
+ sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
460
+ return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
461
+ doc_id: \`${d.id}\`` + (d.snippet ? `
462
+ > ${d.snippet}` : "") + sectionHint;
463
+ }).join("\n");
442
464
  return {
443
465
  content: [
444
466
  {
@@ -447,7 +469,7 @@ function registerGetDocs(server, apiClient) {
447
469
 
448
470
  ${text}
449
471
 
450
- Use doc_id to retrieve full content.`
472
+ Use doc_id for full content, or doc_id + section to fetch one section.`
451
473
  }
452
474
  ]
453
475
  };
@@ -837,18 +859,63 @@ ${spec.taskContent}`);
837
859
  if (!body) throw new Error("spec has no content");
838
860
  return body;
839
861
  }
840
- async function fetchDocBody(api, docId) {
862
+ async function fetchDocBody(api, docId, anchor) {
841
863
  const doc = await api.get(
842
- `/v1/docs/${encodeURIComponent(docId)}/content`
864
+ `/v1/docs/${encodeURIComponent(docId)}/content`,
865
+ anchor ? { section: anchor } : void 0
843
866
  );
844
867
  if (doc.error) throw new Error(doc.error);
845
868
  const body = (doc.content ?? "").trim();
846
869
  if (!body) throw new Error("document is empty");
847
870
  return body;
848
871
  }
872
+ function githubDeepLink(repoFullName, path, anchor) {
873
+ return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
874
+ }
875
+ function formatSectionPointer(repoFullName, path, s) {
876
+ return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
877
+ }
878
+ var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
879
+ "the",
880
+ "a",
881
+ "an",
882
+ "of",
883
+ "to",
884
+ "and",
885
+ "or",
886
+ "for",
887
+ "in",
888
+ "on",
889
+ "with",
890
+ "how",
891
+ "why",
892
+ "what",
893
+ "add",
894
+ "new",
895
+ "use",
896
+ "using",
897
+ "update",
898
+ "fix"
899
+ ]);
900
+ function selectSection(outline, taskDescription) {
901
+ if (!outline || outline.length === 0) return void 0;
902
+ const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
903
+ let best = outline[0];
904
+ let bestScore = -1;
905
+ for (const s of outline) {
906
+ const heading = s.heading.toLowerCase();
907
+ let score = 0;
908
+ for (const kw of keywords) if (heading.includes(kw)) score++;
909
+ if (score > bestScore) {
910
+ best = s;
911
+ bestScore = score;
912
+ }
913
+ }
914
+ return best;
915
+ }
849
916
  async function hydrate(api, item, meta) {
850
917
  try {
851
- const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
918
+ const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
852
919
  return { item, meta, body };
853
920
  } catch (err) {
854
921
  return {
@@ -896,12 +963,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
896
963
  };
897
964
  }),
898
965
  ...docs.items.map((d) => {
966
+ const section = selectSection(d.outline, taskDescription);
967
+ const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
899
968
  refs.set(key("doc", d.id), {
900
969
  type: "doc",
901
970
  id: d.id,
902
971
  title: d.title,
903
- listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)`,
904
- fetchHint: `get_docs(doc_id="${d.id}")`
972
+ section,
973
+ pointer,
974
+ listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
975
+ \u21B3 ${pointer}` : ""),
976
+ fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
905
977
  });
906
978
  return {
907
979
  type: "doc",
@@ -974,6 +1046,8 @@ ${formatStack(stack)}`;
974
1046
  const blocks = [];
975
1047
  for (const h of hydrated) {
976
1048
  itemsLeft--;
1049
+ const headline = h.meta.pointer ? `${h.item.content}
1050
+ ${h.meta.pointer}` : h.item.content;
977
1051
  const emit = (block) => {
978
1052
  bodyBudget -= Math.max(
979
1053
  0,
@@ -984,7 +1058,7 @@ ${formatStack(stack)}`;
984
1058
  if (h.error !== void 0) {
985
1059
  bodyFailures++;
986
1060
  emit(
987
- `${h.item.content}
1061
+ `${headline}
988
1062
 
989
1063
  _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
990
1064
  );
@@ -997,7 +1071,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
997
1071
  );
998
1072
  if (allowance < MIN_BODY_TOKENS) {
999
1073
  emit(
1000
- `${h.item.content}
1074
+ `${headline}
1001
1075
 
1002
1076
  _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
1003
1077
  );
@@ -1009,7 +1083,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
1009
1083
  );
1010
1084
  itemsWithBody++;
1011
1085
  emit(
1012
- `${h.item.content}
1086
+ `${headline}
1013
1087
 
1014
1088
  ${body2}` + (truncated ? `
1015
1089
 
@@ -1028,7 +1102,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
1028
1102
  sections.push(
1029
1103
  `## Relevant Specs & Docs (${blocks.length})
1030
1104
 
1031
- _Full text of the highest-ranked items. Every item carries the ID to fetch it in full._
1105
+ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
1032
1106
 
1033
1107
  ` + blocks.join("\n\n---\n\n")
1034
1108
  );
package/dist/index.js CHANGED
@@ -400,20 +400,28 @@ function registerGetDocs(server2, apiClient2) {
400
400
  "get_docs",
401
401
  {
402
402
  title: "Get Docs",
403
- description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search.",
403
+ description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
404
404
  inputSchema: {
405
405
  repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
406
406
  path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
407
407
  search: z3.string().optional().describe("Keyword search across document content"),
408
408
  doc_id: z3.string().optional().describe(
409
409
  "Specific document ID to retrieve full content"
410
+ ),
411
+ section: z3.string().optional().describe(
412
+ "Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
413
+ ),
414
+ chunk_id: z3.string().optional().describe(
415
+ "Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
410
416
  )
411
417
  }
412
418
  },
413
- async ({ repo, path, search, doc_id }) => {
419
+ async ({ repo, path, search, doc_id, section, chunk_id }) => {
414
420
  if (doc_id) {
421
+ const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
415
422
  const doc = await apiClient2.get(
416
- `/v1/docs/${encodeURIComponent(doc_id)}/content`
423
+ `/v1/docs/${encodeURIComponent(doc_id)}/content`,
424
+ anchor ? { section: anchor } : void 0
417
425
  );
418
426
  if (doc.error) {
419
427
  return {
@@ -426,8 +434,18 @@ function registerGetDocs(server2, apiClient2) {
426
434
  isError: true
427
435
  };
428
436
  }
437
+ let text2 = doc.content;
438
+ if (anchor && doc.section_resolved === false) {
439
+ text2 = `_Section "${anchor}" not found \u2014 returning the full document._
440
+
441
+ ` + text2;
442
+ } else if (anchor && doc.heading) {
443
+ text2 = `## ${doc.heading}
444
+
445
+ ${doc.content}`;
446
+ }
429
447
  return {
430
- content: [{ type: "text", text: doc.content }]
448
+ content: [{ type: "text", text: text2 }]
431
449
  };
432
450
  }
433
451
  const params = {};
@@ -451,10 +469,14 @@ function registerGetDocs(server2, apiClient2) {
451
469
  ]
452
470
  };
453
471
  }
454
- const text = items.map(
455
- (d) => `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})` + (d.snippet ? `
456
- > ${d.snippet}` : "")
457
- ).join("\n");
472
+ const text = items.map((d) => {
473
+ const sections = (d.outline ?? []).slice(0, 8);
474
+ const sectionHint = sections.length > 0 ? `
475
+ sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
476
+ return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
477
+ doc_id: \`${d.id}\`` + (d.snippet ? `
478
+ > ${d.snippet}` : "") + sectionHint;
479
+ }).join("\n");
458
480
  return {
459
481
  content: [
460
482
  {
@@ -463,7 +485,7 @@ function registerGetDocs(server2, apiClient2) {
463
485
 
464
486
  ${text}
465
487
 
466
- Use doc_id to retrieve full content.`
488
+ Use doc_id for full content, or doc_id + section to fetch one section.`
467
489
  }
468
490
  ]
469
491
  };
@@ -853,18 +875,63 @@ ${spec.taskContent}`);
853
875
  if (!body) throw new Error("spec has no content");
854
876
  return body;
855
877
  }
856
- async function fetchDocBody(api, docId) {
878
+ async function fetchDocBody(api, docId, anchor) {
857
879
  const doc = await api.get(
858
- `/v1/docs/${encodeURIComponent(docId)}/content`
880
+ `/v1/docs/${encodeURIComponent(docId)}/content`,
881
+ anchor ? { section: anchor } : void 0
859
882
  );
860
883
  if (doc.error) throw new Error(doc.error);
861
884
  const body = (doc.content ?? "").trim();
862
885
  if (!body) throw new Error("document is empty");
863
886
  return body;
864
887
  }
888
+ function githubDeepLink(repoFullName, path, anchor) {
889
+ return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
890
+ }
891
+ function formatSectionPointer(repoFullName, path, s) {
892
+ return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
893
+ }
894
+ var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
895
+ "the",
896
+ "a",
897
+ "an",
898
+ "of",
899
+ "to",
900
+ "and",
901
+ "or",
902
+ "for",
903
+ "in",
904
+ "on",
905
+ "with",
906
+ "how",
907
+ "why",
908
+ "what",
909
+ "add",
910
+ "new",
911
+ "use",
912
+ "using",
913
+ "update",
914
+ "fix"
915
+ ]);
916
+ function selectSection(outline, taskDescription) {
917
+ if (!outline || outline.length === 0) return void 0;
918
+ const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
919
+ let best = outline[0];
920
+ let bestScore = -1;
921
+ for (const s of outline) {
922
+ const heading = s.heading.toLowerCase();
923
+ let score = 0;
924
+ for (const kw of keywords) if (heading.includes(kw)) score++;
925
+ if (score > bestScore) {
926
+ best = s;
927
+ bestScore = score;
928
+ }
929
+ }
930
+ return best;
931
+ }
865
932
  async function hydrate(api, item, meta) {
866
933
  try {
867
- const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
934
+ const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
868
935
  return { item, meta, body };
869
936
  } catch (err) {
870
937
  return {
@@ -912,12 +979,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
912
979
  };
913
980
  }),
914
981
  ...docs.items.map((d) => {
982
+ const section = selectSection(d.outline, taskDescription);
983
+ const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
915
984
  refs.set(key("doc", d.id), {
916
985
  type: "doc",
917
986
  id: d.id,
918
987
  title: d.title,
919
- listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)`,
920
- fetchHint: `get_docs(doc_id="${d.id}")`
988
+ section,
989
+ pointer,
990
+ listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
991
+ \u21B3 ${pointer}` : ""),
992
+ fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
921
993
  });
922
994
  return {
923
995
  type: "doc",
@@ -990,6 +1062,8 @@ ${formatStack(stack)}`;
990
1062
  const blocks = [];
991
1063
  for (const h of hydrated) {
992
1064
  itemsLeft--;
1065
+ const headline = h.meta.pointer ? `${h.item.content}
1066
+ ${h.meta.pointer}` : h.item.content;
993
1067
  const emit = (block) => {
994
1068
  bodyBudget -= Math.max(
995
1069
  0,
@@ -1000,7 +1074,7 @@ ${formatStack(stack)}`;
1000
1074
  if (h.error !== void 0) {
1001
1075
  bodyFailures++;
1002
1076
  emit(
1003
- `${h.item.content}
1077
+ `${headline}
1004
1078
 
1005
1079
  _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
1006
1080
  );
@@ -1013,7 +1087,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
1013
1087
  );
1014
1088
  if (allowance < MIN_BODY_TOKENS) {
1015
1089
  emit(
1016
- `${h.item.content}
1090
+ `${headline}
1017
1091
 
1018
1092
  _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
1019
1093
  );
@@ -1025,7 +1099,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
1025
1099
  );
1026
1100
  itemsWithBody++;
1027
1101
  emit(
1028
- `${h.item.content}
1102
+ `${headline}
1029
1103
 
1030
1104
  ${body2}` + (truncated ? `
1031
1105
 
@@ -1044,7 +1118,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
1044
1118
  sections.push(
1045
1119
  `## Relevant Specs & Docs (${blocks.length})
1046
1120
 
1047
- _Full text of the highest-ranked items. Every item carries the ID to fetch it in full._
1121
+ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
1048
1122
 
1049
1123
  ` + blocks.join("\n\n---\n\n")
1050
1124
  );
@@ -23,6 +23,13 @@ export interface SpecSummary {
23
23
  requirementCount: number;
24
24
  updatedAt: string;
25
25
  }
26
+ /** One heading-scoped section of a document (#865). */
27
+ export interface DocSection {
28
+ heading: string;
29
+ level: number;
30
+ anchor: string;
31
+ chunkId: string;
32
+ }
26
33
  export interface DocListResponse {
27
34
  items: Array<{
28
35
  id: string;
@@ -32,6 +39,8 @@ export interface DocListResponse {
32
39
  doc_type: string;
33
40
  size: number;
34
41
  synced_at: string;
42
+ /** Structured section outline, when the doc has been re-indexed (#865). */
43
+ outline?: DocSection[];
35
44
  }>;
36
45
  }
37
46
  export interface TechStackEntry {
@@ -335,18 +335,63 @@ ${spec.taskContent}`);
335
335
  if (!body) throw new Error("spec has no content");
336
336
  return body;
337
337
  }
338
- async function fetchDocBody(api, docId) {
338
+ async function fetchDocBody(api, docId, anchor) {
339
339
  const doc = await api.get(
340
- `/v1/docs/${encodeURIComponent(docId)}/content`
340
+ `/v1/docs/${encodeURIComponent(docId)}/content`,
341
+ anchor ? { section: anchor } : void 0
341
342
  );
342
343
  if (doc.error) throw new Error(doc.error);
343
344
  const body = (doc.content ?? "").trim();
344
345
  if (!body) throw new Error("document is empty");
345
346
  return body;
346
347
  }
348
+ function githubDeepLink(repoFullName, path, anchor) {
349
+ return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
350
+ }
351
+ function formatSectionPointer(repoFullName, path, s) {
352
+ return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
353
+ }
354
+ var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
355
+ "the",
356
+ "a",
357
+ "an",
358
+ "of",
359
+ "to",
360
+ "and",
361
+ "or",
362
+ "for",
363
+ "in",
364
+ "on",
365
+ "with",
366
+ "how",
367
+ "why",
368
+ "what",
369
+ "add",
370
+ "new",
371
+ "use",
372
+ "using",
373
+ "update",
374
+ "fix"
375
+ ]);
376
+ function selectSection(outline, taskDescription) {
377
+ if (!outline || outline.length === 0) return void 0;
378
+ const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
379
+ let best = outline[0];
380
+ let bestScore = -1;
381
+ for (const s of outline) {
382
+ const heading = s.heading.toLowerCase();
383
+ let score = 0;
384
+ for (const kw of keywords) if (heading.includes(kw)) score++;
385
+ if (score > bestScore) {
386
+ best = s;
387
+ bestScore = score;
388
+ }
389
+ }
390
+ return best;
391
+ }
347
392
  async function hydrate(api, item, meta) {
348
393
  try {
349
- const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
394
+ const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
350
395
  return { item, meta, body };
351
396
  } catch (err) {
352
397
  return {
@@ -394,12 +439,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
394
439
  };
395
440
  }),
396
441
  ...docs.items.map((d) => {
442
+ const section = selectSection(d.outline, taskDescription);
443
+ const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
397
444
  refs.set(key("doc", d.id), {
398
445
  type: "doc",
399
446
  id: d.id,
400
447
  title: d.title,
401
- listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)`,
402
- fetchHint: `get_docs(doc_id="${d.id}")`
448
+ section,
449
+ pointer,
450
+ listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
451
+ \u21B3 ${pointer}` : ""),
452
+ fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
403
453
  });
404
454
  return {
405
455
  type: "doc",
@@ -472,6 +522,8 @@ ${formatStack(stack)}`;
472
522
  const blocks = [];
473
523
  for (const h of hydrated) {
474
524
  itemsLeft--;
525
+ const headline = h.meta.pointer ? `${h.item.content}
526
+ ${h.meta.pointer}` : h.item.content;
475
527
  const emit = (block) => {
476
528
  bodyBudget -= Math.max(
477
529
  0,
@@ -482,7 +534,7 @@ ${formatStack(stack)}`;
482
534
  if (h.error !== void 0) {
483
535
  bodyFailures++;
484
536
  emit(
485
- `${h.item.content}
537
+ `${headline}
486
538
 
487
539
  _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
488
540
  );
@@ -495,7 +547,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
495
547
  );
496
548
  if (allowance < MIN_BODY_TOKENS) {
497
549
  emit(
498
- `${h.item.content}
550
+ `${headline}
499
551
 
500
552
  _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
501
553
  );
@@ -507,7 +559,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
507
559
  );
508
560
  itemsWithBody++;
509
561
  emit(
510
- `${h.item.content}
562
+ `${headline}
511
563
 
512
564
  ${body2}` + (truncated ? `
513
565
 
@@ -526,7 +578,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
526
578
  sections.push(
527
579
  `## Relevant Specs & Docs (${blocks.length})
528
580
 
529
- _Full text of the highest-ranked items. Every item carries the ID to fetch it in full._
581
+ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
530
582
 
531
583
  ` + blocks.join("\n\n---\n\n")
532
584
  );
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/tools/get-docs.ts
4
+ import { z } from "zod";
5
+ function matchGlob(path, pattern) {
6
+ const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
7
+ return new RegExp(`^${regex}$`).test(path);
8
+ }
9
+ function registerGetDocs(server, apiClient) {
10
+ server.registerTool(
11
+ "get_docs",
12
+ {
13
+ title: "Get Docs",
14
+ description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
15
+ inputSchema: {
16
+ repo: z.string().optional().describe("Filter by repo (owner/repo format)"),
17
+ path: z.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
18
+ search: z.string().optional().describe("Keyword search across document content"),
19
+ doc_id: z.string().optional().describe(
20
+ "Specific document ID to retrieve full content"
21
+ ),
22
+ section: z.string().optional().describe(
23
+ "Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
24
+ ),
25
+ chunk_id: z.string().optional().describe(
26
+ "Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
27
+ )
28
+ }
29
+ },
30
+ async ({ repo, path, search, doc_id, section, chunk_id }) => {
31
+ if (doc_id) {
32
+ const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
33
+ const doc = await apiClient.get(
34
+ `/v1/docs/${encodeURIComponent(doc_id)}/content`,
35
+ anchor ? { section: anchor } : void 0
36
+ );
37
+ if (doc.error) {
38
+ return {
39
+ content: [
40
+ {
41
+ type: "text",
42
+ text: `Error retrieving document: ${doc.error}`
43
+ }
44
+ ],
45
+ isError: true
46
+ };
47
+ }
48
+ let text2 = doc.content;
49
+ if (anchor && doc.section_resolved === false) {
50
+ text2 = `_Section "${anchor}" not found \u2014 returning the full document._
51
+
52
+ ` + text2;
53
+ } else if (anchor && doc.heading) {
54
+ text2 = `## ${doc.heading}
55
+
56
+ ${doc.content}`;
57
+ }
58
+ return {
59
+ content: [{ type: "text", text: text2 }]
60
+ };
61
+ }
62
+ const params = {};
63
+ if (repo) params.repo = repo;
64
+ if (search) params.q = search;
65
+ const response = await apiClient.get(
66
+ "/v1/docs",
67
+ params
68
+ );
69
+ let items = response.items;
70
+ if (path) {
71
+ items = items.filter((item) => matchGlob(item.path, path));
72
+ }
73
+ if (items.length === 0) {
74
+ return {
75
+ content: [
76
+ {
77
+ type: "text",
78
+ text: `No documents found${repo ? ` in ${repo}` : ""}${path ? ` matching "${path}"` : ""}${search ? ` for "${search}"` : ""}.`
79
+ }
80
+ ]
81
+ };
82
+ }
83
+ const text = items.map((d) => {
84
+ const sections = (d.outline ?? []).slice(0, 8);
85
+ const sectionHint = sections.length > 0 ? `
86
+ sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
87
+ return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
88
+ doc_id: \`${d.id}\`` + (d.snippet ? `
89
+ > ${d.snippet}` : "") + sectionHint;
90
+ }).join("\n");
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: `Found ${items.length} document(s) (${response.total} total):
96
+
97
+ ${text}
98
+
99
+ Use doc_id for full content, or doc_id + section to fetch one section.`
100
+ }
101
+ ]
102
+ };
103
+ }
104
+ );
105
+ }
106
+ export {
107
+ registerGetDocs
108
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@systemdox/mcp-server",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "description": "MCP server that exposes SystemDox checks, specs, docs, and tech stack to AI coding tools",
6
6
  "main": "./dist/index.js",