@monoharada/wcf-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/server.mjs ADDED
@@ -0,0 +1,664 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { z } from 'zod';
7
+ import { collectCemCustomElements, validateTextAgainstCem } from './validator.mjs';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ const CANONICAL_PREFIX = 'dads';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Data loading - supports both bundled (npx) and local (repo) modes
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function resolveDataPath(fileName) {
18
+ // 1. Bundled data inside the package
19
+ const bundled = path.join(__dirname, 'data', fileName);
20
+ // 2. Repo root (when running from source)
21
+ const repoRoot = path.resolve(process.cwd());
22
+ const repoMap = {
23
+ 'custom-elements.json': path.join(repoRoot, 'custom-elements.json'),
24
+ 'install-registry.json': path.join(repoRoot, 'registry/install-registry.json'),
25
+ 'pattern-registry.json': path.join(repoRoot, 'registry/pattern-registry.json'),
26
+ };
27
+ return { bundled, repo: repoMap[fileName] };
28
+ }
29
+
30
+ async function loadJsonData(fileName) {
31
+ const { bundled, repo } = resolveDataPath(fileName);
32
+ // Try bundled first, then fall back to repo
33
+ for (const p of [bundled, repo]) {
34
+ if (!p) continue;
35
+ try {
36
+ const text = await fs.readFile(p, 'utf8');
37
+ return JSON.parse(text);
38
+ } catch {
39
+ // Try next path
40
+ }
41
+ }
42
+ throw new Error(`データファイルが見つかりません: ${fileName}`);
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Prefix helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function normalizePrefix(prefix) {
50
+ if (typeof prefix !== 'string' || prefix.trim() === '') return CANONICAL_PREFIX;
51
+ return prefix.trim().toLowerCase();
52
+ }
53
+
54
+ function withPrefix(tagName, prefix) {
55
+ if (typeof tagName !== 'string') return tagName;
56
+ const p = normalizePrefix(prefix);
57
+ if (p === CANONICAL_PREFIX) return tagName;
58
+ const from = `${CANONICAL_PREFIX}-`;
59
+ if (!tagName.startsWith(from)) return tagName;
60
+ return `${p}-${tagName.slice(from.length)}`;
61
+ }
62
+
63
+ function toCanonicalTagName(tagName, prefix) {
64
+ if (typeof tagName !== 'string') return undefined;
65
+ const raw = tagName.trim().toLowerCase();
66
+ if (!raw) return undefined;
67
+ if (raw.startsWith(`${CANONICAL_PREFIX}-`)) return raw;
68
+
69
+ const p = normalizePrefix(prefix);
70
+ if (p !== CANONICAL_PREFIX && raw.startsWith(`${p}-`)) {
71
+ return `${CANONICAL_PREFIX}-${raw.slice(p.length + 1)}`;
72
+ }
73
+
74
+ return raw;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // CEM helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function findCustomElementDeclarations(manifest) {
82
+ const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
83
+ const decls = [];
84
+
85
+ for (const mod of modules) {
86
+ const modulePath = typeof mod?.path === 'string' ? mod.path : undefined;
87
+ const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
88
+ for (const decl of declarations) {
89
+ if (!decl || typeof decl !== 'object') continue;
90
+ const tagName = typeof decl.tagName === 'string' ? decl.tagName : undefined;
91
+ const isCustomElement = decl.customElement === true || decl.kind === 'custom-element';
92
+ if (!isCustomElement || !tagName) continue;
93
+
94
+ decls.push({ decl, tagName: tagName.toLowerCase(), modulePath });
95
+ }
96
+ }
97
+
98
+ return decls;
99
+ }
100
+
101
+ function buildIndexes(manifest) {
102
+ const decls = findCustomElementDeclarations(manifest);
103
+
104
+ const byTag = new Map();
105
+ const byClass = new Map();
106
+ const modulePathByTag = new Map();
107
+
108
+ for (const { decl, tagName, modulePath } of decls) {
109
+ if (!byTag.has(tagName)) byTag.set(tagName, decl);
110
+ if (typeof decl?.name === 'string' && !byClass.has(decl.name)) byClass.set(decl.name, decl);
111
+ if (!modulePathByTag.has(tagName)) modulePathByTag.set(tagName, modulePath);
112
+ }
113
+
114
+ return { byTag, byClass, modulePathByTag, decls };
115
+ }
116
+
117
+ function pickDecl({ byTag, byClass }, { tagName, className, prefix }) {
118
+ if (typeof tagName === 'string' && tagName.trim() !== '') {
119
+ const canonical = toCanonicalTagName(tagName, prefix);
120
+ if (canonical && byTag.has(canonical)) return byTag.get(canonical);
121
+ }
122
+
123
+ if (typeof className === 'string' && className.trim() !== '' && byClass.has(className.trim())) {
124
+ return byClass.get(className.trim());
125
+ }
126
+
127
+ return undefined;
128
+ }
129
+
130
+ function serializeApi(decl, modulePath, prefix) {
131
+ const tagName = typeof decl?.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
132
+ const outTag = tagName ? withPrefix(tagName, prefix) : undefined;
133
+
134
+ const attributes = Array.isArray(decl?.attributes) ? decl.attributes : [];
135
+ const slots = Array.isArray(decl?.slots) ? decl.slots : [];
136
+ const events = Array.isArray(decl?.events) ? decl.events : [];
137
+ const cssParts = Array.isArray(decl?.cssParts) ? decl.cssParts : [];
138
+ const cssProperties = Array.isArray(decl?.cssProperties) ? decl.cssProperties : [];
139
+
140
+ return {
141
+ tagName: outTag,
142
+ className: typeof decl?.name === 'string' ? decl.name : undefined,
143
+ description: typeof decl?.description === 'string' ? decl.description : undefined,
144
+ modulePath,
145
+ custom: decl?.custom,
146
+ attributes: attributes.map((a) => ({
147
+ name: a?.name,
148
+ type: a?.type?.text,
149
+ description: a?.description,
150
+ inheritedFrom: a?.inheritedFrom,
151
+ deprecated: a?.deprecated,
152
+ })),
153
+ slots: slots.map((s) => ({
154
+ name: s?.name,
155
+ description: s?.description,
156
+ })),
157
+ events: events.map((e) => ({
158
+ name: e?.name,
159
+ type: e?.type?.text,
160
+ description: e?.description,
161
+ inheritedFrom: e?.inheritedFrom,
162
+ deprecated: e?.deprecated,
163
+ })),
164
+ cssParts: cssParts.map((p) => ({
165
+ name: p?.name,
166
+ description: p?.description,
167
+ })),
168
+ cssProperties: cssProperties.map((p) => ({
169
+ name: p?.name,
170
+ default: p?.default,
171
+ description: p?.description,
172
+ })),
173
+ };
174
+ }
175
+
176
+ function generateSnippet(api, prefix) {
177
+ const tag = api.tagName ?? withPrefix(String(api.className ?? 'dads-component'), prefix);
178
+ const attrs = Array.isArray(api.attributes) ? api.attributes : [];
179
+ const slots = Array.isArray(api.slots) ? api.slots : [];
180
+
181
+ const attrPriority = [
182
+ 'label',
183
+ 'support-text',
184
+ 'value',
185
+ 'name',
186
+ 'type',
187
+ 'variant',
188
+ 'size',
189
+ 'required',
190
+ 'disabled',
191
+ 'readonly',
192
+ ];
193
+
194
+ const attrByName = new Map(attrs.map((a) => [String(a?.name ?? ''), a]));
195
+ const lines = [];
196
+
197
+ for (const name of attrPriority) {
198
+ const a = attrByName.get(name);
199
+ if (!a) continue;
200
+ const t = String(a.type ?? '').toLowerCase();
201
+ const isBoolean = t.includes('boolean');
202
+ if (isBoolean) lines.push(` ${name}`);
203
+ else lines.push(` ${name}=""`);
204
+ if (lines.length >= 4) break;
205
+ }
206
+
207
+ const open = lines.length > 0 ? `<${tag}\n${lines.join('\n')}\n>` : `<${tag}>`;
208
+ const slotNames = slots
209
+ .map((s) => String(s?.name ?? '').trim())
210
+ .filter((s) => s !== '');
211
+ const slotComment =
212
+ slotNames.length > 0 ? `\n <!-- slots: ${slotNames.join(', ')} -->\n` : '\n';
213
+
214
+ return `${open}${slotComment}</${tag}>`;
215
+ }
216
+
217
+ function findDeclByComponentId(indexes, componentIdRaw) {
218
+ const componentId = typeof componentIdRaw === 'string' ? componentIdRaw.trim() : '';
219
+ if (!componentId) return undefined;
220
+ for (const { decl, modulePath } of indexes.decls) {
221
+ const installId = decl?.custom?.install?.id;
222
+ const inferredId = decl?.custom?.componentId;
223
+ const id = typeof installId === 'string' ? installId : typeof inferredId === 'string' ? inferredId : undefined;
224
+ if (id === componentId) return { decl, modulePath };
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ function applyPrefixToCemIndex(cemIndex, prefix) {
230
+ const p = normalizePrefix(prefix);
231
+ if (p === CANONICAL_PREFIX) return cemIndex;
232
+
233
+ const out = new Map();
234
+ for (const [tag, meta] of cemIndex.entries()) {
235
+ const nextTag = withPrefix(tag, p);
236
+ out.set(nextTag, meta);
237
+ }
238
+ return out;
239
+ }
240
+
241
+ function applyPrefixToHtml(html, prefix) {
242
+ const p = normalizePrefix(prefix);
243
+ if (p === CANONICAL_PREFIX) return String(html ?? '');
244
+ const from = `${CANONICAL_PREFIX}-`;
245
+ const to = `${p}-`;
246
+
247
+ return String(html ?? '').replace(
248
+ new RegExp(`<\\s*(\\/?)\\s*${from}([a-z0-9-]+)(?=[\\s/>])`, 'gi'),
249
+ (_m, slash, rest) => `<${slash ?? ''}${to}${String(rest).toLowerCase()}`,
250
+ );
251
+ }
252
+
253
+ function loadPatternRegistryShape(raw) {
254
+ if (!raw || typeof raw !== 'object') return { patterns: {} };
255
+ const patterns = raw.patterns && typeof raw.patterns === 'object' ? raw.patterns : {};
256
+ return { patterns };
257
+ }
258
+
259
+ function resolveComponentClosure({ installRegistry }, componentIds) {
260
+ const components = installRegistry?.components && typeof installRegistry.components === 'object' ? installRegistry.components : {};
261
+ const queue = [...new Set(componentIds.map((c) => String(c ?? '').trim()).filter(Boolean))];
262
+ const out = new Set();
263
+
264
+ while (queue.length > 0) {
265
+ const id = queue.shift();
266
+ if (!id || out.has(id)) continue;
267
+ out.add(id);
268
+
269
+ const meta = components[id];
270
+ const deps = Array.isArray(meta?.deps) ? meta.deps : [];
271
+ for (const d of deps) {
272
+ const dep = String(d ?? '').trim();
273
+ if (dep && !out.has(dep)) queue.push(dep);
274
+ }
275
+ }
276
+
277
+ return [...out];
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Main
282
+ // ---------------------------------------------------------------------------
283
+
284
+ export async function startServer() {
285
+ const manifest = await loadJsonData('custom-elements.json');
286
+ const indexes = buildIndexes(manifest);
287
+ const canonicalCemIndex = collectCemCustomElements(manifest);
288
+ const installRegistry = await loadJsonData('install-registry.json');
289
+ const patternRegistry = await loadJsonData('pattern-registry.json');
290
+ const { patterns } = loadPatternRegistryShape(patternRegistry);
291
+
292
+ const server = new McpServer({
293
+ name: 'web-components-factory-design-system',
294
+ version: '0.1.0',
295
+ });
296
+
297
+ // -----------------------------------------------------------------------
298
+ // Tool: list_components
299
+ // -----------------------------------------------------------------------
300
+ server.registerTool(
301
+ 'list_components',
302
+ {
303
+ description: 'List custom elements in the design system (from custom-elements.json).',
304
+ inputSchema: {
305
+ prefix: z.string().optional(),
306
+ },
307
+ },
308
+ async ({ prefix }) => {
309
+ const p = normalizePrefix(prefix);
310
+ const list = indexes.decls.map(({ decl, tagName, modulePath }) => ({
311
+ tagName: withPrefix(tagName, p),
312
+ className: typeof decl?.name === 'string' ? decl.name : undefined,
313
+ description: typeof decl?.description === 'string' ? decl.description : undefined,
314
+ modulePath,
315
+ }));
316
+
317
+ return {
318
+ content: [{ type: 'text', text: JSON.stringify(list, null, 2) }],
319
+ };
320
+ },
321
+ );
322
+
323
+ // -----------------------------------------------------------------------
324
+ // Tool: get_component_api
325
+ // -----------------------------------------------------------------------
326
+ server.registerTool(
327
+ 'get_component_api',
328
+ {
329
+ description:
330
+ 'Get a single component API (attributes/slots/events/cssParts/cssProperties) by tagName or className.',
331
+ inputSchema: {
332
+ tagName: z.string().optional(),
333
+ className: z.string().optional(),
334
+ prefix: z.string().optional(),
335
+ },
336
+ },
337
+ async ({ tagName, className, prefix }) => {
338
+ const decl = pickDecl(indexes, { tagName, className, prefix });
339
+ if (!decl) {
340
+ return {
341
+ content: [
342
+ {
343
+ type: 'text',
344
+ text: `Component not found (tagName=${String(tagName ?? '')}, className=${String(className ?? '')})`,
345
+ },
346
+ ],
347
+ isError: true,
348
+ };
349
+ }
350
+
351
+ const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
352
+ const modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined;
353
+ const api = serializeApi(decl, modulePath, prefix);
354
+
355
+ return {
356
+ content: [{ type: 'text', text: JSON.stringify(api, null, 2) }],
357
+ };
358
+ },
359
+ );
360
+
361
+ // -----------------------------------------------------------------------
362
+ // Tool: generate_usage_snippet
363
+ // -----------------------------------------------------------------------
364
+ server.registerTool(
365
+ 'generate_usage_snippet',
366
+ {
367
+ description: 'Generate a minimal usage snippet for a component.',
368
+ inputSchema: {
369
+ component: z.string(),
370
+ prefix: z.string().optional(),
371
+ },
372
+ },
373
+ async ({ component, prefix }) => {
374
+ const decl =
375
+ pickDecl(indexes, { tagName: component, prefix }) ??
376
+ pickDecl(indexes, { className: component, prefix });
377
+
378
+ if (!decl) {
379
+ return {
380
+ content: [{ type: 'text', text: `Component not found: ${component}` }],
381
+ isError: true,
382
+ };
383
+ }
384
+
385
+ const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
386
+ const modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined;
387
+ const api = serializeApi(decl, modulePath, prefix);
388
+ const snippet = generateSnippet(api, prefix);
389
+
390
+ return {
391
+ content: [{ type: 'text', text: snippet }],
392
+ };
393
+ },
394
+ );
395
+
396
+ // -----------------------------------------------------------------------
397
+ // Tool: get_install_recipe
398
+ // -----------------------------------------------------------------------
399
+ server.registerTool(
400
+ 'get_install_recipe',
401
+ {
402
+ description:
403
+ 'Get an install recipe (componentId/deps/define + usage snippet) from CEM custom metadata.',
404
+ inputSchema: {
405
+ component: z.string(),
406
+ prefix: z.string().optional(),
407
+ },
408
+ },
409
+ async ({ component, prefix }) => {
410
+ const p = normalizePrefix(prefix);
411
+
412
+ const byTagOrClass =
413
+ pickDecl(indexes, { tagName: component, prefix: p }) ??
414
+ pickDecl(indexes, { className: component, prefix: p });
415
+
416
+ const byComponentId = byTagOrClass ? undefined : findDeclByComponentId(indexes, component);
417
+ const decl = byTagOrClass ?? byComponentId?.decl;
418
+
419
+ if (!decl) {
420
+ return {
421
+ content: [{ type: 'text', text: `Component not found: ${component}` }],
422
+ isError: true,
423
+ };
424
+ }
425
+
426
+ const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
427
+ const modulePath =
428
+ canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : byComponentId?.modulePath;
429
+ const api = serializeApi(decl, modulePath, p);
430
+ const usageSnippet = generateSnippet(api, p);
431
+
432
+ const install = decl?.custom?.install;
433
+ if (!install || typeof install !== 'object') {
434
+ return {
435
+ content: [
436
+ {
437
+ type: 'text',
438
+ text: 'Install metadata not found in CEM.',
439
+ },
440
+ ],
441
+ isError: true,
442
+ };
443
+ }
444
+
445
+ const componentId = String(install.id ?? '').trim() || api?.custom?.componentId;
446
+ const define = String(install.define ?? '').trim();
447
+ const deps = Array.isArray(install.deps) ? install.deps : [];
448
+ const tags = Array.isArray(install.tags) ? install.tags : [];
449
+
450
+ const tagNames =
451
+ tags.length > 0 ? tags.map((t) => withPrefix(String(t).toLowerCase(), p)) : [api.tagName];
452
+
453
+ const defineHint = define
454
+ ? [
455
+ modulePath ? `import { ${define} } from "${modulePath}";` : `import { ${define} } from "<modulePath>";`,
456
+ `${define}();`,
457
+ p !== CANONICAL_PREFIX ? `// If supported: ${define}("${p}");` : undefined,
458
+ ]
459
+ .filter(Boolean)
460
+ .join('\n')
461
+ : undefined;
462
+
463
+ return {
464
+ content: [
465
+ {
466
+ type: 'text',
467
+ text: JSON.stringify(
468
+ {
469
+ componentId,
470
+ tagNames,
471
+ deps,
472
+ define,
473
+ defineHint,
474
+ source: install.source,
475
+ usageSnippet,
476
+ installHint: componentId ? `wcf add ${componentId}` : undefined,
477
+ },
478
+ null,
479
+ 2,
480
+ ),
481
+ },
482
+ ],
483
+ };
484
+ },
485
+ );
486
+
487
+ // -----------------------------------------------------------------------
488
+ // Tool: validate_markup
489
+ // -----------------------------------------------------------------------
490
+ server.registerTool(
491
+ 'validate_markup',
492
+ {
493
+ description:
494
+ 'Validate an HTML snippet against CEM (unknownElement=error, unknownAttribute=warning).',
495
+ inputSchema: {
496
+ html: z.string(),
497
+ prefix: z.string().optional(),
498
+ },
499
+ },
500
+ async ({ html, prefix }) => {
501
+ const p = normalizePrefix(prefix);
502
+ let cemIndex = canonicalCemIndex;
503
+ if (p !== CANONICAL_PREFIX) {
504
+ const combined = new Map(canonicalCemIndex);
505
+ const prefixed = applyPrefixToCemIndex(canonicalCemIndex, p);
506
+ for (const [tag, meta] of prefixed.entries()) combined.set(tag, meta);
507
+ cemIndex = combined;
508
+ }
509
+
510
+ const diagnostics = validateTextAgainstCem({
511
+ filePath: '<markup>',
512
+ text: html,
513
+ cem: cemIndex,
514
+ severity: {
515
+ unknownElement: 'error',
516
+ unknownAttribute: 'warning',
517
+ },
518
+ }).map((d) => ({
519
+ file: d.file,
520
+ range: d.range,
521
+ severity: d.severity,
522
+ code: d.code,
523
+ message: d.message,
524
+ tagName: d.tagName,
525
+ attrName: d.attrName,
526
+ hint: d.hint,
527
+ }));
528
+
529
+ return {
530
+ content: [{ type: 'text', text: JSON.stringify({ diagnostics }, null, 2) }],
531
+ };
532
+ },
533
+ );
534
+
535
+ // -----------------------------------------------------------------------
536
+ // Tool: list_patterns
537
+ // -----------------------------------------------------------------------
538
+ server.registerTool(
539
+ 'list_patterns',
540
+ {
541
+ description: 'List UI patterns (recipes) from the pattern registry.',
542
+ inputSchema: {},
543
+ },
544
+ async () => {
545
+ const list = Object.values(patterns).map((p) => ({
546
+ id: p?.id,
547
+ title: p?.title,
548
+ description: p?.description,
549
+ requires: p?.requires,
550
+ }));
551
+
552
+ return {
553
+ content: [{ type: 'text', text: JSON.stringify(list, null, 2) }],
554
+ };
555
+ },
556
+ );
557
+
558
+ // -----------------------------------------------------------------------
559
+ // Tool: get_pattern_recipe
560
+ // -----------------------------------------------------------------------
561
+ server.registerTool(
562
+ 'get_pattern_recipe',
563
+ {
564
+ description:
565
+ 'Get a pattern recipe (required componentIds + resolved HTML snippet). Use this to drive wcf installs + UI composition.',
566
+ inputSchema: {
567
+ patternId: z.string(),
568
+ prefix: z.string().optional(),
569
+ },
570
+ },
571
+ async ({ patternId, prefix }) => {
572
+ const id = String(patternId ?? '').trim();
573
+ const p = normalizePrefix(prefix);
574
+ const pat = patterns[id];
575
+ if (!pat) {
576
+ return {
577
+ content: [{ type: 'text', text: `Pattern not found: ${id}` }],
578
+ isError: true,
579
+ };
580
+ }
581
+
582
+ const requires = Array.isArray(pat.requires) ? pat.requires : [];
583
+ const closure = resolveComponentClosure({ installRegistry }, requires);
584
+
585
+ const components = installRegistry?.components && typeof installRegistry.components === 'object' ? installRegistry.components : {};
586
+ const install = Object.fromEntries(
587
+ closure
588
+ .map((cid) => [cid, components[cid]])
589
+ .filter(([, meta]) => meta && typeof meta === 'object')
590
+ .map(([cid, meta]) => [
591
+ cid,
592
+ {
593
+ ...meta,
594
+ tags: Array.isArray(meta.tags) ? meta.tags.map((t) => withPrefix(String(t).toLowerCase(), p)) : meta.tags,
595
+ },
596
+ ]),
597
+ );
598
+
599
+ const canonicalHtml = String(pat.html ?? '');
600
+ const html = applyPrefixToHtml(canonicalHtml, p);
601
+
602
+ return {
603
+ content: [
604
+ {
605
+ type: 'text',
606
+ text: JSON.stringify(
607
+ {
608
+ pattern: {
609
+ id: pat.id,
610
+ title: pat.title,
611
+ description: pat.description,
612
+ },
613
+ prefix: p,
614
+ requires,
615
+ components: closure,
616
+ install,
617
+ html,
618
+ canonicalHtml,
619
+ installHint: closure.length > 0 ? `wcf add ${closure.join(' ')}` : undefined,
620
+ },
621
+ null,
622
+ 2,
623
+ ),
624
+ },
625
+ ],
626
+ };
627
+ },
628
+ );
629
+
630
+ // -----------------------------------------------------------------------
631
+ // Tool: generate_pattern_snippet
632
+ // -----------------------------------------------------------------------
633
+ server.registerTool(
634
+ 'generate_pattern_snippet',
635
+ {
636
+ description: 'Generate a pattern HTML snippet. (Same as get_pattern_recipe().html)',
637
+ inputSchema: {
638
+ patternId: z.string(),
639
+ prefix: z.string().optional(),
640
+ },
641
+ },
642
+ async ({ patternId, prefix }) => {
643
+ const id = String(patternId ?? '').trim();
644
+ const p = normalizePrefix(prefix);
645
+ const pat = patterns[id];
646
+ if (!pat) {
647
+ return {
648
+ content: [{ type: 'text', text: `Pattern not found: ${id}` }],
649
+ isError: true,
650
+ };
651
+ }
652
+
653
+ return {
654
+ content: [{ type: 'text', text: applyPrefixToHtml(String(pat.html ?? ''), p) }],
655
+ };
656
+ },
657
+ );
658
+
659
+ // -----------------------------------------------------------------------
660
+ // Start
661
+ // -----------------------------------------------------------------------
662
+ const transport = new StdioServerTransport();
663
+ await server.connect(transport);
664
+ }