@zvndev/darkmown 2.10.1 → 2.11.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.
@@ -0,0 +1,354 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The Darkmown MCP server's tools and resources.
3
+ //
4
+ // Nothing here re-implements the framework: every tool is a thin adapter over
5
+ // a surface that already exists and is already drift-guarded by the test
6
+ // suite — the directive catalog (src/catalog.js), the agent grammar slice
7
+ // (src/tools/grammar.js), the GBNF generator (src/grammar.js), the in-memory
8
+ // validator (src/tools/validate.js), and the error-code registry
9
+ // (src/errors.js). An MCP client and the `darkmown` CLI therefore can never
10
+ // disagree about what compiles or what a WD code means.
11
+ //
12
+ // The five tools:
13
+ //
14
+ // catalog the machine-readable directive catalog (JSON, llms, llms-full)
15
+ // grammar the GBNF grammar, or a category slice of the directive cheatsheet
16
+ // validate compile .wd source in memory, answering with WD codes and fixes
17
+ // error one WDxxx code's cause, fix, and example (or the valid list)
18
+ // doc fuzzy-find a section of README.md / AGENTS.md by topic
19
+ //
20
+ // The resources expose the shipped markdown docs at darkmown://docs/* URIs,
21
+ // read from the installed package files at request time.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ import fs from "node:fs";
25
+ import { directiveCatalog, llmsFullText, llmsText } from "./catalog.js";
26
+ import { errorCatalog, errorForCode } from "./errors.js";
27
+ import { generateGrammar } from "./grammar.js";
28
+ import { grammar } from "./tools/grammar.js";
29
+ import { validate } from "./tools/validate.js";
30
+
31
+ /** The installed Darkmown version, read from the shipped package.json. */
32
+ function packageVersion() {
33
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
34
+ return String(pkg.version || "0.0.0");
35
+ }
36
+
37
+ /** Read a file shipped in the package root (or under it), as UTF-8. */
38
+ function readPackageFile(/** @type {string} */ file) {
39
+ return fs.readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
40
+ }
41
+
42
+ // --- catalog ---------------------------------------------------------------
43
+
44
+ /** @type {string[]} The render modes, mirroring the CLI's catalog flags. */
45
+ const CATALOG_MODES = ["json", "llms", "llms-full"];
46
+
47
+ /** @type {import("./mcp.js").McpTool} */
48
+ const catalogTool = {
49
+ name: "catalog",
50
+ description:
51
+ "The Darkmown .wd directive catalog: every directive, @loop clause, action op, " +
52
+ 'format pipe, predicate operator, frontmatter key, and WDxxx error code. mode "json" ' +
53
+ '(default) is the full machine-readable catalog, "llms" the compact cheatsheet, ' +
54
+ '"llms-full" the complete reference.',
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ mode: {
59
+ type: "string",
60
+ enum: CATALOG_MODES,
61
+ description: 'Render mode: "json" (default), "llms", or "llms-full".'
62
+ }
63
+ }
64
+ },
65
+ run(args) {
66
+ const mode = args.mode ?? "json";
67
+ if (typeof mode !== "string" || !CATALOG_MODES.includes(mode)) {
68
+ return {
69
+ text: `unknown catalog mode ${JSON.stringify(mode)}. Valid modes: ${CATALOG_MODES.join(", ")}`,
70
+ isError: true
71
+ };
72
+ }
73
+ if (mode === "llms") return { text: llmsText() };
74
+ if (mode === "llms-full") return { text: llmsFullText() };
75
+ return { text: JSON.stringify(directiveCatalog(), null, 2) };
76
+ }
77
+ };
78
+
79
+ // --- grammar ---------------------------------------------------------------
80
+
81
+ /** @type {import("./mcp.js").McpTool} */
82
+ const grammarTool = {
83
+ name: "grammar",
84
+ description:
85
+ "The .wd directive syntax. Pass gbnf:true for the full GBNF directive-line grammar " +
86
+ '(for constrained decoding), or categories like ["state", "actions"] for just the ' +
87
+ "cheatsheet rows covering those directives. Categories: state, loops, actions, " +
88
+ "forms, fetch, structure, media. No arguments returns every category.",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ gbnf: {
93
+ type: "boolean",
94
+ description: "Return the full GBNF grammar instead of a cheatsheet slice."
95
+ },
96
+ categories: {
97
+ type: "array",
98
+ items: { type: "string" },
99
+ description:
100
+ "Directive categories to include: state, loops, actions, forms, fetch, structure, media."
101
+ }
102
+ }
103
+ },
104
+ run(args) {
105
+ if (args.gbnf === true) return { text: generateGrammar() };
106
+ const categories = Array.isArray(args.categories)
107
+ ? args.categories.filter((c) => typeof c === "string")
108
+ : undefined;
109
+ const result = grammar(categories);
110
+ return { text: result.text, data: result.data, ...(result.ok ? {} : { isError: true }) };
111
+ }
112
+ };
113
+
114
+ // --- validate ---------------------------------------------------------------
115
+
116
+ /** @type {import("./mcp.js").McpTool} */
117
+ const validateTool = {
118
+ name: "validate",
119
+ description:
120
+ "Compile .wd (or .md) source with the real Darkmown compiler, in memory. On success " +
121
+ "answers whether the page is static or reactive and lists any warnings; on failure " +
122
+ "answers with the stable WD error code, the file:line, a Use: hint, and a concrete " +
123
+ "example. Prove a page compiles before writing it.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ source: { type: "string", description: "The full .wd/.md page source." },
128
+ filename: {
129
+ type: "string",
130
+ description: 'Name the source is compiled under (default "index.wd").'
131
+ }
132
+ },
133
+ required: ["source"]
134
+ },
135
+ run(args) {
136
+ if (typeof args.source !== "string") {
137
+ return { text: 'validate needs a "source" string.', isError: true };
138
+ }
139
+ const filename =
140
+ typeof args.filename === "string" && args.filename.trim()
141
+ ? args.filename.replace(/^\/+/, "")
142
+ : "index.wd";
143
+ const key = filename.startsWith("site/") ? filename : `site/pages/${filename}`;
144
+ const result = validate({ [key]: args.source }, key);
145
+ return { text: result.text, data: { ok: result.ok, ...result.data } };
146
+ }
147
+ };
148
+
149
+ // --- error -----------------------------------------------------------------
150
+
151
+ /** @type {import("./mcp.js").McpTool} */
152
+ const errorTool = {
153
+ name: "error",
154
+ description:
155
+ "Look up one Darkmown compile-error code (e.g. WD201): its title, what causes it, " +
156
+ "how to fix it, and a concrete compilable example. An unknown code answers with " +
157
+ "the list of valid codes.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: {
161
+ code: { type: "string", description: 'The WDxxx code, e.g. "WD201".' }
162
+ },
163
+ required: ["code"]
164
+ },
165
+ run(args) {
166
+ const code = typeof args.code === "string" ? args.code.trim().toUpperCase() : "";
167
+ const entry = errorForCode(code);
168
+ if (!entry) {
169
+ const codes = errorCatalog().map((e) => e.code);
170
+ return {
171
+ text:
172
+ `unknown error code ${JSON.stringify(code || args.code)}. ` +
173
+ `Valid codes: ${codes.join(", ")}`,
174
+ data: { codes },
175
+ isError: true
176
+ };
177
+ }
178
+ const lines = [`${entry.code} ${entry.title}`, `cause: ${entry.cause}`, `fix: ${entry.fix}`];
179
+ if (entry.example) lines.push(`e.g. ${entry.example}`);
180
+ return { text: lines.join("\n"), data: entry };
181
+ }
182
+ };
183
+
184
+ // --- doc -------------------------------------------------------------------
185
+
186
+ /** The shipped long-form docs the `doc` tool searches, in search order. */
187
+ const DOC_FILES = ["README.md", "AGENTS.md"];
188
+
189
+ /**
190
+ * One searchable section of a shipped doc.
191
+ * @typedef {object} DocSection
192
+ * @property {string} topic The heading text, e.g. "Includes & sections".
193
+ * @property {string} file The doc the section lives in.
194
+ * @property {string} body The section's markdown, heading excluded.
195
+ */
196
+
197
+ /**
198
+ * Split a doc into sections on `#`/`##`/`###` headings, ignoring heading-shaped
199
+ * lines inside fenced code blocks (README's example robots.txt and sample pages
200
+ * carry lines that start with `#`).
201
+ * @returns {DocSection[]}
202
+ */
203
+ export function docSections() {
204
+ /** @type {{ topic: string, file: string, lines: string[] }[]} */
205
+ const raw = [];
206
+ for (const file of DOC_FILES) {
207
+ let fenced = false;
208
+ /** @type {{ topic: string, file: string, lines: string[] } | null} */
209
+ let current = null;
210
+ for (const line of readPackageFile(file).split("\n")) {
211
+ if (/^(```|~~~)/.test(line.trimStart())) fenced = !fenced;
212
+ const heading = !fenced && /^#{1,3} (.+)$/.exec(line);
213
+ if (heading) {
214
+ current = { topic: heading[1].trim(), file, lines: [] };
215
+ raw.push(current);
216
+ } else if (current) {
217
+ current.lines.push(line);
218
+ }
219
+ }
220
+ }
221
+ return raw.map(({ topic, file, lines }) => ({ topic, file, body: lines.join("\n").trim() }));
222
+ }
223
+
224
+ /**
225
+ * Score a section heading against a query, higher is better. Exact match wins,
226
+ * then containment either way, then shared-word count. Normalisation folds
227
+ * case and strips punctuation, so "raw html" matches
228
+ * "⚠️ Raw HTML is escaped by default (since 2.0.0)".
229
+ * @param {string} topic
230
+ * @param {string} query
231
+ * @returns {number}
232
+ */
233
+ function scoreSection(topic, query) {
234
+ const normalize = (/** @type {string} */ s) =>
235
+ s
236
+ .toLowerCase()
237
+ .replace(/[^a-z0-9 ]/g, " ")
238
+ .replace(/\s+/g, " ")
239
+ .trim();
240
+ const heading = normalize(topic);
241
+ const wanted = normalize(query);
242
+ if (!wanted) return 0;
243
+ if (heading === wanted) return 100;
244
+ if (heading.includes(wanted) || wanted.includes(heading)) return 60;
245
+ const words = new Set(heading.split(" "));
246
+ let overlap = 0;
247
+ for (const word of wanted.split(" ")) if (words.has(word)) overlap++;
248
+ return overlap;
249
+ }
250
+
251
+ /** @type {import("./mcp.js").McpTool} */
252
+ const docTool = {
253
+ name: "doc",
254
+ description:
255
+ "Fuzzy-find a section of the shipped Darkmown docs (README.md and AGENTS.md) by " +
256
+ 'topic, e.g. "includes", "forms", "dark mode", and return that section\'s markdown. ' +
257
+ "Call with no topic to list every available section.",
258
+ inputSchema: {
259
+ type: "object",
260
+ properties: {
261
+ topic: { type: "string", description: "What to look up, in a word or two." }
262
+ }
263
+ },
264
+ run(args) {
265
+ const topic = typeof args.topic === "string" ? args.topic.trim() : "";
266
+ const sections = docSections();
267
+ const list = sections.map((s) => `- ${s.topic} (${s.file})`).join("\n");
268
+ if (!topic) {
269
+ return {
270
+ text: `Available doc sections:\n${list}`,
271
+ data: { topics: sections.map((s) => ({ topic: s.topic, file: s.file })) }
272
+ };
273
+ }
274
+ let best = /** @type {DocSection | null} */ (null);
275
+ let bestScore = 0;
276
+ for (const section of sections) {
277
+ const score = scoreSection(section.topic, topic);
278
+ if (score > bestScore) {
279
+ best = section;
280
+ bestScore = score;
281
+ }
282
+ }
283
+ if (!best) {
284
+ return {
285
+ text: `no doc section matches ${JSON.stringify(topic)}. Available sections:\n${list}`,
286
+ isError: true
287
+ };
288
+ }
289
+ return {
290
+ text: `# ${best.topic} (${best.file})\n\n${best.body}`,
291
+ data: { topic: best.topic, file: best.file }
292
+ };
293
+ }
294
+ };
295
+
296
+ // --- resources ---------------------------------------------------------------
297
+
298
+ /**
299
+ * The shipped docs exposed as MCP resources, read from the installed package
300
+ * at request time so a long-running server sees the files the user actually
301
+ * has installed.
302
+ * @type {{ uri: string, name: string, file: string, description: string }[]}
303
+ */
304
+ const DOC_RESOURCES = [
305
+ {
306
+ uri: "darkmown://docs/readme",
307
+ name: "README.md",
308
+ file: "README.md",
309
+ description: "The Darkmown README: authoring model, directive reference, commands."
310
+ },
311
+ {
312
+ uri: "darkmown://docs/agents",
313
+ name: "AGENTS.md",
314
+ file: "AGENTS.md",
315
+ description: "The agent authoring guide: the complete .wd directive set and the hard rules."
316
+ },
317
+ {
318
+ uri: "darkmown://docs/changelog",
319
+ name: "CHANGELOG.md",
320
+ file: "CHANGELOG.md",
321
+ description: "Darkmown release history."
322
+ },
323
+ {
324
+ uri: "darkmown://docs/errors",
325
+ name: "docs/errors.md",
326
+ file: "docs/errors.md",
327
+ description: "Every stable WDxxx compile-error code with cause, fix, and example."
328
+ },
329
+ {
330
+ uri: "darkmown://docs/cli",
331
+ name: "docs/cli.md",
332
+ file: "docs/cli.md",
333
+ description: "The Darkmown CLI command reference."
334
+ }
335
+ ];
336
+
337
+ /**
338
+ * The server configuration: identity, the five tools, and the doc resources.
339
+ * Built fresh on each call so tests can mutate nothing shared.
340
+ * @returns {import("./mcp.js").McpConfig}
341
+ */
342
+ export function mcpConfig() {
343
+ return {
344
+ serverInfo: { name: "darkmown", version: packageVersion() },
345
+ tools: [catalogTool, grammarTool, validateTool, errorTool, docTool],
346
+ resources: DOC_RESOURCES.map(({ uri, name, file, description }) => ({
347
+ uri,
348
+ name,
349
+ description,
350
+ mimeType: "text/markdown",
351
+ read: () => readPackageFile(file)
352
+ }))
353
+ };
354
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,287 @@
1
+ // ---------------------------------------------------------------------------
2
+ // MCP (Model Context Protocol) server, stdio transport, hand-rolled.
3
+ //
4
+ // MCP over stdio is newline-delimited JSON-RPC 2.0: the client (Claude Code,
5
+ // Codex, Cursor, …) writes one JSON message per line on the server's stdin and
6
+ // reads one JSON response per line from its stdout. Everything else a server
7
+ // wants to say (startup banners, logs) goes to stderr — a single stray byte on
8
+ // stdout corrupts the frame stream, so `serveMcp` only ever writes responses
9
+ // there.
10
+ //
11
+ // This is a focused implementation of the subset a doc/validation server
12
+ // needs, protocol revision 2024-11-05:
13
+ //
14
+ // initialize handshake: protocolVersion, capabilities, serverInfo
15
+ // notifications/initialized client ack — no response (as for every notification)
16
+ // ping liveness — answers {}
17
+ // tools/list the five Darkmown tools, with inputSchema
18
+ // tools/call run one tool, answer MCP text content
19
+ // resources/list the darkmown://docs/* resources
20
+ // resources/read one resource's markdown text
21
+ //
22
+ // Deliberately NOT implemented (noted in the handshake's capabilities by their
23
+ // absence): prompts, logging, completion, sampling, subscriptions, and
24
+ // BATCHED requests — a JSON array on one line is answered with a single
25
+ // -32600 Invalid Request, which the spec permits a server to do. Tool/resource
26
+ // definitions are static for the life of the process, so the optional
27
+ // list-change notifications never fire.
28
+ //
29
+ // The module is dependency-free (Node built-ins only) and side-effect-free on
30
+ // import: `serveMcp` is called explicitly by the CLI's `mcp` command, and the
31
+ // dispatcher is a pure function tests drive in-process.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** The one MCP protocol revision this server speaks. */
35
+ export const PROTOCOL_VERSION = "2024-11-05";
36
+
37
+ // Standard JSON-RPC 2.0 error codes.
38
+ const PARSE_ERROR = -32700;
39
+ const INVALID_REQUEST = -32600;
40
+ const METHOD_NOT_FOUND = -32601;
41
+ const INVALID_PARAMS = -32602;
42
+
43
+ /**
44
+ * One tool the server exposes over `tools/list` / `tools/call`.
45
+ * @typedef {object} McpTool
46
+ * @property {string} name
47
+ * @property {string} description
48
+ * @property {object} inputSchema A JSON Schema object describing the arguments.
49
+ * @property {(args: Record<string, unknown>) => McpToolOutcome} run
50
+ */
51
+
52
+ /**
53
+ * What a tool run returns. `text` becomes the first MCP text content item; a
54
+ * defined `data` is appended as a second text item carrying pretty-printed
55
+ * JSON, so an agent gets both the human-readable answer and the structured
56
+ * one. `isError` marks the result as a tool-level failure (a bad argument),
57
+ * never a protocol error — those are JSON-RPC errors on the envelope.
58
+ * @typedef {object} McpToolOutcome
59
+ * @property {string} text
60
+ * @property {object} [data]
61
+ * @property {boolean} [isError]
62
+ */
63
+
64
+ /**
65
+ * One resource the server exposes over `resources/list` / `resources/read`.
66
+ * @typedef {object} McpResource
67
+ * @property {string} uri
68
+ * @property {string} name
69
+ * @property {string} description
70
+ * @property {string} mimeType
71
+ * @property {() => string} read
72
+ */
73
+
74
+ /**
75
+ * Everything the dispatcher needs to answer. Constructed by
76
+ * `mcpConfig()` in src/mcp-tools.js; injected here so this module stays a
77
+ * transport + envelope layer with no knowledge of Darkmown's docs.
78
+ * @typedef {object} McpConfig
79
+ * @property {{ name: string, version: string }} serverInfo
80
+ * @property {McpTool[]} tools
81
+ * @property {McpResource[]} resources
82
+ */
83
+
84
+ /**
85
+ * A JSON-RPC 2.0 message as it arrives on the wire. The server is liberal in
86
+ * what it accepts: `id` may be a string, a number, or absent (a notification).
87
+ * @typedef {{ jsonrpc?: string, id?: string | number | null, method?: unknown, params?: unknown }} McpMessage
88
+ */
89
+
90
+ /**
91
+ * Create the request dispatcher: one JSON-RPC message in, one response (or
92
+ * `null` for a notification, which must never be answered) out.
93
+ *
94
+ * @param {McpConfig} config
95
+ * @returns {(message: unknown) => object | null}
96
+ */
97
+ export function createDispatcher({ serverInfo, tools, resources }) {
98
+ const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
99
+ const resourcesByUri = new Map(resources.map((resource) => [resource.uri, resource]));
100
+
101
+ /**
102
+ * @param {string | number | null} id
103
+ * @param {unknown} value
104
+ */
105
+ const ok = (id, value) => ({ jsonrpc: "2.0", id, result: value });
106
+ /**
107
+ * @param {string | number | null} id
108
+ * @param {number} code
109
+ * @param {string} message
110
+ */
111
+ const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
112
+
113
+ /**
114
+ * `tools/call`: find the tool, run it, shape the MCP result. A tool that
115
+ * throws is a tool-level error result (the spec's `isError` channel), not a
116
+ * protocol error — the client asked a fair question and the tool failed.
117
+ * @param {string | number | null} id
118
+ * @param {unknown} params
119
+ */
120
+ function callTool(id, params) {
121
+ const args = /** @type {{ name?: unknown, arguments?: unknown }} */ (
122
+ params && typeof params === "object" ? params : {}
123
+ );
124
+ const tool = typeof args.name === "string" ? toolsByName.get(args.name) : undefined;
125
+ if (!tool) {
126
+ const known = tools.map((t) => t.name).join(", ");
127
+ return fail(id, INVALID_PARAMS, `Unknown tool: ${String(args.name)}. Available: ${known}`);
128
+ }
129
+ /** @type {McpToolOutcome} */
130
+ let outcome;
131
+ try {
132
+ outcome = tool.run(
133
+ /** @type {Record<string, unknown>} */ (
134
+ args.arguments && typeof args.arguments === "object" ? args.arguments : {}
135
+ )
136
+ );
137
+ } catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ return ok(id, {
140
+ content: [{ type: "text", text: `Tool "${tool.name}" failed: ${message}` }],
141
+ isError: true
142
+ });
143
+ }
144
+ const content = [{ type: "text", text: outcome.text }];
145
+ if (outcome.data !== undefined) {
146
+ content.push({ type: "text", text: JSON.stringify(outcome.data, null, 2) });
147
+ }
148
+ return ok(id, { content, ...(outcome.isError ? { isError: true } : {}) });
149
+ }
150
+
151
+ /**
152
+ * `resources/read`: one resource's full text. An unknown URI is an invalid
153
+ * params error naming what the server actually has.
154
+ * @param {string | number | null} id
155
+ * @param {unknown} params
156
+ */
157
+ function readResource(id, params) {
158
+ const args = /** @type {{ uri?: unknown }} */ (
159
+ params && typeof params === "object" ? params : {}
160
+ );
161
+ const resource = typeof args.uri === "string" ? resourcesByUri.get(args.uri) : undefined;
162
+ if (!resource) {
163
+ const known = resources.map((r) => r.uri).join(", ");
164
+ return fail(id, INVALID_PARAMS, `Unknown resource: ${String(args.uri)}. Available: ${known}`);
165
+ }
166
+ return ok(id, {
167
+ contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.read() }]
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Dispatch one decoded message. Returns the response to write back, or
173
+ * `null` when the message is a notification (answered with silence, per
174
+ * JSON-RPC) or otherwise needs no reply.
175
+ * @param {unknown} message
176
+ * @returns {object | null}
177
+ */
178
+ function handle(message) {
179
+ if (!message || typeof message !== "object") {
180
+ // A bare scalar, or a JSON array (a batch — not supported): one error.
181
+ return fail(null, INVALID_REQUEST, "Invalid Request: expected a JSON-RPC object");
182
+ }
183
+ const { id, method, params } = /** @type {McpMessage} */ (message);
184
+ if (typeof method !== "string") {
185
+ return fail(id ?? null, INVALID_REQUEST, "Invalid Request: missing method");
186
+ }
187
+ // No id means notification: `notifications/initialized` and anything else
188
+ // the client fires one-way. Notifications are never answered, even when
189
+ // the method is unknown.
190
+ if (id === undefined || id === null) return null;
191
+
192
+ switch (method) {
193
+ case "initialize":
194
+ return ok(id, {
195
+ protocolVersion: PROTOCOL_VERSION,
196
+ capabilities: {
197
+ tools: { listChanged: false },
198
+ resources: { listChanged: false }
199
+ },
200
+ serverInfo
201
+ });
202
+ case "ping":
203
+ return ok(id, {});
204
+ case "tools/list":
205
+ return ok(id, {
206
+ tools: tools.map(({ name, description, inputSchema }) => ({
207
+ name,
208
+ description,
209
+ inputSchema
210
+ }))
211
+ });
212
+ case "tools/call":
213
+ return callTool(id, params);
214
+ case "resources/list":
215
+ return ok(id, {
216
+ resources: resources.map(({ uri, name, description, mimeType }) => ({
217
+ uri,
218
+ name,
219
+ description,
220
+ mimeType
221
+ }))
222
+ });
223
+ case "resources/read":
224
+ return readResource(id, params);
225
+ default:
226
+ return fail(id, METHOD_NOT_FOUND, `Method not found: ${method}`);
227
+ }
228
+ }
229
+
230
+ return handle;
231
+ }
232
+
233
+ /**
234
+ * Run the stdio loop: read newline-delimited JSON-RPC from `input`, write one
235
+ * JSON response per line to `output`. Resolves when `input` ends, which is
236
+ * how an MCP host stops a stdio server.
237
+ *
238
+ * A line that is not valid JSON answers with a -32700 Parse error and the loop
239
+ * carries on — one garbage line from a misbehaving client must not kill the
240
+ * server. A trailing partial line at end-of-input is answered too: MCP clients
241
+ * always terminate frames, but a hand-rolled `echo '{…}' | darkmown mcp` smoke
242
+ * test may not.
243
+ *
244
+ * @param {{ input: NodeJS.ReadableStream, output: NodeJS.WritableStream,
245
+ * dispatcher: (message: unknown) => object | null }} io
246
+ * @returns {Promise<{ command: "mcp" }>}
247
+ */
248
+ export function serveMcp({ input, output, dispatcher }) {
249
+ const send = (/** @type {object} */ message) => {
250
+ output.write(`${JSON.stringify(message)}\n`);
251
+ };
252
+ const answer = (/** @type {string} */ line) => {
253
+ const trimmed = line.trim();
254
+ if (!trimmed) return;
255
+ /** @type {unknown} */
256
+ let message;
257
+ try {
258
+ message = JSON.parse(trimmed);
259
+ } catch {
260
+ send({
261
+ jsonrpc: "2.0",
262
+ id: null,
263
+ error: { code: PARSE_ERROR, message: "Parse error: line is not valid JSON" }
264
+ });
265
+ return;
266
+ }
267
+ const response = dispatcher(message);
268
+ if (response) send(response);
269
+ };
270
+
271
+ let buffer = "";
272
+ return new Promise((resolve) => {
273
+ input.on("data", (chunk) => {
274
+ buffer += chunk;
275
+ let index = buffer.indexOf("\n");
276
+ while (index !== -1) {
277
+ answer(buffer.slice(0, index));
278
+ buffer = buffer.slice(index + 1);
279
+ index = buffer.indexOf("\n");
280
+ }
281
+ });
282
+ input.on("end", () => {
283
+ if (buffer) answer(buffer);
284
+ resolve({ command: "mcp" });
285
+ });
286
+ });
287
+ }
package/src/statics.js CHANGED
@@ -292,6 +292,7 @@ const contentTypes = {
292
292
  ".js": "text/javascript; charset=utf-8",
293
293
  ".json": "application/json",
294
294
  ".map": "application/json",
295
+ ".md": "text/markdown; charset=utf-8",
295
296
  ".svg": "image/svg+xml",
296
297
  ".png": "image/png",
297
298
  ".jpg": "image/jpeg",
@@ -169,6 +169,11 @@ export type SiteIdentity = {
169
169
  * Most-recent-first RSS item cap, from `rss_limit:`.
170
170
  */
171
171
  rssLimit: number;
172
+ /**
173
+ * Publish every page's markdown source at
174
+ * `<route>.md`, from the home page's `md_source: true` (2.11).
175
+ */
176
+ mdSource: boolean;
172
177
  };
173
178
  export type Paths = import("./config.js").Paths;
174
179
  export type Assets = import("./compiler.js").Assets;