starlight-mcp 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bkspace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # starlight-mcp
2
+
3
+ Give your [Astro](https://astro.build) or [Starlight](https://starlight.astro.build) docs site an [MCP](https://modelcontextprotocol.io) server.
4
+
5
+ Agents connect to `https://your-site.com/mcp` and get three tools — `search_docs`, `get_doc`, `list_docs` — answered straight from your docs content collection. No database, no embeddings, no separate service: the corpus is a JSON file emitted at build time, and the server is a stateless JSON-RPC handler small enough to read in one sitting.
6
+
7
+ ```bash
8
+ npm install starlight-mcp
9
+ ```
10
+
11
+ **Live demo**: the discountkit docs serve theirs at `https://next.discountkit.app/mcp` —
12
+
13
+ ```bash
14
+ claude mcp add --transport http discountkit-docs https://next.discountkit.app/mcp
15
+ ```
16
+
17
+ ## How it works
18
+
19
+ 1. **Build time** — the Astro integration injects two prerendered routes into your site:
20
+ - `/mcp/docs-index.json` — every page of your docs collection (title, description, URL, cleaned markdown body)
21
+ - `/mcp-schema.json` — a machine-readable tool catalog agents can discover before connecting (link it from your `llms.txt`)
22
+ 2. **Runtime** — a dependency-free handler speaks stateless [streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http): plain JSON-RPC over POST. It loads the corpus once per isolate and answers `initialize`, `tools/list` and `tools/call`. On SSR sites the integration injects this for you; on static sites you add one tiny shim on your host (Cloudflare recipe below).
23
+
24
+ Because the transport is stateless HTTP, it works from Claude Code, Cursor, VS Code, Windsurf and anything else that speaks MCP — no SSE, no sessions, no sticky routing.
25
+
26
+ ## Quickstart
27
+
28
+ ### 1. Add the integration
29
+
30
+ ```js
31
+ // astro.config.mjs
32
+ import { defineConfig } from "astro/config";
33
+ import starlight from "@astrojs/starlight";
34
+ import starlightMcp from "starlight-mcp";
35
+
36
+ export default defineConfig({
37
+ site: "https://your-site.com", // required — URLs in results are absolute
38
+ integrations: [
39
+ starlight({ title: "My Docs" }),
40
+ starlightMcp({
41
+ serverName: "my-docs",
42
+ siteLabel: "My Product",
43
+ docsRedirect: "/docs/ai-tools/", // where humans land if they open /mcp in a browser
44
+ }),
45
+ ],
46
+ });
47
+ ```
48
+
49
+ That's the whole setup for **SSR sites** (`output: "server"`): the live endpoint is injected at `/mcp`.
50
+
51
+ For **static sites** the build emits the corpus and schema, and you serve the endpoint with a few lines of host glue:
52
+
53
+ ### 2. Static sites on Cloudflare Workers
54
+
55
+ ```ts
56
+ // worker/index.ts
57
+ import { createStaticMcpWorker } from "starlight-mcp/cloudflare";
58
+
59
+ export default createStaticMcpWorker({
60
+ serverName: "my-docs",
61
+ siteLabel: "My Product",
62
+ docsRedirect: "/docs/ai-tools/",
63
+ });
64
+ ```
65
+
66
+ ```jsonc
67
+ // wrangler.jsonc
68
+ {
69
+ "name": "my-docs-site",
70
+ "main": "worker/index.ts",
71
+ "compatibility_date": "2025-06-01",
72
+ "assets": {
73
+ "directory": "./dist",
74
+ "binding": "ASSETS",
75
+ "run_worker_first": ["/mcp"] // script wakes up ONLY for /mcp
76
+ }
77
+ }
78
+ ```
79
+
80
+ Every other request is served from the static assets layer without invoking the Worker — you pay for script execution only on MCP traffic.
81
+
82
+ ### 3. Any other host
83
+
84
+ `starlight-mcp/handler` is a pure `(Request) => Promise<Response>` with zero dependencies — wire it into whatever speaks fetch:
85
+
86
+ ```ts
87
+ import { createMcpHandler } from "starlight-mcp/handler";
88
+
89
+ const handler = createMcpHandler({
90
+ serverInfo: { name: "my-docs", version: "1.0.0" },
91
+ siteLabel: "My Product",
92
+ loadIndex: async () => {
93
+ // return DocEntry[] from wherever your build put docs-index.json
94
+ const res = await fetch("https://your-site.com/mcp/docs-index.json");
95
+ return res.json();
96
+ },
97
+ });
98
+ ```
99
+
100
+ ## Connecting clients
101
+
102
+ ```bash
103
+ # Claude Code
104
+ claude mcp add --transport http my-docs https://your-site.com/mcp
105
+ ```
106
+
107
+ ```jsonc
108
+ // Cursor — .cursor/mcp.json
109
+ {
110
+ "mcpServers": {
111
+ "my-docs": { "url": "https://your-site.com/mcp" }
112
+ }
113
+ }
114
+ ```
115
+
116
+ Clients that only speak stdio can bridge with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
117
+
118
+ ```jsonc
119
+ {
120
+ "mcpServers": {
121
+ "my-docs": {
122
+ "command": "npx",
123
+ "args": ["mcp-remote", "https://your-site.com/mcp"]
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ ## Tools
130
+
131
+ | Tool | Arguments | Returns |
132
+ | --- | --- | --- |
133
+ | `search_docs` | `query` | Top 5 pages by weighted term match (title ×6, description ×3, body occurrences), each with URL, description and a snippet around the first hit |
134
+ | `get_doc` | `path` (doc id or URL) | One full page as markdown with its source URL |
135
+ | `list_docs` | — | Every page with title, description and URL |
136
+
137
+ ## Options
138
+
139
+ All options are optional.
140
+
141
+ | Option | Default | What it does |
142
+ | --- | --- | --- |
143
+ | `serverName` | `<site-host>-docs` | MCP server name reported to clients |
144
+ | `serverVersion` | `"0.1.0"` | Version reported to clients |
145
+ | `path` | `"/mcp"` | Endpoint path; corpus lands at `${path}/docs-index.json`, catalog at `${path}-schema.json` |
146
+ | `collection` | `"docs"` | Which content collection to index (Starlight's is `docs`) |
147
+ | `siteLabel` | site host | Human-readable product name used in tool descriptions and `initialize` instructions |
148
+ | `instructions` | generated | The `instructions` string agents see on connect |
149
+ | `include` | all | Only index docs whose id starts with one of these prefixes |
150
+ | `exclude` | none | Drop docs whose id starts with one of these prefixes |
151
+ | `docsRedirect` | — | 302 humans who open the endpoint in a browser to this page (otherwise: a polite 405 JSON explainer) |
152
+ | `toolDescriptions` | generated | Replace the description of any tool, e.g. `{ search_docs: "Search the Acme docs (billing, webhooks, SDKs)…" }` — worth hand-tuning so agents know when to reach for it |
153
+
154
+ MDX noise (import statements, standalone `<Component>` tag lines) is stripped from indexed bodies automatically.
155
+
156
+ ## Make it discoverable
157
+
158
+ Two cheap tricks that make agents actually find the server:
159
+
160
+ - **llms.txt** — lead with the MCP endpoint before your page list, e.g.
161
+ ```
162
+ ## MCP server
163
+ Connect for structured search instead of scraping:
164
+ claude mcp add --transport http my-docs https://your-site.com/mcp
165
+ Tool catalog: https://your-site.com/mcp-schema.json
166
+ ```
167
+ - **A docs page for humans** — an "AI tools" page with per-editor install snippets, and point `docsRedirect` at it so people who open `/mcp` in a browser land somewhere useful.
168
+
169
+ ## Notes
170
+
171
+ - **Ships TypeScript source.** The package is consumed through bundlers — Astro/Vite, wrangler's esbuild, Bun, tsx — which all eat `.ts` natively. Plain `node` without a bundler needs a transpile step (compiled dist is on the roadmap).
172
+ - **Protocol** — implements stateless streamable HTTP for protocol versions `2025-06-18` and `2025-03-26`: `initialize`, `ping`, `notifications/*` (202), `tools/list`, `tools/call`; CORS is open so browser-based clients work.
173
+ - **State** — none. No sessions, no KV, no cookies. The corpus is cached per isolate/process and refreshes on deploy.
174
+
175
+ ## Roadmap
176
+
177
+ - Compiled `dist/` + `.d.ts` for bundler-free Node consumers
178
+ - Optional MiniSearch-backed index for large doc sets
179
+ - `extraTools` escape hatch for site-specific tools
180
+ - Custom `clean` function for exotic MDX
181
+ - Conformance tests against the official MCP SDK client
182
+ - `llms.txt` / `llms-full.txt` generation from the same corpus
183
+
184
+ ## Prior art
185
+
186
+ - [Varity's docs](https://github.com/varity-labs/varity-docs) — the `/mcp-schema.json` + llms.txt recruiting pattern
187
+ - [mcpdoc](https://github.com/langchain-ai/mcpdoc) — llms.txt-driven docs MCP as a local stdio server
188
+ - Built for (and proven on) the [discountkit](https://next.discountkit.app) docs
189
+
190
+ ## License
191
+
192
+ MIT © bkspace
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "starlight-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Give your Astro or Starlight docs site an MCP server — search_docs, get_doc and list_docs over stateless streamable HTTP. Astro integration, pure fetch handler, and Cloudflare static-assets glue included.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "bkspace",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/bkspace/starlight-mcp.git"
11
+ },
12
+ "homepage": "https://github.com/bkspace/starlight-mcp#readme",
13
+ "bugs": "https://github.com/bkspace/starlight-mcp/issues",
14
+ "keywords": [
15
+ "astro",
16
+ "astro-integration",
17
+ "withastro",
18
+ "starlight",
19
+ "starlight-plugin",
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "documentation",
23
+ "llm",
24
+ "cloudflare-workers"
25
+ ],
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./handler": "./src/handler.ts",
29
+ "./cloudflare": "./src/cloudflare.ts",
30
+ "./routes/docs-index": "./src/routes/docs-index.ts",
31
+ "./routes/schema": "./src/routes/schema.ts",
32
+ "./routes/endpoint": "./src/routes/endpoint.ts"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "sideEffects": false,
40
+ "scripts": {
41
+ "test": "vitest run",
42
+ "check": "tsc --noEmit"
43
+ },
44
+ "peerDependencies": {
45
+ "astro": ">=4.14.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "astro": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "astro": "^5.10.0",
54
+ "typescript": "^5.5.0",
55
+ "vitest": "^3.0.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.17.0"
59
+ }
60
+ }
package/src/clean.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Strip the MDX noise agents shouldn't see: import statements and standalone
3
+ * JSX component tag lines (`<Tabs>`, `</CardGrid>`, …). Markdown prose,
4
+ * headings, code fences and inline HTML all survive.
5
+ */
6
+ export function cleanMdxBody(body: string): string {
7
+ return body
8
+ .split("\n")
9
+ .filter((line) => !/^import\s.+from\s/.test(line.trim()))
10
+ .filter((line) => !/^<\/?[A-Z][\w]*(\s[^>]*)?>?\s*$/.test(line.trim()))
11
+ .join("\n")
12
+ .replace(/\n{3,}/g, "\n\n")
13
+ .trim();
14
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Glue for static sites on Cloudflare Workers with static assets: the
3
+ * whole site stays on the assets layer, the script wakes up only for the
4
+ * MCP endpoint and reads the build-time corpus back through the ASSETS
5
+ * binding. Pair with `run_worker_first: ["/mcp"]` in wrangler.jsonc.
6
+ */
7
+ import { createMcpHandler, type DocEntry } from "./handler";
8
+ import type { ToolDescriptions } from "./tools";
9
+
10
+ export interface StaticMcpWorkerOptions {
11
+ /** MCP server name reported to clients. Default: `"docs"`. */
12
+ serverName?: string;
13
+ /** Version string reported to clients. Default: `"0.1.0"`. */
14
+ serverVersion?: string;
15
+ /** Human-readable site name used inside tool descriptions. */
16
+ siteLabel?: string;
17
+ /** `instructions` string returned from `initialize`. */
18
+ instructions?: string;
19
+ /** Where to 302 humans who open the endpoint in a browser. */
20
+ docsRedirect?: string;
21
+ /** Replace the generated description of any tool. */
22
+ toolDescriptions?: ToolDescriptions;
23
+ /** Endpoint path — must match the integration's `path`. Default: `"/mcp"`. */
24
+ path?: string;
25
+ }
26
+
27
+ interface AssetsBinding {
28
+ fetch(input: URL | Request | string, init?: RequestInit): Promise<Response>;
29
+ }
30
+
31
+ export interface McpWorkerEnv {
32
+ ASSETS: AssetsBinding;
33
+ }
34
+
35
+ /**
36
+ * Returns a complete Worker export: `/mcp` (or `options.path`) is served by
37
+ * the MCP handler, everything else falls through to static assets.
38
+ *
39
+ * ```ts
40
+ * import { createStaticMcpWorker } from "starlight-mcp/cloudflare";
41
+ * export default createStaticMcpWorker({ serverName: "my-docs" });
42
+ * ```
43
+ */
44
+ export function createStaticMcpWorker(options: StaticMcpWorkerOptions = {}) {
45
+ const path = options.path ?? "/mcp";
46
+ let corpus: DocEntry[] | null = null; // per-isolate cache
47
+
48
+ return {
49
+ async fetch(request: Request, env: McpWorkerEnv): Promise<Response> {
50
+ const { pathname, origin } = new URL(request.url);
51
+ if (pathname !== path && pathname !== `${path}/`)
52
+ return env.ASSETS.fetch(request);
53
+
54
+ const handler = createMcpHandler({
55
+ serverInfo: {
56
+ name: options.serverName ?? "docs",
57
+ version: options.serverVersion ?? "0.1.0",
58
+ },
59
+ siteLabel: options.siteLabel,
60
+ instructions: options.instructions,
61
+ docsRedirect: options.docsRedirect,
62
+ toolDescriptions: options.toolDescriptions,
63
+ loadIndex: async () => {
64
+ if (corpus) return corpus;
65
+ const res = await env.ASSETS.fetch(
66
+ new URL(`${path}/docs-index.json`, origin),
67
+ );
68
+ if (!res.ok)
69
+ throw new Error(
70
+ `${path}/docs-index.json missing (HTTP ${res.status}) — is the starlight-mcp integration in your astro config?`,
71
+ );
72
+ corpus = (await res.json()) as DocEntry[];
73
+ return corpus;
74
+ },
75
+ });
76
+ return handler(request);
77
+ },
78
+ };
79
+ }
package/src/config.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Options and normalization. The resolved config must stay JSON-serializable:
3
+ * it is inlined into a virtual module (`virtual:starlight-mcp/config`) and
4
+ * read back by the injected routes at build time.
5
+ */
6
+ import type { ToolDescriptions } from "./tools";
7
+
8
+ export interface StarlightMcpOptions {
9
+ /** MCP server name reported to clients. Default: derived from your site URL (`<host>-docs`). */
10
+ serverName?: string;
11
+ /** Version string reported to clients. Default: `"0.1.0"`. */
12
+ serverVersion?: string;
13
+ /**
14
+ * URL path of the MCP endpoint. The corpus is emitted at
15
+ * `${path}/docs-index.json` and the tool catalog at `${path}-schema.json`.
16
+ * Default: `"/mcp"`.
17
+ */
18
+ path?: string;
19
+ /** Content collection to index. Default: `"docs"` (Starlight's collection). */
20
+ collection?: string;
21
+ /** Human-readable site name used inside tool descriptions. Default: your site host, else `"this site"`. */
22
+ siteLabel?: string;
23
+ /** `instructions` string returned from `initialize`, shown to connecting agents. */
24
+ instructions?: string;
25
+ /** Only index docs whose id starts with one of these prefixes. */
26
+ include?: string[];
27
+ /** Drop docs whose id starts with one of these prefixes. Applied after `include`. */
28
+ exclude?: string[];
29
+ /**
30
+ * Where to 302 humans who open the endpoint in a browser
31
+ * (e.g. `"/docs/ai-tools/"`). Default: a plain 405 JSON explainer.
32
+ */
33
+ docsRedirect?: string;
34
+ /**
35
+ * Replace the generated description of any tool — worth hand-tuning:
36
+ * mention your product's actual topics so agents know when to search.
37
+ */
38
+ toolDescriptions?: ToolDescriptions;
39
+ }
40
+
41
+ export interface ResolvedMcpConfig {
42
+ serverName: string;
43
+ serverVersion: string;
44
+ path: string;
45
+ collection: string;
46
+ siteLabel: string;
47
+ instructions?: string;
48
+ include?: string[];
49
+ exclude?: string[];
50
+ docsRedirect?: string;
51
+ toolDescriptions?: ToolDescriptions;
52
+ }
53
+
54
+ export function normalizeOptions(
55
+ options: StarlightMcpOptions = {},
56
+ site?: string,
57
+ ): ResolvedMcpConfig {
58
+ const host = site ? new URL(site).hostname : undefined;
59
+ let path = options.path ?? "/mcp";
60
+ path = path.replace(/\/+$/, "");
61
+ if (!path.startsWith("/")) path = `/${path}`;
62
+ return {
63
+ serverName:
64
+ options.serverName ??
65
+ (host ? `${host.replace(/[^a-zA-Z0-9]+/g, "-")}-docs` : "docs"),
66
+ serverVersion: options.serverVersion ?? "0.1.0",
67
+ path,
68
+ collection: options.collection ?? "docs",
69
+ siteLabel: options.siteLabel ?? host ?? "this site",
70
+ instructions: options.instructions,
71
+ include: options.include,
72
+ exclude: options.exclude,
73
+ docsRedirect: options.docsRedirect,
74
+ toolDescriptions: options.toolDescriptions,
75
+ };
76
+ }
package/src/corpus.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Build the docs corpus from an Astro content collection. Only route
3
+ * modules may import this — `astro:content` exists solely inside an
4
+ * Astro build. The pure handler never touches it.
5
+ */
6
+ import { getCollection } from "astro:content";
7
+ import { cleanMdxBody } from "./clean";
8
+ import type { ResolvedMcpConfig } from "./config";
9
+ import type { DocEntry } from "./handler";
10
+
11
+ export async function buildIndex(
12
+ config: ResolvedMcpConfig,
13
+ siteHref?: string,
14
+ ): Promise<DocEntry[]> {
15
+ const base = siteHref?.replace(/\/$/, "") ?? "";
16
+ const docs = await getCollection(config.collection);
17
+ const keep = (id: string) =>
18
+ (!config.include || config.include.some((p) => id.startsWith(p))) &&
19
+ !(config.exclude ?? []).some((p) => id.startsWith(p));
20
+ return docs
21
+ .filter((d) => keep(d.id))
22
+ .sort((a, b) => a.id.localeCompare(b.id))
23
+ .map((d) => ({
24
+ path: d.id,
25
+ title: (d.data.title as string) ?? d.id,
26
+ description: (d.data.description as string) ?? "",
27
+ url: `${base}/${d.id}/`,
28
+ body: cleanMdxBody(d.body ?? ""),
29
+ }));
30
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Ambient shims for this package's own typecheck only. Consumers never see
3
+ * these: inside an Astro project the real `astro:content` types and the
4
+ * injected virtual module take over.
5
+ */
6
+ declare module "astro:content" {
7
+ export function getCollection(collection: string): Promise<
8
+ Array<{
9
+ id: string;
10
+ body?: string;
11
+ data: Record<string, unknown>;
12
+ }>
13
+ >;
14
+ }
15
+
16
+ declare module "virtual:starlight-mcp/config" {
17
+ const config: import("./config").ResolvedMcpConfig;
18
+ export default config;
19
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The MCP server itself: a pure `(Request) => Promise<Response>` speaking
3
+ * stateless streamable HTTP — plain JSON-RPC over POST. No SDK, no
4
+ * sessions, no state, no dependencies. Runs on any WinterCG runtime
5
+ * (Cloudflare Workers, Deno, Bun, Node 18+, Vercel Edge, …).
6
+ */
7
+ import { makeTools, type McpTool, type ToolDescriptions } from "./tools";
8
+
9
+ export interface DocEntry {
10
+ /** Stable id, e.g. `docs/components/volume-picker` — what `get_doc` looks up. */
11
+ path: string;
12
+ title: string;
13
+ description: string;
14
+ /** Absolute URL of the page. */
15
+ url: string;
16
+ /** Cleaned markdown body. */
17
+ body: string;
18
+ }
19
+
20
+ export interface McpHandlerOptions {
21
+ serverInfo: { name: string; version: string };
22
+ /** Human-readable site name used inside tool descriptions. Default: `serverInfo.name`. */
23
+ siteLabel?: string;
24
+ /** `instructions` string returned from `initialize`. */
25
+ instructions?: string;
26
+ /** Where to 302 humans who open the endpoint in a browser. Default: 405 JSON explainer. */
27
+ docsRedirect?: string;
28
+ /** Replace the generated description of any tool. */
29
+ toolDescriptions?: ToolDescriptions;
30
+ /** Supply the corpus. Called on every tools/call — memoize inside if loading is expensive. */
31
+ loadIndex: () => Promise<DocEntry[]>;
32
+ }
33
+
34
+ export type McpRequestHandler = (request: Request) => Promise<Response>;
35
+
36
+ const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"];
37
+
38
+ const CORS = {
39
+ "Access-Control-Allow-Origin": "*",
40
+ "Access-Control-Allow-Methods": "POST, GET, OPTIONS, DELETE",
41
+ "Access-Control-Allow-Headers":
42
+ "content-type, mcp-session-id, mcp-protocol-version, authorization",
43
+ };
44
+
45
+ const json = (body: unknown, status = 200) =>
46
+ new Response(JSON.stringify(body), {
47
+ status,
48
+ headers: { "Content-Type": "application/json", ...CORS },
49
+ });
50
+
51
+ const rpcResult = (id: unknown, result: unknown) =>
52
+ json({ jsonrpc: "2.0", id, result });
53
+ const rpcError = (id: unknown, code: number, message: string) =>
54
+ json({ jsonrpc: "2.0", id, error: { code, message } });
55
+
56
+ function snippet(body: string, query: string, radius = 180): string {
57
+ const i = body.toLowerCase().indexOf(query.toLowerCase());
58
+ if (i === -1) return body.slice(0, radius * 2).trim() + "…";
59
+ const start = Math.max(0, i - radius);
60
+ return (start > 0 ? "…" : "") + body.slice(start, i + radius).trim() + "…";
61
+ }
62
+
63
+ function searchDocs(index: DocEntry[], query: string): string {
64
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
65
+ const scored = index
66
+ .map((d) => {
67
+ const title = d.title.toLowerCase();
68
+ const desc = d.description.toLowerCase();
69
+ const body = d.body.toLowerCase();
70
+ let score = 0;
71
+ for (const t of terms) {
72
+ if (title.includes(t)) score += 6;
73
+ if (desc.includes(t)) score += 3;
74
+ const matches = body.split(t).length - 1;
75
+ score += Math.min(matches, 10);
76
+ }
77
+ return { d, score };
78
+ })
79
+ .filter((x) => x.score > 0)
80
+ .sort((a, b) => b.score - a.score)
81
+ .slice(0, 5);
82
+ if (!scored.length) return `No documentation pages matched "${query}".`;
83
+ return scored
84
+ .map(
85
+ ({ d }) =>
86
+ `## ${d.title}\n${d.url}\n${d.description}\n\n> ${snippet(d.body, terms[0] ?? query)}`,
87
+ )
88
+ .join("\n\n---\n\n");
89
+ }
90
+
91
+ function getDoc(index: DocEntry[], path: string): string {
92
+ const clean = path.replace(/^https?:\/\/[^/]+/, "").replace(/^\/|\/$/g, "");
93
+ const doc =
94
+ index.find((d) => d.path === clean) ??
95
+ index.find((d) => d.url.endsWith(`/${clean}/`)) ??
96
+ index.find((d) => d.path.endsWith(clean));
97
+ if (!doc)
98
+ return `No documentation page found for "${path}". Use list_docs or search_docs to find valid paths.`;
99
+ return `# ${doc.title}\n\nSource: ${doc.url}\n\n${doc.body}`;
100
+ }
101
+
102
+ function listDocs(index: DocEntry[]): string {
103
+ return index
104
+ .map(
105
+ (d) =>
106
+ `- ${d.title} — ${d.url}${d.description ? `\n ${d.description}` : ""}`,
107
+ )
108
+ .join("\n");
109
+ }
110
+
111
+ export function createMcpHandler(options: McpHandlerOptions): McpRequestHandler {
112
+ const { serverInfo, loadIndex } = options;
113
+ const siteLabel = options.siteLabel ?? serverInfo.name;
114
+ const tools: McpTool[] = makeTools(siteLabel, options.toolDescriptions);
115
+ const instructions =
116
+ options.instructions ??
117
+ `Documentation server for ${siteLabel}. Use search_docs to find pages, get_doc to read one in full.`;
118
+
119
+ return async function handleMcp(request: Request): Promise<Response> {
120
+ if (request.method === "OPTIONS")
121
+ return new Response(null, { status: 204, headers: CORS });
122
+ if (request.method !== "POST") {
123
+ // a human in a browser: send them to the setup page instead of a 405
124
+ if (
125
+ options.docsRedirect &&
126
+ request.method === "GET" &&
127
+ (request.headers.get("accept") ?? "").includes("text/html")
128
+ ) {
129
+ return Response.redirect(
130
+ new URL(options.docsRedirect, request.url),
131
+ 302,
132
+ );
133
+ }
134
+ return json(
135
+ { error: "This MCP server is stateless; POST JSON-RPC to this endpoint." },
136
+ 405,
137
+ );
138
+ }
139
+
140
+ let msg: any;
141
+ try {
142
+ msg = await request.json();
143
+ } catch {
144
+ return rpcError(null, -32700, "Parse error");
145
+ }
146
+ const { id, method, params } = msg ?? {};
147
+
148
+ if (method === "initialize") {
149
+ const requested = params?.protocolVersion;
150
+ const version = PROTOCOL_VERSIONS.includes(requested)
151
+ ? requested
152
+ : PROTOCOL_VERSIONS[0];
153
+ return rpcResult(id, {
154
+ protocolVersion: version,
155
+ capabilities: { tools: {} },
156
+ serverInfo,
157
+ instructions,
158
+ });
159
+ }
160
+ if (String(method ?? "").startsWith("notifications/"))
161
+ return new Response(null, { status: 202, headers: CORS });
162
+ if (method === "ping") return rpcResult(id, {});
163
+ if (method === "tools/list") return rpcResult(id, { tools });
164
+
165
+ if (method === "tools/call") {
166
+ const name = params?.name;
167
+ const args = params?.arguments ?? {};
168
+ let index: DocEntry[];
169
+ try {
170
+ index = await loadIndex();
171
+ } catch (err) {
172
+ return rpcError(id, -32603, `Failed to load the docs corpus: ${err instanceof Error ? err.message : String(err)}`);
173
+ }
174
+ let text: string;
175
+ if (name === "search_docs") text = searchDocs(index, String(args.query ?? ""));
176
+ else if (name === "get_doc") text = getDoc(index, String(args.path ?? ""));
177
+ else if (name === "list_docs") text = listDocs(index);
178
+ else return rpcError(id, -32602, `Unknown tool: ${name}`);
179
+ return rpcResult(id, { content: [{ type: "text", text }] });
180
+ }
181
+
182
+ return rpcError(id, -32601, `Method not found: ${method}`);
183
+ };
184
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The Astro integration. Injects two build-time routes into any Astro or
3
+ * Starlight project:
4
+ *
5
+ * `${path}/docs-index.json` — the docs corpus the MCP server answers from
6
+ * `${path}-schema.json` — the machine-readable tool catalog
7
+ *
8
+ * On server output it also injects the live MCP endpoint at `${path}`.
9
+ * On static output, pair the emitted corpus with a tiny host shim — see
10
+ * `starlight-mcp/cloudflare` or `starlight-mcp/handler`.
11
+ */
12
+ import type { AstroIntegration } from "astro";
13
+ import {
14
+ normalizeOptions,
15
+ type ResolvedMcpConfig,
16
+ type StarlightMcpOptions,
17
+ } from "./config";
18
+
19
+ const VIRTUAL_ID = "virtual:starlight-mcp/config";
20
+ const RESOLVED_ID = "\0" + VIRTUAL_ID;
21
+
22
+ export default function starlightMcp(
23
+ options: StarlightMcpOptions = {},
24
+ ): AstroIntegration {
25
+ return {
26
+ name: "starlight-mcp",
27
+ hooks: {
28
+ "astro:config:setup": ({ config, injectRoute, updateConfig, logger }) => {
29
+ const resolved = normalizeOptions(options, config.site);
30
+
31
+ updateConfig({
32
+ vite: {
33
+ plugins: [
34
+ {
35
+ name: "starlight-mcp/config",
36
+ resolveId(id: string) {
37
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
38
+ },
39
+ load(id: string) {
40
+ if (id === RESOLVED_ID)
41
+ return `export default ${JSON.stringify(resolved)};`;
42
+ },
43
+ },
44
+ ],
45
+ },
46
+ });
47
+
48
+ injectRoute({
49
+ pattern: `${resolved.path}/docs-index.json`,
50
+ entrypoint: "starlight-mcp/routes/docs-index",
51
+ prerender: true,
52
+ });
53
+ injectRoute({
54
+ pattern: `${resolved.path}-schema.json`,
55
+ entrypoint: "starlight-mcp/routes/schema",
56
+ prerender: true,
57
+ });
58
+
59
+ if (config.output === "server") {
60
+ injectRoute({
61
+ pattern: resolved.path,
62
+ entrypoint: "starlight-mcp/routes/endpoint",
63
+ prerender: false,
64
+ });
65
+ logger.info(`live MCP endpoint at ${resolved.path}`);
66
+ } else {
67
+ logger.info(
68
+ `corpus at ${resolved.path}/docs-index.json — serve ${resolved.path} with starlight-mcp/cloudflare (or starlight-mcp/handler on any WinterCG host)`,
69
+ );
70
+ }
71
+ },
72
+ },
73
+ };
74
+ }
75
+
76
+ export { starlightMcp };
77
+ export { normalizeOptions } from "./config";
78
+ export type { StarlightMcpOptions, ResolvedMcpConfig } from "./config";
79
+ export type { DocEntry, McpHandlerOptions, McpRequestHandler } from "./handler";
80
+ export type { McpTool, ToolDescriptions } from "./tools";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `${path}/docs-index.json` — the documentation corpus, regenerated on
3
+ * every build from the content collection.
4
+ */
5
+ import type { APIRoute } from "astro";
6
+ import config from "virtual:starlight-mcp/config";
7
+ import { buildIndex } from "../corpus";
8
+
9
+ export const prerender = true;
10
+
11
+ export const GET: APIRoute = async ({ site }) => {
12
+ const index = await buildIndex(config, site?.href);
13
+ return new Response(JSON.stringify(index), {
14
+ headers: { "Content-Type": "application/json; charset=utf-8" },
15
+ });
16
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `${path}` — the live MCP endpoint, injected only on `output: "server"`.
3
+ * Static sites don't get this route; they serve the emitted corpus through
4
+ * a host shim instead (see starlight-mcp/cloudflare).
5
+ */
6
+ import type { APIRoute } from "astro";
7
+ import config from "virtual:starlight-mcp/config";
8
+ import { buildIndex } from "../corpus";
9
+ import { createMcpHandler, type McpRequestHandler } from "../handler";
10
+
11
+ export const prerender = false;
12
+
13
+ let handler: McpRequestHandler | null = null;
14
+ let corpus: ReturnType<typeof buildIndex> | null = null;
15
+
16
+ export const ALL: APIRoute = ({ request, site }) => {
17
+ handler ??= createMcpHandler({
18
+ serverInfo: { name: config.serverName, version: config.serverVersion },
19
+ siteLabel: config.siteLabel,
20
+ instructions: config.instructions,
21
+ docsRedirect: config.docsRedirect,
22
+ toolDescriptions: config.toolDescriptions,
23
+ loadIndex: () => (corpus ??= buildIndex(config, site?.href)),
24
+ });
25
+ return handler(request);
26
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `${path}-schema.json` — the MCP tool catalog as a machine-readable file
3
+ * (the Varity pattern), generated from the same definitions the server
4
+ * uses at runtime. Link it from llms.txt so agents can discover the
5
+ * endpoint without connecting first.
6
+ */
7
+ import type { APIRoute } from "astro";
8
+ import config from "virtual:starlight-mcp/config";
9
+ import { makeTools } from "../tools";
10
+
11
+ export const prerender = true;
12
+
13
+ export const GET: APIRoute = ({ site }) => {
14
+ const base = site?.href.replace(/\/$/, "") ?? "";
15
+ return new Response(
16
+ JSON.stringify(
17
+ {
18
+ server: { name: config.serverName, version: config.serverVersion },
19
+ endpoint: `${base}${config.path}`,
20
+ transport: "streamable-http",
21
+ tools: makeTools(config.siteLabel, config.toolDescriptions),
22
+ },
23
+ null,
24
+ 2,
25
+ ),
26
+ { headers: { "Content-Type": "application/json; charset=utf-8" } },
27
+ );
28
+ };
package/src/tools.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The MCP tool catalog — single source of truth, used by the runtime
3
+ * handler and by the published `${path}-schema.json` file.
4
+ */
5
+ export interface McpTool {
6
+ name: string;
7
+ description: string;
8
+ inputSchema: Record<string, unknown>;
9
+ }
10
+
11
+ export type ToolDescriptions = Partial<
12
+ Record<"search_docs" | "get_doc" | "list_docs", string>
13
+ >;
14
+
15
+ export function makeTools(
16
+ siteLabel: string,
17
+ overrides: ToolDescriptions = {},
18
+ ): McpTool[] {
19
+ return [
20
+ {
21
+ name: "search_docs",
22
+ description:
23
+ overrides.search_docs ??
24
+ `Search the ${siteLabel} documentation. Returns the best-matching pages with snippets and URLs.`,
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ query: {
29
+ type: "string",
30
+ description: "What to search for",
31
+ },
32
+ },
33
+ required: ["query"],
34
+ },
35
+ },
36
+ {
37
+ name: "get_doc",
38
+ description:
39
+ overrides.get_doc ??
40
+ "Fetch one documentation page as full markdown by its path (as returned by search_docs or list_docs).",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ path: { type: "string", description: "The doc path or URL" },
45
+ },
46
+ required: ["path"],
47
+ },
48
+ },
49
+ {
50
+ name: "list_docs",
51
+ description:
52
+ overrides.list_docs ??
53
+ "List every documentation page with its title, description and URL.",
54
+ inputSchema: { type: "object", properties: {} },
55
+ },
56
+ ];
57
+ }