blume 1.4.2 → 1.5.0

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.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -1,11 +1,19 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import type { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js";
2
3
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
4
  import {
4
5
  CallToolRequestSchema,
5
6
  ListToolsRequestSchema,
6
7
  } from "@modelcontextprotocol/sdk/types.js";
8
+ import { z } from "zod";
7
9
 
8
- import { stripBasePath, withBasePath } from "../../core/base-path.ts";
10
+ import {
11
+ normalizeRoute as normalizePageRoute,
12
+ stripBasePath,
13
+ withBasePath,
14
+ } from "../../core/base-path.ts";
15
+ import { absoluteUrl } from "../../core/site-url.ts";
16
+ import { trimEnd } from "../../core/trim.ts";
9
17
  import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
10
18
  import type { OramaDoc } from "../../search/orama-index.ts";
11
19
  import type { McpData } from "./data.ts";
@@ -15,8 +23,9 @@ import { MCP_TOOLS } from "./tools.ts";
15
23
  * The low-level SDK `Server` is used (rather than the high-level `McpServer`)
16
24
  * because the latter's `registerTool` is generic over the caller's Zod instance;
17
25
  * Blume's zod and the SDK's may resolve to different copies, whose types don't
18
- * unify. Hand-written JSON Schema and the SDK's own request schemas avoid that
19
- * entirely.
26
+ * unify. Each tool's input is defined once in Blume's own zod: the runtime
27
+ * parse and the JSON Schema advertised by `tools/list` (via `z.toJSONSchema`)
28
+ * derive from the same definition, so they cannot drift.
20
29
  */
21
30
 
22
31
  /** Default and maximum number of hits returned by `search_docs`. */
@@ -25,7 +34,7 @@ const MAX_SEARCH_LIMIT = 20;
25
34
  /** Excerpt length when a page has no description. */
26
35
  const EXCERPT_LENGTH = 200;
27
36
 
28
- const CORS_HEADERS: Record<string, string> = {
37
+ const CORS_HEADERS = {
29
38
  "Access-Control-Allow-Headers":
30
39
  "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
31
40
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -33,97 +42,167 @@ const CORS_HEADERS: Record<string, string> = {
33
42
  "Access-Control-Expose-Headers": "Mcp-Session-Id",
34
43
  };
35
44
 
36
- /** The optional content-type filter `search_docs` and `list_pages` share. */
37
- const CONTENT_TYPES_SCHEMA = {
38
- description:
39
- 'Only include pages of these content types (frontmatter `type`, e.g. `["doc", "rfc"]`). `list_pages` shows each page\'s type. Omit to include every type.',
40
- items: { type: "string" },
41
- type: "array",
42
- } as const;
43
-
44
- /** The optional facet filter `search_docs` and `list_pages` share. */
45
- const FILTERS_SCHEMA = {
46
- additionalProperties: { type: "string" },
47
- description:
48
- 'Only include pages matching every facet, key → required value (e.g. `{"status": "enforced"}`). Facets are metadata the site declares per content type; `list_pages` shows each page\'s facet values. Omit for no facet filtering.',
49
- type: "object",
50
- } as const;
51
-
52
- /** JSON Schema for each tool's input, keyed by tool name. */
53
- const INPUT_SCHEMAS: Record<string, Record<string, unknown>> = {
54
- get_navigation: { properties: {}, type: "object" },
55
- get_page: {
56
- properties: {
57
- route: {
58
- description: "The page route, e.g. `/guides/install`.",
59
- type: "string",
60
- },
61
- },
62
- required: ["route"],
63
- type: "object",
64
- },
65
- list_pages: {
66
- properties: {
67
- contentTypes: CONTENT_TYPES_SCHEMA,
68
- filters: FILTERS_SCHEMA,
69
- },
70
- type: "object",
71
- },
72
- search_docs: {
73
- properties: {
74
- contentTypes: CONTENT_TYPES_SCHEMA,
75
- filters: FILTERS_SCHEMA,
76
- limit: {
77
- description: `Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`,
78
- maximum: MAX_SEARCH_LIMIT,
79
- minimum: 1,
80
- type: "integer",
81
- },
82
- query: { description: "The search query.", type: "string" },
83
- },
84
- required: ["query"],
85
- type: "object",
86
- },
87
- };
88
-
89
- /** The `tools/list` payload, derived from shared metadata + input schemas. */
90
- const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
91
- annotations: tool.annotations,
92
- description: tool.description,
93
- inputSchema: INPUT_SCHEMAS[tool.name],
94
- name: tool.name,
95
- title: tool.title,
96
- }));
97
-
98
- const asString = (value: unknown): string =>
99
- typeof value === "string" ? value : "";
45
+ // Each field is a preprocess pipe: the input side accepts the sloppy shapes
46
+ // LLM callers actually send (a bare string for an array field, `[]`/`{}`
47
+ // meaning "no filter", out-of-range limits clamped rather than rejected), and
48
+ // the pipe's *output* side is the clean shape which is exactly what
49
+ // `z.toJSONSchema` emits for `tools/list`. No coercion can ever fail, so a
50
+ // tool call is never rejected on argument shape, matching the previous
51
+ // hand-rolled coercions.
100
52
 
101
53
  /**
102
- * The `contentTypes` filter as a string array, or `undefined` when absent or
103
- * empty an agent sending `[]` means "no filter", not "match nothing". A bare
54
+ * The optional content-type filter `search_docs` and `list_pages` share.
55
+ * `[]` or no usable strings mean "no filter", not "match nothing"; a bare
104
56
  * string is accepted as a one-element list.
105
57
  */
106
- const asContentTypes = (value: unknown): string[] | undefined => {
107
- const list = Array.isArray(value)
108
- ? value.filter((entry): entry is string => typeof entry === "string")
109
- : [value].filter((entry): entry is string => typeof entry === "string");
58
+ const contentTypesField = z.preprocess((value) => {
59
+ const list = (Array.isArray(value) ? value : [value]).filter(
60
+ (entry): entry is string => typeof entry === "string"
61
+ );
110
62
  return list.length > 0 ? list : undefined;
111
- };
63
+ }, z.array(z.string()).optional().describe('Only include pages of these content types (frontmatter `type`, e.g. `["doc", "rfc"]`). `list_pages` shows each page\'s type. Omit to include every type.'));
112
64
 
113
65
  /**
114
- * The `filters` facet map with only its string-valued entries, or `undefined`
115
- * when nothing usable remains — an empty `{}` means "no filter".
66
+ * The optional facet filter `search_docs` and `list_pages` share. Only
67
+ * string-valued entries survive; an empty `{}` means "no filter".
116
68
  */
117
- const asFacetFilters = (value: unknown): Record<string, string> | undefined => {
118
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
69
+ /** Accepts any plain object, so the string-valued entries can be sifted out. */
70
+ const looseFacetObject = z.record(z.string(), z.unknown());
71
+
72
+ const filtersField = z.preprocess((value) => {
73
+ const candidate = looseFacetObject.safeParse(value);
74
+ if (!candidate.success) {
119
75
  return;
120
76
  }
121
- const entries = Object.entries(value).filter(
77
+ const entries = Object.entries(candidate.data).filter(
122
78
  (entry): entry is [string, string] => typeof entry[1] === "string"
123
79
  );
124
80
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
81
+ }, z.record(z.string(), z.string()).optional().describe('Only include pages matching every facet, key → required value (e.g. `{"status": "enforced"}`). Facets are metadata the site declares per content type; `list_pages` shows each page\'s facet values. Omit for no facet filtering.'));
82
+
83
+ /** Clamped into range rather than rejected; non-numeric means the default. */
84
+ const limitField = z.preprocess(
85
+ (value) => {
86
+ // `Number` is the identity on numbers, so one conversion covers both the
87
+ // well-typed call and a numeric string.
88
+ const num = Number(value);
89
+ return Number.isFinite(num)
90
+ ? Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT)
91
+ : undefined;
92
+ },
93
+ z
94
+ .int()
95
+ .min(1)
96
+ .max(MAX_SEARCH_LIMIT)
97
+ .optional()
98
+ .describe(`Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`)
99
+ );
100
+
101
+ /** A required text field; a missing or non-string value coerces to "". */
102
+ const textField = (description: string) =>
103
+ z.preprocess((value) => {
104
+ const parsed = z.string().safeParse(value);
105
+ return parsed.success ? parsed.data : "";
106
+ }, z.string().describe(description));
107
+
108
+ /** An optional trimmed text field; blank or non-string means "absent". */
109
+ const optionalTextField = (description: string) =>
110
+ z.preprocess((value) => {
111
+ const parsed = z.string().safeParse(value);
112
+ const trimmed = parsed.success ? parsed.data.trim() : "";
113
+ return trimmed || undefined;
114
+ }, z.string().optional().describe(description));
115
+
116
+ /** The optional locale filter `search_docs` and `list_pages` share. */
117
+ const localeField = optionalTextField(
118
+ "Only include pages in this locale (e.g. `fr`). Omit for every language."
119
+ );
120
+
121
+ /** The optional docs-version scope `search_docs` and `list_pages` share. */
122
+ const versionField = optionalTextField(
123
+ 'Docs version to scope to on a versioned site: `"latest"` (the default — current docs only), `"all"` (every version), or an archived version id (e.g. `"v1.0"`). Ignored when the site is unversioned.'
124
+ );
125
+
126
+ /** Every tool's input schema — the runtime parse and tools/list source. */
127
+ const TOOL_INPUTS = {
128
+ get_navigation: z.object({
129
+ locale: optionalTextField(
130
+ "Locale whose navigation tree to return (defaults to the default locale)."
131
+ ),
132
+ version: optionalTextField(
133
+ "Archived version id whose tree to return (defaults to the current docs)."
134
+ ),
135
+ }),
136
+ get_page: z.object({
137
+ route: textField("The page route, e.g. `/guides/install`."),
138
+ }),
139
+ list_pages: z.object({
140
+ contentTypes: contentTypesField,
141
+ filters: filtersField,
142
+ locale: localeField,
143
+ version: versionField,
144
+ }),
145
+ search_docs: z.object({
146
+ contentTypes: contentTypesField,
147
+ filters: filtersField,
148
+ limit: limitField,
149
+ locale: localeField,
150
+ query: textField("The search query."),
151
+ version: versionField,
152
+ }),
125
153
  };
126
154
 
155
+ /**
156
+ * A tool's advertised JSON Schema. The dialect key is dropped (noise in a
157
+ * tools/list payload), as is the root `additionalProperties: false` — the
158
+ * runtime strips unknown keys rather than rejecting them, and the advertised
159
+ * schema shouldn't promise stricter validation than the server performs.
160
+ */
161
+ const inputSchemaFor = (schema: z.ZodType) => {
162
+ const {
163
+ $schema: _dialect,
164
+ additionalProperties: _closed,
165
+ ...rest
166
+ } = z.toJSONSchema(schema);
167
+ return rest;
168
+ };
169
+
170
+ /** The `tools/list` payload, derived from shared metadata + input schemas. */
171
+ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
172
+ annotations: tool.annotations,
173
+ description: tool.description,
174
+ inputSchema: inputSchemaFor(
175
+ // SAFETY: TOOL_INPUTS declares a schema for every MCP_TOOLS name; the two
176
+ // lists are maintained together so names and descriptions never drift.
177
+ TOOL_INPUTS[tool.name as keyof typeof TOOL_INPUTS]
178
+ ),
179
+ name: tool.name,
180
+ title: tool.title,
181
+ }));
182
+
183
+ /** One `search_docs` result entry; `version` only appears on versioned sites. */
184
+ interface SearchHitPayload {
185
+ contentType: string | undefined;
186
+ excerpt: string;
187
+ facets: Record<string, string> | undefined;
188
+ route: string;
189
+ title: string;
190
+ url: string;
191
+ version?: string;
192
+ }
193
+
194
+ /** One `list_pages` entry; `version` only appears on versioned sites. */
195
+ interface PageListingPayload {
196
+ contentType: string;
197
+ description: string | undefined;
198
+ facets: Record<string, string> | undefined;
199
+ lastModified: string | null;
200
+ route: string;
201
+ title: string;
202
+ url: string;
203
+ version?: string;
204
+ }
205
+
127
206
  /** Whether a page's facet values satisfy every requested filter entry. */
128
207
  const matchesFacets = (
129
208
  facets: Record<string, string> | undefined,
@@ -131,14 +210,47 @@ const matchesFacets = (
131
210
  ): boolean =>
132
211
  Object.entries(filters).every(([key, value]) => facets?.[key] === value);
133
212
 
134
- const asLimit = (value: unknown): number => {
135
- const num = typeof value === "number" ? value : Number(value);
136
- if (!Number.isFinite(num)) {
137
- return DEFAULT_SEARCH_LIMIT;
213
+ /**
214
+ * Resolve the `version` scope on a versioned site: `undefined` disables the
215
+ * filter (`"all"`), `""` is the current docs (the default — agents almost
216
+ * always want the live documentation), and anything else is an archived id
217
+ * (an unknown id simply matches nothing). On an unversioned site the input is
218
+ * ignored entirely. The input arrives pre-trimmed (blank coerced to absent)
219
+ * from the tool's input schema.
220
+ */
221
+ const asVersionScope = (
222
+ value: string | undefined,
223
+ data: McpData
224
+ ): string | undefined => {
225
+ if (!data.archivedVersions) {
226
+ return;
227
+ }
228
+ if (value === "all") {
229
+ return;
230
+ }
231
+ if (value === undefined || value === "latest" || value === "current") {
232
+ return "";
138
233
  }
139
- return Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT);
234
+ return value;
140
235
  };
141
236
 
237
+ /**
238
+ * Error message for a `get_navigation` version id that isn't a configured
239
+ * archived version, or `null` when the id is valid (or the site is
240
+ * unversioned, where the id is ignored like the other tools' scopes). Unlike
241
+ * `asVersionScope`'s match-nothing filters, a bad id here would otherwise
242
+ * silently return the *current* tree posing as the requested snapshot.
243
+ */
244
+ const unknownVersionError = (
245
+ versionId: string | undefined,
246
+ data: McpData
247
+ ): string | null =>
248
+ versionId &&
249
+ data.archivedVersions &&
250
+ !data.archivedVersions.includes(versionId)
251
+ ? `Unknown version "${versionId}". Archived versions: ${data.archivedVersions.join(", ")}.`
252
+ : null;
253
+
142
254
  /**
143
255
  * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
144
256
  * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
@@ -160,11 +272,10 @@ const normalizeRoute = (input: string, data: McpData): string => {
160
272
  } catch {
161
273
  // Malformed percent sequence — compare it as written.
162
274
  }
163
- const noTrailing = value.replace(/\/+$/u, "");
164
- const noSuffix = noTrailing.replace(/\.mdx?$/u, "");
165
- const withSlash = noSuffix.startsWith("/") ? noSuffix : `/${noSuffix}`;
166
- const based = stripBasePath(data.base, withSlash);
167
- return based === "" ? "/" : based;
275
+ // Trailing slashes come off before the suffix so `/a/b.md/` still loses its
276
+ // `.md`; normalizePageRoute then settles the leading slash.
277
+ const noSuffix = trimEnd(value, "/").replace(/\.mdx?$/u, "");
278
+ return stripBasePath(data.base, normalizePageRoute(noSuffix));
168
279
  };
169
280
 
170
281
  /** Build the absolute (or root-relative) URL for a route. */
@@ -174,7 +285,7 @@ const urlFor = (route: string, data: McpData): string => {
174
285
  const path = withBasePath(data.base, route);
175
286
  // Concatenate rather than `new URL(path, site)` — a root-absolute path
176
287
  // would drop the base path of a subpath deployment (`acme.com/docs`).
177
- return data.site ? `${data.site.replace(/\/+$/u, "")}${path}` : path;
288
+ return data.site ? absoluteUrl(data.site, path) : path;
178
289
  };
179
290
 
180
291
  /** A hit's excerpt: its description, else the head of its content with an
@@ -187,10 +298,11 @@ const excerptFor = (doc: OramaDoc): string => {
187
298
  return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
188
299
  };
189
300
 
190
- const text = (value: string, isError = false) => ({
191
- content: [{ text: value, type: "text" as const }],
192
- ...(isError ? { isError: true } : {}),
193
- });
301
+ /** A tool call's text result, marked as an error when `isError` is set. */
302
+ const text = (value: string, isError = false) => {
303
+ const content = [{ text: value, type: "text" as const }];
304
+ return isError ? { content, isError: true } : { content };
305
+ };
194
306
 
195
307
  /** Lazily builds the Orama index over a snapshot's documents, once. */
196
308
  export type OramaIndexProvider = () => Promise<
@@ -218,12 +330,12 @@ export const buildServer = (
218
330
  data: McpData,
219
331
  index: OramaIndexProvider
220
332
  ): Server => {
333
+ const serverOptions: ServerOptions = data.instructions
334
+ ? { capabilities: { tools: {} }, instructions: data.instructions }
335
+ : { capabilities: { tools: {} } };
221
336
  const server = new Server(
222
337
  { name: data.name, version: data.version },
223
- {
224
- capabilities: { tools: {} },
225
- ...(data.instructions ? { instructions: data.instructions } : {}),
226
- }
338
+ serverOptions
227
339
  );
228
340
 
229
341
  server.setRequestHandler(ListToolsRequestSchema, () => ({
@@ -234,31 +346,41 @@ export const buildServer = (
234
346
  const { arguments: args = {}, name } = request.params;
235
347
 
236
348
  if (name === "search_docs") {
349
+ const input = TOOL_INPUTS.search_docs.parse(args);
237
350
  const db = await index();
238
351
  const hits = await queryOramaIndex(
239
352
  db,
240
- asString(args.query),
241
- asLimit(args.limit),
353
+ input.query,
354
+ input.limit ?? DEFAULT_SEARCH_LIMIT,
242
355
  {
243
- contentTypes: asContentTypes(args.contentTypes),
244
- facets: asFacetFilters(args.filters),
356
+ contentTypes: input.contentTypes,
357
+ facets: input.filters,
358
+ locale: input.locale,
359
+ version: asVersionScope(input.version, data),
245
360
  }
246
361
  );
247
362
  // `route` is the key `get_page` takes (the tool descriptions promise
248
363
  // it); `url` is where the page is served.
249
- const results = hits.map((doc: OramaDoc) => ({
250
- contentType: doc.contentType,
251
- excerpt: excerptFor(doc),
252
- facets: doc.facets,
253
- route: doc.route,
254
- title: doc.title,
255
- url: urlFor(doc.route, data),
256
- }));
364
+ const results = hits.map((doc: OramaDoc) => {
365
+ const hit: SearchHitPayload = {
366
+ contentType: doc.contentType,
367
+ excerpt: excerptFor(doc),
368
+ facets: doc.facets,
369
+ route: doc.route,
370
+ title: doc.title,
371
+ url: urlFor(doc.route, data),
372
+ };
373
+ if (data.archivedVersions) {
374
+ hit.version = doc.version ?? "";
375
+ }
376
+ return hit;
377
+ });
257
378
  return text(JSON.stringify(results, null, 2));
258
379
  }
259
380
 
260
381
  if (name === "get_page") {
261
- const key = normalizeRoute(asString(args.route), data);
382
+ const input = TOOL_INPUTS.get_page.parse(args);
383
+ const key = normalizeRoute(input.route, data);
262
384
  const markdown = data.pages[key];
263
385
  if (markdown === undefined) {
264
386
  return text(
@@ -270,24 +392,33 @@ export const buildServer = (
270
392
  }
271
393
 
272
394
  if (name === "list_pages") {
273
- const contentTypes = asContentTypes(args.contentTypes);
274
- const filters = asFacetFilters(args.filters);
395
+ const input = TOOL_INPUTS.list_pages.parse(args);
396
+ const { contentTypes, filters, locale } = input;
397
+ const versionScope = asVersionScope(input.version, data);
275
398
  const routes = data.routes.filter(
276
399
  (route) =>
277
400
  (!contentTypes || contentTypes.includes(route.contentType)) &&
278
- (!filters || matchesFacets(route.facets, filters))
401
+ (!filters || matchesFacets(route.facets, filters)) &&
402
+ (!locale || route.locale === locale) &&
403
+ (versionScope === undefined || route.version === versionScope)
279
404
  );
280
405
  return text(
281
406
  JSON.stringify(
282
- routes.map((route) => ({
283
- contentType: route.contentType,
284
- description: route.description,
285
- facets: route.facets,
286
- lastModified: route.lastModified,
287
- route: route.route,
288
- title: route.title,
289
- url: urlFor(route.route, data),
290
- })),
407
+ routes.map((route) => {
408
+ const listing: PageListingPayload = {
409
+ contentType: route.contentType,
410
+ description: route.description,
411
+ facets: route.facets,
412
+ lastModified: route.lastModified,
413
+ route: route.route,
414
+ title: route.title,
415
+ url: urlFor(route.route, data),
416
+ };
417
+ if (data.archivedVersions) {
418
+ listing.version = route.version;
419
+ }
420
+ return listing;
421
+ }),
291
422
  null,
292
423
  2
293
424
  )
@@ -295,7 +426,31 @@ export const buildServer = (
295
426
  }
296
427
 
297
428
  if (name === "get_navigation") {
298
- return text(JSON.stringify(data.navigation, null, 2));
429
+ // A version id selects the snapshot's tree; a locale selects its
430
+ // language (falling back through the default locale to any tree the
431
+ // snapshot has). Without a version, a locale selects the current docs'
432
+ // localized tree. An unknown id on a versioned site is an error — the
433
+ // current tree would silently masquerade as the requested snapshot.
434
+ const { locale, version: versionId } =
435
+ TOOL_INPUTS.get_navigation.parse(args);
436
+ const unknownVersion = unknownVersionError(versionId, data);
437
+ if (unknownVersion) {
438
+ return text(unknownVersion, true);
439
+ }
440
+ let { navigation } = data;
441
+ const byLocale = versionId
442
+ ? data.navigationByVersion?.[versionId]
443
+ : undefined;
444
+ if (byLocale) {
445
+ navigation =
446
+ (locale ? byLocale[locale] : undefined) ??
447
+ byLocale[data.defaultLocale ?? ""] ??
448
+ Object.values(byLocale)[0] ??
449
+ navigation;
450
+ } else if (locale && data.navigationByLocale?.[locale]) {
451
+ navigation = data.navigationByLocale[locale];
452
+ }
453
+ return text(JSON.stringify(navigation, null, 2));
299
454
  }
300
455
 
301
456
  return text(`Unknown tool: ${name}`, true);
@@ -19,7 +19,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
19
19
  {
20
20
  annotations: READ_ONLY,
21
21
  description:
22
- 'Full-text search across the documentation. Returns matching pages with their title, route, content type, and a short excerpt; pass `contentTypes` to search only pages of certain types (e.g. `rfc`, `changelog`), and `filters` to require facet values the site declares per type (e.g. `{"status": "enforced"}`). Use this first to discover relevant pages, then `get_page` to read one in full.',
22
+ 'Full-text search across the documentation. Returns matching pages with their title, route, content type, and a short excerpt; pass `contentTypes` to search only pages of certain types (e.g. `rfc`, `changelog`), `filters` to require facet values the site declares per type (e.g. `{"status": "enforced"}`), and `locale` to search one language. On a versioned site results default to the current docs — pass `version` to search an archived version (e.g. `"v1.0"`) or `"all"` for every version. Use this first to discover relevant pages, then `get_page` to read one in full.',
23
23
  name: "search_docs",
24
24
  title: "Search documentation",
25
25
  },
@@ -33,14 +33,14 @@ export const MCP_TOOLS: McpToolMeta[] = [
33
33
  {
34
34
  annotations: READ_ONLY,
35
35
  description:
36
- "List every documentation page with its route, title, description, content type, and any declared facet values; pass `contentTypes` and/or `filters` to narrow the list. Useful for enumerating the docs, discovering the types and facets in use, or finding a page when search is too narrow.",
36
+ 'List every documentation page with its route, title, description, content type, and any declared facet values; pass `contentTypes`, `filters`, and/or `locale` to narrow the list. On a versioned site the list defaults to the current docs — pass `version` for an archived version or `"all"`. Useful for enumerating the docs, discovering the types and facets in use, or finding a page when search is too narrow.',
37
37
  name: "list_pages",
38
38
  title: "List pages",
39
39
  },
40
40
  {
41
41
  annotations: READ_ONLY,
42
42
  description:
43
- "Return the documentation navigation tree (header tabs and the sidebar hierarchy), reflecting how the docs are organized for readers.",
43
+ "Return the documentation navigation tree (header tabs and the sidebar hierarchy), reflecting how the docs are organized for readers. Pass `locale` for a language's tree and, on a versioned site, `version` for an archived snapshot's tree.",
44
44
  name: "get_navigation",
45
45
  title: "Get navigation",
46
46
  },
package/src/ai/skills.ts CHANGED
@@ -89,23 +89,46 @@ const collectEntries = async (
89
89
  return entries;
90
90
  };
91
91
 
92
+ /** The two SKILL.md frontmatter fields the discovery index publishes. */
93
+ interface SkillMeta {
94
+ description: string;
95
+ name: string;
96
+ }
97
+
98
+ interface SkillMetaResult {
99
+ meta: SkillMeta | null;
100
+ warning?: string;
101
+ }
102
+
103
+ /**
104
+ * What js-yaml can put in a SKILL.md frontmatter field. Rich scalars (Dates)
105
+ * ride along as the object arm; only strings are accepted below anyway.
106
+ */
107
+ type FrontmatterField =
108
+ | string
109
+ | number
110
+ | boolean
111
+ | null
112
+ | undefined
113
+ | FrontmatterField[]
114
+ | { [key: string]: FrontmatterField };
115
+
116
+ const isString = (value: FrontmatterField): value is string =>
117
+ typeof value === "string";
118
+
92
119
  /** Frontmatter of a SKILL.md, or null with a warning when unusable. */
93
- const skillMeta = (
94
- raw: string,
95
- dirName: string
96
- ): { meta: { description: string; name: string } | null; warning?: string } => {
97
- let data: Record<string, unknown>;
120
+ const skillMeta = (raw: string, dirName: string): SkillMetaResult => {
121
+ let data: { description?: FrontmatterField; name?: FrontmatterField };
98
122
  try {
99
- ({ data } = matter(raw) as unknown as { data: Record<string, unknown> });
123
+ ({ data } = matter(raw));
100
124
  } catch {
101
125
  return {
102
126
  meta: null,
103
127
  warning: `Skill "${dirName}" has unparsable SKILL.md frontmatter; skipped.`,
104
128
  };
105
129
  }
106
- const name = typeof data.name === "string" ? data.name : "";
107
- const description =
108
- typeof data.description === "string" ? data.description : "";
130
+ const name = isString(data.name) ? data.name : "";
131
+ const description = isString(data.description) ? data.description : "";
109
132
  if (!(name && description)) {
110
133
  return {
111
134
  meta: null,