@thallylabs/mcp 0.10.25 → 0.10.28

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
@@ -2,10 +2,9 @@
2
2
 
3
3
  A [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI
4
4
  tools — Claude Code, Claude Desktop, Cursor, Windsurf — manage
5
- [Thally](https://github.com/thallylabs/thally) knowledge surfaces through
6
- natural language. Tools can create, read, update, search, and migrate
7
- documentation, then trace a product-repository change into reviewable docs
8
- work.
5
+ [Thally](https://github.com/thallylabs/thally) docs through natural language.
6
+ Tools can create, read, update, search, and migrate documentation, then trace a
7
+ product change into an evidence-backed update for human review.
9
8
 
10
9
  ## Setup
11
10
 
package/dist/index.js CHANGED
@@ -243,19 +243,25 @@ function formatGroup(group, indent) {
243
243
  }
244
244
  return lines;
245
245
  }
246
+ function formatPages(pages, indent) {
247
+ return pages.flatMap((page) => {
248
+ if (typeof page !== "string") return formatGroup(page, indent);
249
+ const href = page === "introduction" ? "/" : `/${page}`;
250
+ return [`${indent}- ${page.padEnd(30)} \u2192 ${href}`];
251
+ });
252
+ }
246
253
  async function handleListPages(input) {
247
254
  const config = readDocsJson(input.projectDir);
248
255
  const lines = [];
249
256
  for (const tab of config.tabs) {
250
257
  lines.push(`Tab: ${tab.tab}`);
251
- if (tab.href) {
258
+ const navigationPages = [...tab.pages ?? [], ...tab.groups ?? []];
259
+ if (tab.href && navigationPages.length === 0) {
252
260
  lines.push(` \u2192 External: ${tab.href}`);
253
- } else if (tab.api) {
261
+ } else if (tab.api && navigationPages.length === 0) {
254
262
  lines.push(` \u2192 API Reference: ${tab.api.source}`);
255
- } else if (tab.groups && tab.groups.length > 0) {
256
- for (const group of tab.groups) {
257
- lines.push(...formatGroup(group, " "));
258
- }
263
+ } else if (navigationPages.length > 0) {
264
+ lines.push(...formatPages(navigationPages, " "));
259
265
  } else {
260
266
  lines.push(" (no pages)");
261
267
  }
@@ -270,18 +276,35 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync
270
276
  import { join as join3 } from "path";
271
277
 
272
278
  // src/lib/frontmatter.ts
273
- import matter from "gray-matter";
274
- var FRONTMATTER_OPTIONS = {
275
- engines: {
276
- javascript: () => ({}),
277
- js: () => ({})
278
- }
279
- };
279
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
280
280
  function parseFrontmatter(raw) {
281
- return matter(raw, FRONTMATTER_OPTIONS);
281
+ const source = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
282
+ const opening = /^---([^\r\n]*)\r?\n/.exec(source);
283
+ if (!opening || opening[1].startsWith("-")) return { content: source, data: {} };
284
+ const language = opening[1].trim().toLowerCase();
285
+ const remainder = source.slice(opening[0].length);
286
+ const closing = /^---[ \t]*\r?$/m.exec(remainder);
287
+ const matter = closing ? remainder.slice(0, closing.index) : remainder;
288
+ let content = closing ? remainder.slice(closing.index + closing[0].length) : "";
289
+ if (content.startsWith("\r\n")) content = content.slice(2);
290
+ else if (content.startsWith("\n")) content = content.slice(1);
291
+ if (matter.trim() === "" || !["", "yaml", "yml"].includes(language)) {
292
+ return { content, data: {} };
293
+ }
294
+ const parsed = parseYaml(matter);
295
+ return {
296
+ content,
297
+ data: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
298
+ };
282
299
  }
283
300
  function stringifyFrontmatter(body, data) {
284
- return matter.stringify(body, data);
301
+ const normalizedBody = body.endsWith("\n") ? body : `${body}
302
+ `;
303
+ const serialized = stringifyYaml(data, { lineWidth: 0 }).trimEnd();
304
+ return serialized === "{}" ? normalizedBody : `---
305
+ ${serialized}
306
+ ---
307
+ ${normalizedBody}`;
285
308
  }
286
309
 
287
310
  // src/tools/update-page.ts
@@ -884,8 +907,16 @@ function collectNavPageIds(groups, seen, duplicates) {
884
907
  }
885
908
  function addOrphanToNav(projectDir, pageId) {
886
909
  const config = readDocsJson(projectDir);
887
- const tab = config.tabs.find((t) => !t.href && !t.api && t.groups && t.groups.length > 0);
888
- if (!tab || !tab.groups) return;
910
+ const tab = config.tabs.find((candidate) => !candidate.href && !candidate.api && (candidate.pages?.length || candidate.groups?.length));
911
+ if (!tab) return;
912
+ if (tab.pages) {
913
+ if (!tab.pages.includes(pageId)) {
914
+ tab.pages.push(pageId);
915
+ writeDocsJson(projectDir, config);
916
+ }
917
+ return;
918
+ }
919
+ if (!tab.groups) return;
889
920
  const lastGroup = tab.groups[tab.groups.length - 1];
890
921
  const existing = lastGroup.pages.filter((p) => typeof p === "string");
891
922
  if (!existing.includes(pageId)) {
@@ -904,12 +935,14 @@ async function handleLintProject(input) {
904
935
  const navPageIds = /* @__PURE__ */ new Set();
905
936
  const duplicates = /* @__PURE__ */ new Set();
906
937
  for (const tab of config.tabs) {
907
- if (tab.href || tab.api) continue;
908
- if (!tab.groups || tab.groups.length === 0) {
938
+ const hasNavigationNodes = Boolean(tab.pages?.length || tab.groups?.length);
939
+ if ((tab.href || tab.api) && !hasNavigationNodes) continue;
940
+ if (!hasNavigationNodes) {
909
941
  issues.push({ severity: "error", message: `Tab "${tab.tab}" has no groups and no href \u2014 it will render empty` });
910
942
  continue;
911
943
  }
912
- collectNavPageIds(tab.groups.map((g) => g), navPageIds, duplicates);
944
+ collectNavPageIds(tab.pages ?? [], navPageIds, duplicates);
945
+ collectNavPageIds(tab.groups ?? [], navPageIds, duplicates);
913
946
  }
914
947
  for (const dup of duplicates) {
915
948
  issues.push({ severity: "error", message: `[duplicate] "${dup}" appears more than once in docs.json` });
@@ -1003,7 +1036,7 @@ async function handleLintProject(input) {
1003
1036
 
1004
1037
  // src/tools/translate-docs.ts
1005
1038
  import { z as z14 } from "zod";
1006
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, existsSync as existsSync8, mkdirSync as mkdirSync2 } from "fs";
1039
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, existsSync as existsSync8, mkdirSync as mkdirSync2 } from "fs";
1007
1040
  import { join as join9, dirname as dirname2 } from "path";
1008
1041
  import Anthropic from "@anthropic-ai/sdk";
1009
1042
  import pLimit from "p-limit";
@@ -1036,8 +1069,8 @@ function getAllPageIds(config) {
1036
1069
  const seen = /* @__PURE__ */ new Set();
1037
1070
  const hrefOnlyPages = [];
1038
1071
  for (const tab of config.tabs) {
1039
- if (tab.api && !tab.groups) continue;
1040
- if (!tab.groups && tab.href) {
1072
+ if (tab.api && !tab.pages && !tab.groups) continue;
1073
+ if (!tab.pages && !tab.groups && tab.href) {
1041
1074
  const pageId = tab.href.replace(/^\//, "");
1042
1075
  if (pageId && !seen.has(pageId)) {
1043
1076
  seen.add(pageId);
@@ -1046,13 +1079,10 @@ function getAllPageIds(config) {
1046
1079
  }
1047
1080
  continue;
1048
1081
  }
1049
- if (!tab.groups) continue;
1050
- for (const group of tab.groups) {
1051
- for (const id of collectPageIds(group.pages)) {
1052
- if (!seen.has(id)) {
1053
- seen.add(id);
1054
- ids.push(id);
1055
- }
1082
+ for (const id of collectPageIds([...tab.pages ?? [], ...tab.groups ?? []])) {
1083
+ if (!seen.has(id)) {
1084
+ seen.add(id);
1085
+ ids.push(id);
1056
1086
  }
1057
1087
  }
1058
1088
  }
@@ -1150,7 +1180,7 @@ async function handleTranslateDocs(input) {
1150
1180
  }
1151
1181
  const translated = await translatePage(sourceContent, targetLocale.label, locale, model, client);
1152
1182
  mkdirSync2(dirname2(targetFile), { recursive: true });
1153
- writeFileSync6(targetFile, translated + "\n", "utf8");
1183
+ writeFileSync5(targetFile, translated + "\n", "utf8");
1154
1184
  results.push({ pageId, success: true });
1155
1185
  } catch (err) {
1156
1186
  const msg = err instanceof Error ? err.message : String(err);
@@ -1573,10 +1603,10 @@ async function handleReadApiSpec(input) {
1573
1603
  }
1574
1604
 
1575
1605
  // src/tools/update-api-spec.ts
1576
- import { writeFileSync as writeFileSync7 } from "fs";
1606
+ import { writeFileSync as writeFileSync6 } from "fs";
1577
1607
  import { extname as extname2 } from "path";
1578
1608
  import { z as z17 } from "zod";
1579
- import { parse as parseYaml } from "yaml";
1609
+ import { parse as parseYaml2 } from "yaml";
1580
1610
  var updateApiSpecSchema = z17.object({
1581
1611
  projectDir: z17.string().describe("Path to the Thally project root"),
1582
1612
  source: z17.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
@@ -1584,7 +1614,7 @@ var updateApiSpecSchema = z17.object({
1584
1614
  });
1585
1615
  function parseDocument(source, content) {
1586
1616
  try {
1587
- return extname2(source).toLowerCase() === ".json" ? JSON.parse(content) : parseYaml(content);
1617
+ return extname2(source).toLowerCase() === ".json" ? JSON.parse(content) : parseYaml2(content);
1588
1618
  } catch {
1589
1619
  throw new Error(
1590
1620
  `OpenAPI source is not valid ${extname2(source).toLowerCase() === ".json" ? "JSON" : "YAML"}.`
@@ -1599,7 +1629,7 @@ async function handleUpdateApiSpec(input) {
1599
1629
  }
1600
1630
  const content = input.content.endsWith("\n") ? input.content : `${input.content}
1601
1631
  `;
1602
- writeFileSync7(source.path, content, "utf8");
1632
+ writeFileSync6(source.path, content, "utf8");
1603
1633
  return `\u2705 API source updated: ${source.source}`;
1604
1634
  }
1605
1635
 
package/dist/tools.d.ts CHANGED
@@ -5,8 +5,8 @@ import { z } from 'zod';
5
5
  * - `project` — reads/writes a local Thally project directory (`projectDir`).
6
6
  * - `site` — queries a deployed Thally site over HTTP (`siteUrl`).
7
7
  *
8
- * The remote MCP endpoint (A6) exposes only what's safe over HTTP; the docs
9
- * agent (A1) drives the `project` tools against a checked-out docs repo. Both
8
+ * The remote MCP endpoint exposes only what's safe over HTTP; the docs
9
+ * agent drives the `project` tools against a checked-out docs repo. Both
10
10
  * consume this one registry instead of re-declaring the tools.
11
11
  */
12
12
  type ToolScope = "project" | "site";
package/dist/tools.js CHANGED
@@ -234,19 +234,25 @@ function formatGroup(group, indent) {
234
234
  }
235
235
  return lines;
236
236
  }
237
+ function formatPages(pages, indent) {
238
+ return pages.flatMap((page) => {
239
+ if (typeof page !== "string") return formatGroup(page, indent);
240
+ const href = page === "introduction" ? "/" : `/${page}`;
241
+ return [`${indent}- ${page.padEnd(30)} \u2192 ${href}`];
242
+ });
243
+ }
237
244
  async function handleListPages(input) {
238
245
  const config = readDocsJson(input.projectDir);
239
246
  const lines = [];
240
247
  for (const tab of config.tabs) {
241
248
  lines.push(`Tab: ${tab.tab}`);
242
- if (tab.href) {
249
+ const navigationPages = [...tab.pages ?? [], ...tab.groups ?? []];
250
+ if (tab.href && navigationPages.length === 0) {
243
251
  lines.push(` \u2192 External: ${tab.href}`);
244
- } else if (tab.api) {
252
+ } else if (tab.api && navigationPages.length === 0) {
245
253
  lines.push(` \u2192 API Reference: ${tab.api.source}`);
246
- } else if (tab.groups && tab.groups.length > 0) {
247
- for (const group of tab.groups) {
248
- lines.push(...formatGroup(group, " "));
249
- }
254
+ } else if (navigationPages.length > 0) {
255
+ lines.push(...formatPages(navigationPages, " "));
250
256
  } else {
251
257
  lines.push(" (no pages)");
252
258
  }
@@ -261,18 +267,35 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync
261
267
  import { join as join3 } from "path";
262
268
 
263
269
  // src/lib/frontmatter.ts
264
- import matter from "gray-matter";
265
- var FRONTMATTER_OPTIONS = {
266
- engines: {
267
- javascript: () => ({}),
268
- js: () => ({})
269
- }
270
- };
270
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
271
271
  function parseFrontmatter(raw) {
272
- return matter(raw, FRONTMATTER_OPTIONS);
272
+ const source = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
273
+ const opening = /^---([^\r\n]*)\r?\n/.exec(source);
274
+ if (!opening || opening[1].startsWith("-")) return { content: source, data: {} };
275
+ const language = opening[1].trim().toLowerCase();
276
+ const remainder = source.slice(opening[0].length);
277
+ const closing = /^---[ \t]*\r?$/m.exec(remainder);
278
+ const matter = closing ? remainder.slice(0, closing.index) : remainder;
279
+ let content = closing ? remainder.slice(closing.index + closing[0].length) : "";
280
+ if (content.startsWith("\r\n")) content = content.slice(2);
281
+ else if (content.startsWith("\n")) content = content.slice(1);
282
+ if (matter.trim() === "" || !["", "yaml", "yml"].includes(language)) {
283
+ return { content, data: {} };
284
+ }
285
+ const parsed = parseYaml(matter);
286
+ return {
287
+ content,
288
+ data: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
289
+ };
273
290
  }
274
291
  function stringifyFrontmatter(body, data) {
275
- return matter.stringify(body, data);
292
+ const normalizedBody = body.endsWith("\n") ? body : `${body}
293
+ `;
294
+ const serialized = stringifyYaml(data, { lineWidth: 0 }).trimEnd();
295
+ return serialized === "{}" ? normalizedBody : `---
296
+ ${serialized}
297
+ ---
298
+ ${normalizedBody}`;
276
299
  }
277
300
 
278
301
  // src/tools/update-page.ts
@@ -875,8 +898,16 @@ function collectNavPageIds(groups, seen, duplicates) {
875
898
  }
876
899
  function addOrphanToNav(projectDir, pageId) {
877
900
  const config = readDocsJson(projectDir);
878
- const tab = config.tabs.find((t) => !t.href && !t.api && t.groups && t.groups.length > 0);
879
- if (!tab || !tab.groups) return;
901
+ const tab = config.tabs.find((candidate) => !candidate.href && !candidate.api && (candidate.pages?.length || candidate.groups?.length));
902
+ if (!tab) return;
903
+ if (tab.pages) {
904
+ if (!tab.pages.includes(pageId)) {
905
+ tab.pages.push(pageId);
906
+ writeDocsJson(projectDir, config);
907
+ }
908
+ return;
909
+ }
910
+ if (!tab.groups) return;
880
911
  const lastGroup = tab.groups[tab.groups.length - 1];
881
912
  const existing = lastGroup.pages.filter((p) => typeof p === "string");
882
913
  if (!existing.includes(pageId)) {
@@ -895,12 +926,14 @@ async function handleLintProject(input) {
895
926
  const navPageIds = /* @__PURE__ */ new Set();
896
927
  const duplicates = /* @__PURE__ */ new Set();
897
928
  for (const tab of config.tabs) {
898
- if (tab.href || tab.api) continue;
899
- if (!tab.groups || tab.groups.length === 0) {
929
+ const hasNavigationNodes = Boolean(tab.pages?.length || tab.groups?.length);
930
+ if ((tab.href || tab.api) && !hasNavigationNodes) continue;
931
+ if (!hasNavigationNodes) {
900
932
  issues.push({ severity: "error", message: `Tab "${tab.tab}" has no groups and no href \u2014 it will render empty` });
901
933
  continue;
902
934
  }
903
- collectNavPageIds(tab.groups.map((g) => g), navPageIds, duplicates);
935
+ collectNavPageIds(tab.pages ?? [], navPageIds, duplicates);
936
+ collectNavPageIds(tab.groups ?? [], navPageIds, duplicates);
904
937
  }
905
938
  for (const dup of duplicates) {
906
939
  issues.push({ severity: "error", message: `[duplicate] "${dup}" appears more than once in docs.json` });
@@ -994,7 +1027,7 @@ async function handleLintProject(input) {
994
1027
 
995
1028
  // src/tools/translate-docs.ts
996
1029
  import { z as z14 } from "zod";
997
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, existsSync as existsSync8, mkdirSync as mkdirSync2 } from "fs";
1030
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, existsSync as existsSync8, mkdirSync as mkdirSync2 } from "fs";
998
1031
  import { join as join9, dirname as dirname2 } from "path";
999
1032
  import Anthropic from "@anthropic-ai/sdk";
1000
1033
  import pLimit from "p-limit";
@@ -1027,8 +1060,8 @@ function getAllPageIds(config) {
1027
1060
  const seen = /* @__PURE__ */ new Set();
1028
1061
  const hrefOnlyPages = [];
1029
1062
  for (const tab of config.tabs) {
1030
- if (tab.api && !tab.groups) continue;
1031
- if (!tab.groups && tab.href) {
1063
+ if (tab.api && !tab.pages && !tab.groups) continue;
1064
+ if (!tab.pages && !tab.groups && tab.href) {
1032
1065
  const pageId = tab.href.replace(/^\//, "");
1033
1066
  if (pageId && !seen.has(pageId)) {
1034
1067
  seen.add(pageId);
@@ -1037,13 +1070,10 @@ function getAllPageIds(config) {
1037
1070
  }
1038
1071
  continue;
1039
1072
  }
1040
- if (!tab.groups) continue;
1041
- for (const group of tab.groups) {
1042
- for (const id of collectPageIds(group.pages)) {
1043
- if (!seen.has(id)) {
1044
- seen.add(id);
1045
- ids.push(id);
1046
- }
1073
+ for (const id of collectPageIds([...tab.pages ?? [], ...tab.groups ?? []])) {
1074
+ if (!seen.has(id)) {
1075
+ seen.add(id);
1076
+ ids.push(id);
1047
1077
  }
1048
1078
  }
1049
1079
  }
@@ -1141,7 +1171,7 @@ async function handleTranslateDocs(input) {
1141
1171
  }
1142
1172
  const translated = await translatePage(sourceContent, targetLocale.label, locale, model, client);
1143
1173
  mkdirSync2(dirname2(targetFile), { recursive: true });
1144
- writeFileSync6(targetFile, translated + "\n", "utf8");
1174
+ writeFileSync5(targetFile, translated + "\n", "utf8");
1145
1175
  results.push({ pageId, success: true });
1146
1176
  } catch (err) {
1147
1177
  const msg = err instanceof Error ? err.message : String(err);
@@ -1564,10 +1594,10 @@ async function handleReadApiSpec(input) {
1564
1594
  }
1565
1595
 
1566
1596
  // src/tools/update-api-spec.ts
1567
- import { writeFileSync as writeFileSync7 } from "fs";
1597
+ import { writeFileSync as writeFileSync6 } from "fs";
1568
1598
  import { extname as extname2 } from "path";
1569
1599
  import { z as z17 } from "zod";
1570
- import { parse as parseYaml } from "yaml";
1600
+ import { parse as parseYaml2 } from "yaml";
1571
1601
  var updateApiSpecSchema = z17.object({
1572
1602
  projectDir: z17.string().describe("Path to the Thally project root"),
1573
1603
  source: z17.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
@@ -1575,7 +1605,7 @@ var updateApiSpecSchema = z17.object({
1575
1605
  });
1576
1606
  function parseDocument(source, content) {
1577
1607
  try {
1578
- return extname2(source).toLowerCase() === ".json" ? JSON.parse(content) : parseYaml(content);
1608
+ return extname2(source).toLowerCase() === ".json" ? JSON.parse(content) : parseYaml2(content);
1579
1609
  } catch {
1580
1610
  throw new Error(
1581
1611
  `OpenAPI source is not valid ${extname2(source).toLowerCase() === ".json" ? "JSON" : "YAML"}.`
@@ -1590,7 +1620,7 @@ async function handleUpdateApiSpec(input) {
1590
1620
  }
1591
1621
  const content = input.content.endsWith("\n") ? input.content : `${input.content}
1592
1622
  `;
1593
- writeFileSync7(source.path, content, "utf8");
1623
+ writeFileSync6(source.path, content, "utf8");
1594
1624
  return `\u2705 API source updated: ${source.source}`;
1595
1625
  }
1596
1626
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thallylabs/mcp",
3
- "version": "0.10.25",
4
- "description": "MCP server for managing Thally knowledge surfaces and tracing product changes into documentation work.",
3
+ "version": "0.10.28",
4
+ "description": "MCP server for managing Thally docs and tracing product changes into evidence-backed updates for human review.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=18"
@@ -38,8 +38,7 @@
38
38
  "dependencies": {
39
39
  "@anthropic-ai/sdk": "^0.36.0",
40
40
  "@modelcontextprotocol/sdk": "^1.15.0",
41
- "create-thally-docs": "0.10.23",
42
- "gray-matter": "^4.0.3",
41
+ "create-thally-docs": "0.10.26",
43
42
  "p-limit": "^6.1.0",
44
43
  "yaml": "^2.8.2",
45
44
  "zod": "^3.0.0"