@zvndev/darkmown 2.10.1 → 2.12.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/AGENTS.md +8 -6
- package/CHANGELOG.md +16 -0
- package/README.md +45 -5
- package/docs/cli.md +20 -0
- package/docs/errors.md +255 -0
- package/docs/spec-alignment.md +11 -0
- package/package.json +2 -1
- package/site/pages/docs/index.wd +10 -5
- package/site/pages/index.wd +1 -0
- package/site/pages/playground/index.js +32 -33
- package/src/builder.js +118 -11
- package/src/catalog.js +8 -0
- package/src/cli.js +49 -4
- package/src/compiler/markdown.js +14 -2
- package/src/compiler/page.js +27 -15
- package/src/compiler.js +1 -1
- package/src/font-subset.js +335 -105
- package/src/mcp-tools.js +354 -0
- package/src/mcp.js +287 -0
- package/src/statics.js +172 -15
- package/types/builder.d.ts +5 -0
- package/types/compiler/page.d.ts +12 -3
- package/types/compiler.d.ts +4 -1
- package/types/font-subset.d.ts +54 -6
- package/types/statics.d.ts +42 -3
package/src/mcp-tools.js
ADDED
|
@@ -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
|
+
}
|