@twobirds/microcomponents 0.10.2 → 0.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.
package/dist/mcp/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
5
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
3
  import { basename, dirname, join, resolve } from "node:path";
7
4
  import { fileURLToPath } from "node:url";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
7
+ import { z } from "zod";
8
8
  //#region \0rolldown/runtime.js
9
9
  var __defProp = Object.defineProperty;
10
10
  var __esmMin = (fn, res, err) => () => {
@@ -25,6 +25,301 @@ var __exportAll = (all, no_symbols) => {
25
25
  return target;
26
26
  };
27
27
  //#endregion
28
+ //#region src/mcp/docs.ts
29
+ /**
30
+ Documentation data for the MCP server — the single source of truth the tools read.
31
+
32
+ The index is derived, never hand-copied. In a published package the server runs from
33
+ dist/mcp and reads the prebuilt api.json; in development (tsx from src/mcp) the index
34
+ is generated live from src/ at startup, so it cannot drift away from the library it
35
+ documents. The dev/prod decision lives in server.ts; this module is the pure data layer.
36
+
37
+ @module MCP/Docs
38
+ */
39
+ /**
40
+ Reads the prebuilt api.json from a server directory.
41
+
42
+ @param dir - The directory the compiled server sits in; api.json is beside it.
43
+ @returns The parsed index.
44
+ */
45
+ function loadIndex(dir) {
46
+ return JSON.parse(readFileSync(join(dir, "api.json"), "utf8"));
47
+ }
48
+ /**
49
+ Concept essays live one per file: copied into dist/mcp/concepts in production, read
50
+ from docs/concepts in development.
51
+
52
+ @param dir - The concepts folder.
53
+ @returns A record of concept name (filename without `.md`) to essay text. An empty
54
+ record when the folder is absent.
55
+ */
56
+ function readConcepts(dir) {
57
+ if (!existsSync(dir)) return {};
58
+ const out = {};
59
+ for (const f of readdirSync(dir)) {
60
+ if (!f.endsWith(".md")) continue;
61
+ out[f.replace(/\.md$/, "")] = readFileSync(join(dir, f), "utf8").trim();
62
+ }
63
+ return out;
64
+ }
65
+ /**
66
+ First paragraph or sentence of a description, truncated honestly.
67
+
68
+ @param text - The description text.
69
+ @param limit - Maximum characters before truncation.
70
+ @returns The summary.
71
+ */
72
+ function summarize(text, limit = 160) {
73
+ const flat = (text.split(/\n\s*\n|(?<=[.!?])\s+/)[0] ?? text).replace(/\s+/g, " ").trim();
74
+ if (flat.length <= limit) return flat;
75
+ return `${flat.slice(0, limit).replace(/[\s,\-]+$/, "")}...`;
76
+ }
77
+ var FENCE = "`".repeat(3);
78
+ /**
79
+ Scores one index entry against a lowercased query.
80
+
81
+ @param e - The entry to score.
82
+ @param q - The lowercased query.
83
+ @returns The relevance score; 0 when the entry does not match.
84
+ */
85
+ function scoreEntry(e, q) {
86
+ let score = 0;
87
+ if (e.name.toLowerCase() === q) score += 8;
88
+ if (e.name.toLowerCase().includes(q)) score += 4;
89
+ if ((e.group ?? "").toLowerCase().includes(q)) score += 2;
90
+ if ((e.description ?? "").toLowerCase().includes(q)) score += 3;
91
+ for (const ex of e.examples ?? []) if (ex.code.toLowerCase().includes(q) || ex.title.toLowerCase().includes(q)) score += 2;
92
+ return score;
93
+ }
94
+ /**
95
+ Builds the six tools against a given context.
96
+
97
+ @param ctx - The index, the concept essays, and the readable source root.
98
+ @returns A record of tool name to definition, in registration order.
99
+ */
100
+ function createTools(ctx) {
101
+ const API = ctx.index.entries;
102
+ const undocumented = ctx.index.undocumented;
103
+ const CONCEPT_NAMES = Object.keys(ctx.concepts);
104
+ return {
105
+ "list-components": {
106
+ description: "List all documented Micro Components classes, utilities and types with their group and a short summary",
107
+ schema: {},
108
+ handler: async () => {
109
+ const list = Object.values(API).map((e) => ({
110
+ name: e.name,
111
+ kind: e.kind,
112
+ group: e.group,
113
+ file: e.file,
114
+ summary: summarize(e.description ?? "", 120),
115
+ examples: (e.examples ?? []).length
116
+ }));
117
+ return { content: [{
118
+ type: "text",
119
+ text: JSON.stringify({
120
+ libraryVersion: ctx.index.libraryVersion,
121
+ exportedCount: ctx.index.exportedCount,
122
+ components: list
123
+ }, null, 2)
124
+ }] };
125
+ }
126
+ },
127
+ "get-component-docs": {
128
+ description: "Get comprehensive documentation and usage examples for a Micro Components class or utility",
129
+ schema: { componentName: z.string().describe("The name of the component (e.g., DC, defineCE, observe, injectCSS)") },
130
+ handler: async ({ componentName }) => {
131
+ const entry = API[componentName];
132
+ if (!entry) return {
133
+ content: [{
134
+ type: "text",
135
+ text: `No documentation for "${componentName}". ${Object.keys(API).length} entries exist; call list-components or search-components to discover names.`
136
+ }],
137
+ isError: true
138
+ };
139
+ const lines = [
140
+ `# ${entry.name}`,
141
+ "",
142
+ `**Kind:** ${entry.kind ?? "unknown"}`,
143
+ ...entry.group ? [`**Group:** ${entry.group}`] : [],
144
+ ...entry.file ? [`**Source:** ${entry.file}`] : [],
145
+ ""
146
+ ];
147
+ if (entry.description) lines.push("## Description", entry.description, "");
148
+ if (entry.signatures?.length) {
149
+ lines.push("## Signatures");
150
+ for (const sig of entry.signatures) lines.push(`${FENCE}ts`, sig, FENCE, "");
151
+ }
152
+ const examples = entry.examples ?? [];
153
+ if (examples.length) {
154
+ lines.push("## Examples");
155
+ for (const ex of examples) {
156
+ if (ex.title) lines.push(`### ${ex.title}`);
157
+ lines.push(`${FENCE}ts`, ex.code, FENCE, "");
158
+ }
159
+ }
160
+ return { content: [{
161
+ type: "text",
162
+ text: lines.join("\n")
163
+ }] };
164
+ }
165
+ },
166
+ "search-components": {
167
+ description: "Search Micro Components documentation by keyword. Searches names, groups, descriptions and code examples.",
168
+ schema: { query: z.string().describe("Search term to find relevant components (e.g., \"event\", \"observable\", \"css\")") },
169
+ handler: async ({ query }) => {
170
+ const q = query.toLowerCase();
171
+ const hits = Object.values(API).map((e) => ({
172
+ e,
173
+ s: scoreEntry(e, q)
174
+ })).filter((h) => h.s > 0).sort((a, b) => b.s - a.s || a.e.name.localeCompare(b.e.name)).slice(0, 12);
175
+ const conceptHits = CONCEPT_NAMES.filter((k) => ctx.concepts[k].toLowerCase().includes(q));
176
+ if (!hits.length && !conceptHits.length) return { content: [{
177
+ type: "text",
178
+ text: `No documentation matched "${query}". Use list-components to see every entry, or get-source-file for raw source.`
179
+ }] };
180
+ const results = hits.map((h) => ({
181
+ name: h.e.name,
182
+ kind: h.e.kind,
183
+ group: h.e.group,
184
+ relevance: h.s,
185
+ snippet: summarize(h.e.description ?? "", 150)
186
+ }));
187
+ return { content: [{
188
+ type: "text",
189
+ text: JSON.stringify({
190
+ query,
191
+ results,
192
+ conceptMatches: conceptHits
193
+ }, null, 2)
194
+ }] };
195
+ }
196
+ },
197
+ "get-core-concept": {
198
+ description: `Get a core Micro Components concept essay. Available: ${CONCEPT_NAMES.join(", ") || "none"}`,
199
+ schema: { concept: z.string().describe(`Concept name, one of: ${CONCEPT_NAMES.join(", ")}`) },
200
+ handler: async ({ concept }) => {
201
+ const content = ctx.concepts[concept];
202
+ if (!content) return {
203
+ content: [{
204
+ type: "text",
205
+ text: `Concept "${concept}" not found. Available: ${CONCEPT_NAMES.join(", ")}`
206
+ }],
207
+ isError: true
208
+ };
209
+ return { content: [{
210
+ type: "text",
211
+ text: content
212
+ }] };
213
+ }
214
+ },
215
+ "get-source-file": {
216
+ description: "Retrieve the actual source code from a Micro Components library file (package-root relative)",
217
+ schema: { filepath: z.string().describe("Path to source file, e.g. dist/core/MC.js, dist/core/MC.d.ts, dist/mcp/concepts/event-system.md") },
218
+ handler: async ({ filepath }) => {
219
+ const full = resolve(ctx.sourceRoot, filepath);
220
+ if (full !== ctx.sourceRoot && !full.startsWith(ctx.sourceRoot + "/")) return {
221
+ content: [{
222
+ type: "text",
223
+ text: `Refused: "${filepath}" is outside the package sources.`
224
+ }],
225
+ isError: true
226
+ };
227
+ if (!existsSync(full)) return {
228
+ content: [{
229
+ type: "text",
230
+ text: `File not found: ${filepath}`
231
+ }],
232
+ isError: true
233
+ };
234
+ try {
235
+ const source = readFileSync(full, "utf8");
236
+ if (source.length > 2e5) return {
237
+ content: [{
238
+ type: "text",
239
+ text: `File too large (${source.length} bytes). Request a specific symbol via get-component-docs instead.`
240
+ }],
241
+ isError: true
242
+ };
243
+ return { content: [{
244
+ type: "text",
245
+ text: source
246
+ }] };
247
+ } catch (error) {
248
+ return {
249
+ content: [{
250
+ type: "text",
251
+ text: `Error reading file: ${error}`
252
+ }],
253
+ isError: true
254
+ };
255
+ }
256
+ }
257
+ },
258
+ "list-undocumented": {
259
+ description: "Report public exports whose source lacks a doc block — the drift signal for src/ documentation",
260
+ schema: {},
261
+ handler: async () => ({ content: [{
262
+ type: "text",
263
+ text: undocumented.length ? `${undocumented.length} undocumented export(s):\n${undocumented.join("\n")}` : `All ${ctx.index.exportedCount} public exports carry a doc block.`
264
+ }] })
265
+ }
266
+ };
267
+ }
268
+ //#endregion
269
+ //#region src/mcp/server.ts
270
+ /**
271
+ Wiring for the MCP server: read the documentation environment, and build the McpServer.
272
+
273
+ The tool behaviour lives in tools.ts and the data layer in docs.ts, both transport-free and
274
+ unit-tested. This module is the seam between them: loadEnv() makes the dev/prod decision and
275
+ reads the index and the concept essays, createServer() registers the tools on a server without
276
+ connecting a transport. Both are testable. Connecting stdio is the entry point's job
277
+ (index.ts), excluded from coverage as the process entry — running it in a test would take over
278
+ the test process's real stdio, which is not available in a worker thread.
279
+
280
+ @module MCP/Server
281
+ */
282
+ /**
283
+ Reads the documentation environment for the directory the compiled server sits in.
284
+
285
+ Production: the server runs from dist/mcp, so it reads the prebuilt api.json and the copied
286
+ concepts/ that the build step emits next to it. Development: the server runs from src/mcp,
287
+ where index.ts is present, so the index is generated live from src/ (it cannot drift away
288
+ from the library it documents) and concepts are read straight from docs/concepts.
289
+
290
+ @param dir - The directory the server module lives in.
291
+ @param devLoader - Supplies the live-generated index in development. The entry point passes
292
+ the real doc generator; tests pass a fixture, so the dev branch is pinned without running
293
+ the generator as a side effect on dist/.
294
+ @returns The index, the concept essays, and the folder get-source-file is confined to.
295
+ */
296
+ async function loadEnv(dir, devLoader) {
297
+ const IS_DEV = existsSync(join(dir, "index.ts"));
298
+ const conceptsDir = IS_DEV ? join(dir, "..", "..", "docs", "concepts") : join(dir, "concepts");
299
+ return {
300
+ index: IS_DEV ? await devLoader() : loadIndex(dir),
301
+ concepts: readConcepts(conceptsDir),
302
+ sourceRoot: join(dir, "..", "..")
303
+ };
304
+ }
305
+ /**
306
+ Registers every tool on a fresh McpServer, without connecting a transport.
307
+
308
+ @param env - The index, the concept essays, and the readable source root.
309
+ @returns The server, ready to connect.
310
+ */
311
+ function createServer(env) {
312
+ const server = new McpServer({
313
+ name: "microcomponents-mcp",
314
+ version: env.index.libraryVersion
315
+ });
316
+ for (const [name, def] of Object.entries(createTools(env))) server.registerTool(name, {
317
+ description: def.description,
318
+ inputSchema: def.schema
319
+ }, (args) => def.handler(args));
320
+ return server;
321
+ }
322
+ //#endregion
28
323
  //#region mcp-docs-generate.ts
29
324
  /**
30
325
  Generates the documentation index for the MCP server directly from src/.
@@ -45,9 +340,9 @@ above the first overload supplies the prose.
45
340
  */
46
341
  var mcp_docs_generate_exports = /* @__PURE__ */ __exportAll({
47
342
  extract: () => extract,
48
- index: () => index$1,
343
+ index: () => index,
49
344
  scanFile: () => scanFile,
50
- undocumented: () => undocumented$1,
345
+ undocumented: () => undocumented,
51
346
  walk: () => walk
52
347
  });
53
348
  /** Read a barrel file, returning each re-exported name with its group and hidden flag. */
@@ -209,7 +504,7 @@ function pickDoc(name) {
209
504
  }
210
505
  return best ?? { decl: candidates[0] };
211
506
  }
212
- var ROOT, SRC, OUTFILE, MAX_DOC_GAP_LINES, DECL_PATTERNS, publicEntries, scanned, byName, pkg, entries, undocumented$1, publicCount, index$1;
507
+ var ROOT, SRC, OUTFILE, MAX_DOC_GAP_LINES, DECL_PATTERNS, publicEntries, scanned, byName, pkg, entries, undocumented, publicCount, index;
213
508
  var init_mcp_docs_generate = __esmMin((() => {
214
509
  ROOT = dirname(fileURLToPath(import.meta.url));
215
510
  SRC = join(ROOT, "src");
@@ -240,12 +535,12 @@ var init_mcp_docs_generate = __esmMin((() => {
240
535
  for (const scan of Object.values(scanned)) for (const d of scan.decls) (byName[d.name] ??= []).push(d);
241
536
  pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
242
537
  entries = {};
243
- undocumented$1 = [];
538
+ undocumented = [];
244
539
  for (const pub of publicEntries) {
245
540
  if (pub.hidden) continue;
246
541
  const found = pickDoc(pub.name);
247
542
  if (!found) {
248
- undocumented$1.push(`${pub.kind} ${pub.name}`);
543
+ undocumented.push(`${pub.kind} ${pub.name}`);
249
544
  continue;
250
545
  }
251
546
  const sigs = byName[pub.name].map((d) => d.sig).filter(Boolean);
@@ -262,20 +557,20 @@ var init_mcp_docs_generate = __esmMin((() => {
262
557
  description: doc.description || void 0,
263
558
  examples: doc.examples.length ? doc.examples : void 0
264
559
  };
265
- if (!doc.description) undocumented$1.push(`${pub.kind} ${pub.name}`);
560
+ if (!doc.description) undocumented.push(`${pub.kind} ${pub.name}`);
266
561
  }
267
562
  publicCount = publicEntries.filter((p) => !p.hidden).length;
268
- index$1 = {
563
+ index = {
269
564
  libraryVersion: pkg.version,
270
565
  exportedCount: publicCount,
271
566
  entries,
272
- undocumented: undocumented$1
567
+ undocumented
273
568
  };
274
569
  if (Boolean(process.argv[1]) && import.meta.url === `file://${resolve(process.argv[1])}`) {
275
- writeFileSync(OUTFILE, JSON.stringify(index$1, null, 2));
570
+ writeFileSync(OUTFILE, JSON.stringify(index, null, 2));
276
571
  console.log(`${OUTFILE}: ${Object.keys(entries).length} of ${publicCount} public exports documented`);
277
- if (undocumented$1.length) {
278
- console.warn(`missing doc block (${undocumented$1.length}): ${undocumented$1.join(", ")}`);
572
+ if (undocumented.length) {
573
+ console.warn(`missing doc block (${undocumented.length}): ${undocumented.join(", ")}`);
279
574
  process.exit(1);
280
575
  }
281
576
  }
@@ -293,199 +588,21 @@ step emits next to it. In development (tsx from src/mcp) the index is generated
293
588
  from src/ at startup, so it cannot drift away from the library it documents.
294
589
 
295
590
  The server is Node-only and is emitted by tsconfig.mcp.json; package.json "bin" points
296
- at the compiled dist/mcp/index.js.
591
+ at the compiled dist/mcp/index.js. The tool layer (tools.ts), the data layer (docs.ts)
592
+ and the wiring (server.ts) are transport-free and unit-tested; this file is the process
593
+ entry that supplies the live generator, connects stdio, and reports readiness. It is
594
+ excluded from coverage — running it in a test would take over the test process's real
595
+ stdio, which a worker thread does not have.
297
596
 
298
597
  @example Run the published server: node dist/mcp/index.js
299
598
  @example Run from source during development: pnpm run mcp:dev
300
599
  @example Regenerate the shipped index only: pnpm run mcp:docs
301
600
  */
302
- var __dirname = dirname(fileURLToPath(import.meta.url));
303
- var MAX_SOURCE_BYTES = 2e5;
304
- var FENCE = "`".repeat(3);
305
- var IS_DEV = existsSync(join(__dirname, "index.ts"));
306
- var index;
307
- var undocumented;
308
- if (IS_DEV) {
309
- const live = await Promise.resolve().then(() => (init_mcp_docs_generate(), mcp_docs_generate_exports));
310
- index = live.index;
311
- undocumented = live.undocumented;
312
- } else {
313
- const data = JSON.parse(readFileSync(join(__dirname, "api.json"), "utf8"));
314
- index = data;
315
- undocumented = data.undocumented;
316
- }
317
- var API = index.entries;
318
- /** Concept essays live one per file: copied into dist/mcp/concepts in production, read from docs/concepts in development. */
319
- var CONCEPTS_DIR = IS_DEV ? join(__dirname, "..", "..", "docs", "concepts") : join(__dirname, "concepts");
320
- function readConcepts() {
321
- if (!existsSync(CONCEPTS_DIR)) return {};
322
- const out = {};
323
- for (const f of readdirSync(CONCEPTS_DIR)) {
324
- if (!f.endsWith(".md")) continue;
325
- out[f.replace(/\.md$/, "")] = readFileSync(join(CONCEPTS_DIR, f), "utf8").trim();
326
- }
327
- return out;
601
+ /** Development: tsx runs src/mcp/index.ts, so the index is generated live from src/. */
602
+ async function generateIndex() {
603
+ return (await Promise.resolve().then(() => (init_mcp_docs_generate(), mcp_docs_generate_exports))).index;
328
604
  }
329
- var CONCEPTS = readConcepts();
330
- var CONCEPT_NAMES = Object.keys(CONCEPTS);
331
- var SOURCE_ROOT = join(__dirname, "..", "..");
332
- /** First paragraph or sentence of a description, truncated honestly. */
333
- function summarize(text, limit = 160) {
334
- const flat = (text.split(/\n\s*\n|(?<=[.!?])\s+/)[0] ?? text).replace(/\s+/g, " ").trim();
335
- if (flat.length <= limit) return flat;
336
- return `${flat.slice(0, limit).replace(/[\s,\-]+$/, "")}...`;
337
- }
338
- var server = new McpServer({
339
- name: "microcomponents-mcp",
340
- version: index.libraryVersion
341
- });
342
- server.tool("list-components", "List all documented Micro Components classes, utilities and types with their group and a short summary", {}, async () => {
343
- const list = Object.values(API).map((e) => ({
344
- name: e.name,
345
- kind: e.kind,
346
- group: e.group,
347
- file: e.file,
348
- summary: summarize(e.description ?? "", 120),
349
- examples: (e.examples ?? []).length
350
- }));
351
- return { content: [{
352
- type: "text",
353
- text: JSON.stringify({
354
- libraryVersion: index.libraryVersion,
355
- exportedCount: index.exportedCount,
356
- components: list
357
- }, null, 2)
358
- }] };
359
- });
360
- server.tool("get-component-docs", "Get comprehensive documentation and usage examples for a Micro Components class or utility", { componentName: z.string().describe("The name of the component (e.g., DC, defineCE, observe, injectCSS)") }, async ({ componentName }) => {
361
- const entry = API[componentName];
362
- if (!entry) return {
363
- content: [{
364
- type: "text",
365
- text: `No documentation for "${componentName}". ${Object.keys(API).length} entries exist; call list-components or search-components to discover names.`
366
- }],
367
- isError: true
368
- };
369
- const lines = [
370
- `# ${entry.name}`,
371
- "",
372
- `**Kind:** ${entry.kind ?? "unknown"}`,
373
- ...entry.group ? [`**Group:** ${entry.group}`] : [],
374
- ...entry.file ? [`**Source:** ${entry.file}`] : [],
375
- ""
376
- ];
377
- if (entry.description) lines.push("## Description", entry.description, "");
378
- if (entry.signatures?.length) {
379
- lines.push("## Signatures");
380
- for (const sig of entry.signatures) lines.push(`${FENCE}ts`, sig, FENCE, "");
381
- }
382
- const examples = entry.examples ?? [];
383
- if (examples.length) {
384
- lines.push("## Examples");
385
- for (const ex of examples) {
386
- if (ex.title) lines.push(`### ${ex.title}`);
387
- lines.push(`${FENCE}ts`, ex.code, FENCE, "");
388
- }
389
- }
390
- return { content: [{
391
- type: "text",
392
- text: lines.join("\n")
393
- }] };
394
- });
395
- server.tool("search-components", "Search Micro Components documentation by keyword. Searches names, groups, descriptions and code examples.", { query: z.string().describe("Search term to find relevant components (e.g., \"event\", \"observable\", \"css\")") }, async ({ query }) => {
396
- const q = query.toLowerCase();
397
- const scoreEntry = (e) => {
398
- let score = 0;
399
- if (e.name.toLowerCase() === q) score += 8;
400
- if (e.name.toLowerCase().includes(q)) score += 4;
401
- if ((e.group ?? "").toLowerCase().includes(q)) score += 2;
402
- if ((e.description ?? "").toLowerCase().includes(q)) score += 3;
403
- for (const ex of e.examples ?? []) if (ex.code.toLowerCase().includes(q) || ex.title.toLowerCase().includes(q)) score += 2;
404
- return score;
405
- };
406
- const hits = Object.values(API).map((e) => ({
407
- e,
408
- s: scoreEntry(e)
409
- })).filter((h) => h.s > 0).sort((a, b) => b.s - a.s || a.e.name.localeCompare(b.e.name)).slice(0, 12);
410
- const conceptHits = CONCEPT_NAMES.filter((k) => CONCEPTS[k].toLowerCase().includes(q));
411
- if (!hits.length && !conceptHits.length) return { content: [{
412
- type: "text",
413
- text: `No documentation matched "${query}". Use list-components to see every entry, or get-source-file for raw source.`
414
- }] };
415
- const results = hits.map((h) => ({
416
- name: h.e.name,
417
- kind: h.e.kind,
418
- group: h.e.group,
419
- relevance: h.s,
420
- snippet: summarize(h.e.description ?? "", 150)
421
- }));
422
- return { content: [{
423
- type: "text",
424
- text: JSON.stringify({
425
- query,
426
- results,
427
- conceptMatches: conceptHits
428
- }, null, 2)
429
- }] };
430
- });
431
- server.tool("get-core-concept", `Get a core Micro Components concept essay. Available: ${CONCEPT_NAMES.join(", ") || "none"}`, { concept: z.string().describe(`Concept name, one of: ${CONCEPT_NAMES.join(", ")}`) }, async ({ concept }) => {
432
- const content = CONCEPTS[concept];
433
- if (!content) return {
434
- content: [{
435
- type: "text",
436
- text: `Concept "${concept}" not found. Available: ${CONCEPT_NAMES.join(", ")}`
437
- }],
438
- isError: true
439
- };
440
- return { content: [{
441
- type: "text",
442
- text: content
443
- }] };
444
- });
445
- server.tool("get-source-file", "Retrieve the actual source code from a Micro Components library file (package-root relative)", { filepath: z.string().describe("Path to source file, e.g. dist/core/MC.js, dist/core/MC.d.ts, dist/mcp/concepts/event-system.md") }, async ({ filepath }) => {
446
- const full = resolve(SOURCE_ROOT, filepath);
447
- if (full !== SOURCE_ROOT && !full.startsWith(SOURCE_ROOT + "/")) return {
448
- content: [{
449
- type: "text",
450
- text: `Refused: "${filepath}" is outside the package sources.`
451
- }],
452
- isError: true
453
- };
454
- if (!existsSync(full)) return {
455
- content: [{
456
- type: "text",
457
- text: `File not found: ${filepath}`
458
- }],
459
- isError: true
460
- };
461
- try {
462
- const source = readFileSync(full, "utf8");
463
- if (source.length > MAX_SOURCE_BYTES) return {
464
- content: [{
465
- type: "text",
466
- text: `File too large (${source.length} bytes). Request a specific symbol via get-component-docs instead.`
467
- }],
468
- isError: true
469
- };
470
- return { content: [{
471
- type: "text",
472
- text: source
473
- }] };
474
- } catch (error) {
475
- return {
476
- content: [{
477
- type: "text",
478
- text: `Error reading file: ${error}`
479
- }],
480
- isError: true
481
- };
482
- }
483
- });
484
- server.tool("list-undocumented", "Report public exports whose source lacks a doc block — the drift signal for src/ documentation", {}, async () => ({ content: [{
485
- type: "text",
486
- text: undocumented.length ? `${undocumented.length} undocumented export(s):\n${undocumented.join("\n")}` : `All ${index.exportedCount} public exports carry a doc block.`
487
- }] }));
488
- var transport = new StdioServerTransport();
489
- await server.connect(transport);
490
- console.error(`Micro Components MCP server running on stdio (library ${index.libraryVersion}, ${Object.keys(API).length} documented exports)`);
605
+ var env = await loadEnv(dirname(fileURLToPath(import.meta.url)), generateIndex);
606
+ await createServer(env).connect(new StdioServerTransport());
607
+ console.error(`Micro Components MCP server running on stdio (library ${env.index.libraryVersion}, ${Object.keys(env.index.entries).length} documented exports)`);
491
608
  //#endregion
@@ -0,0 +1,43 @@
1
+ /**
2
+ Wiring for the MCP server: read the documentation environment, and build the McpServer.
3
+
4
+ The tool behaviour lives in tools.ts and the data layer in docs.ts, both transport-free and
5
+ unit-tested. This module is the seam between them: loadEnv() makes the dev/prod decision and
6
+ reads the index and the concept essays, createServer() registers the tools on a server without
7
+ connecting a transport. Both are testable. Connecting stdio is the entry point's job
8
+ (index.ts), excluded from coverage as the process entry — running it in a test would take over
9
+ the test process's real stdio, which is not available in a worker thread.
10
+
11
+ @module MCP/Server
12
+ */
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { type IndexFile } from './docs.js';
15
+ /** Everything the tools read: the index, the concept essays, and the readable source root. */
16
+ export interface ServerEnv {
17
+ index: IndexFile;
18
+ concepts: Record<string, string>;
19
+ sourceRoot: string;
20
+ }
21
+ /**
22
+ Reads the documentation environment for the directory the compiled server sits in.
23
+
24
+ Production: the server runs from dist/mcp, so it reads the prebuilt api.json and the copied
25
+ concepts/ that the build step emits next to it. Development: the server runs from src/mcp,
26
+ where index.ts is present, so the index is generated live from src/ (it cannot drift away
27
+ from the library it documents) and concepts are read straight from docs/concepts.
28
+
29
+ @param dir - The directory the server module lives in.
30
+ @param devLoader - Supplies the live-generated index in development. The entry point passes
31
+ the real doc generator; tests pass a fixture, so the dev branch is pinned without running
32
+ the generator as a side effect on dist/.
33
+ @returns The index, the concept essays, and the folder get-source-file is confined to.
34
+ */
35
+ export declare function loadEnv(dir: string, devLoader: () => Promise<IndexFile>): Promise<ServerEnv>;
36
+ /**
37
+ Registers every tool on a fresh McpServer, without connecting a transport.
38
+
39
+ @param env - The index, the concept essays, and the readable source root.
40
+ @returns The server, ready to connect.
41
+ */
42
+ export declare function createServer(env: ServerEnv): McpServer;
43
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;EAWE;AACF,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAA2B,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAGpE,8FAA8F;AAC9F,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,UAAU,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;EAaE;AACF,wBAAsB,OAAO,CAC5B,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,GACjC,OAAO,CAAC,SAAS,CAAC,CAWpB;AAED;;;;;EAKE;AACF,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,SAAS,CAatD"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ The MCP tool layer — six tools over the derived documentation index.
3
+
4
+ Every tool is a plain handler over the index, the concept essays and a source root, so
5
+ the behaviour (including the path-traversal guard of get-source-file) is testable
6
+ without a transport. server.ts wires these onto an McpServer; this module has no
7
+ transport, no console side effects, and no module-level state.
8
+
9
+ @module MCP/Tools
10
+ */
11
+ import { z } from 'zod';
12
+ import { type Entry, type IndexFile } from './docs.js';
13
+ /** Files larger than this are refused by get-source-file. */
14
+ export declare const MAX_SOURCE_BYTES = 200000;
15
+ /**
16
+ The text payload a tool returns. Shaped to satisfy the SDK's ToolCallback return type,
17
+ which requires an index signature — a plain interface does not have one.
18
+ */
19
+ export type ToolResult = {
20
+ content: Array<{
21
+ type: 'text';
22
+ text: string;
23
+ }>;
24
+ isError?: boolean;
25
+ [key: string]: unknown;
26
+ };
27
+ /** One tool: what it says, what it accepts, and what it does. */
28
+ export interface ToolDef {
29
+ description: string;
30
+ schema: Record<string, z.ZodTypeAny>;
31
+ handler: (args: Record<string, string>) => Promise<ToolResult>;
32
+ }
33
+ /** Everything the tools read: the index, the essays, and the readable source root. */
34
+ export interface ToolContext {
35
+ index: IndexFile;
36
+ concepts: Record<string, string>;
37
+ sourceRoot: string;
38
+ }
39
+ /**
40
+ Scores one index entry against a lowercased query.
41
+
42
+ @param e - The entry to score.
43
+ @param q - The lowercased query.
44
+ @returns The relevance score; 0 when the entry does not match.
45
+ */
46
+ export declare function scoreEntry(e: Entry, q: string): number;
47
+ /**
48
+ Builds the six tools against a given context.
49
+
50
+ @param ctx - The index, the concept essays, and the readable source root.
51
+ @returns A record of tool name to definition, in registration order.
52
+ */
53
+ export declare function createTools(ctx: ToolContext): Record<string, ToolDef>;
54
+ //# sourceMappingURL=tools.d.ts.map