mcp-software-design 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.
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Heuristic code-smell detection. Pure and side-effect-free so it can be
3
+ * unit-tested in isolation and reused by the MCP `check_smells` tool.
4
+ *
5
+ * IMPORTANT — these are HEURISTICS, not proofs. A smell is a hint that a
6
+ * design principle *might* be strained; it is never a verdict. Detection is
7
+ * language-agnostic and best-effort: it reasons about braces, parentheses,
8
+ * and indentation, not a real parser, so it favors "obvious" findings and
9
+ * accepts occasional misses on exotic syntax. Every finding is mapped to the
10
+ * principle it hints at and a concrete refactoring suggestion.
11
+ *
12
+ * Robustness: all scanning is done with single-pass character loops and
13
+ * `indexOf`, never backtracking regexes over the whole input, so runtime is
14
+ * linear in the source length even on adversarial input.
15
+ */
16
+ export const DEFAULTS = {
17
+ longMethod: 40,
18
+ maxParams: 4,
19
+ maxDepth: 4,
20
+ maxFileLines: 400,
21
+ largeClassMethods: 12,
22
+ dupThreshold: 3,
23
+ };
24
+ /** Max source size we'll analyze; larger input is truncated with a note. */
25
+ export const MAX_CHARS = 2_000_000;
26
+ const CONTROL_KEYWORDS = new Set([
27
+ "if", "for", "while", "switch", "catch", "else", "do", "elif", "except",
28
+ "finally", "with", "when", "try", "return", "await", "yield", "throw",
29
+ ]);
30
+ const TYPE_KEYWORDS = new Set([
31
+ "class", "interface", "enum", "struct", "namespace", "module", "record",
32
+ "trait", "object", "protocol", "extension",
33
+ ]);
34
+ /**
35
+ * Blank out string literals, line comments, and block comments so structural
36
+ * counters (braces, commas, numbers) don't trip over their contents. Returns
37
+ * one sanitized string per input line; line indices are preserved.
38
+ *
39
+ * This is a single left-to-right tokenizer that tracks whether we're inside a
40
+ * block comment or an unterminated template literal ACROSS lines — so `/*`
41
+ * or a brace that appears inside a string never affects the code view, and a
42
+ * multi-line template literal is fully blanked. String/comment scanning uses
43
+ * `indexOf`/char loops (no backtracking regex), so it is linear-time.
44
+ */
45
+ export function sanitize(lines) {
46
+ const sanitizedLines = [];
47
+ // Carry-over state between lines. `"` / `'` strings do NOT carry (an
48
+ // unterminated one ends at the newline) to avoid runaway blanking; only
49
+ // block comments and template literals span lines.
50
+ let mode = "code";
51
+ for (const rawLine of lines) {
52
+ let codeOnly = "";
53
+ let cursor = 0;
54
+ const lineLength = rawLine.length;
55
+ while (cursor < lineLength) {
56
+ if (mode === "block") {
57
+ const closeIndex = rawLine.indexOf("*/", cursor);
58
+ if (closeIndex === -1) {
59
+ cursor = lineLength;
60
+ }
61
+ else {
62
+ cursor = closeIndex + 2;
63
+ mode = "code";
64
+ }
65
+ continue;
66
+ }
67
+ if (mode === "template") {
68
+ let scanIndex = cursor;
69
+ let closed = false;
70
+ while (scanIndex < lineLength) {
71
+ const char = rawLine[scanIndex];
72
+ if (char === "\\") {
73
+ scanIndex += 2;
74
+ continue;
75
+ }
76
+ if (char === "`") {
77
+ closed = true;
78
+ break;
79
+ }
80
+ scanIndex++;
81
+ }
82
+ if (closed) {
83
+ cursor = scanIndex + 1;
84
+ mode = "code";
85
+ }
86
+ else {
87
+ cursor = lineLength;
88
+ }
89
+ continue;
90
+ }
91
+ // mode === "code"
92
+ const char = rawLine[cursor];
93
+ const nextChar = cursor + 1 < lineLength ? rawLine[cursor + 1] : "";
94
+ if (char === "/" && nextChar === "/") {
95
+ cursor = lineLength;
96
+ continue;
97
+ } // line comment
98
+ if (char === "/" && nextChar === "*") {
99
+ mode = "block";
100
+ cursor += 2;
101
+ continue;
102
+ } // block comment open
103
+ // `#`: a Python/shell line comment, EXCEPT a JS/TS private member
104
+ // (`#field`, `this.#field`). Treat as code when it names an identifier
105
+ // or follows a `.`/identifier; otherwise it's a comment.
106
+ if (char === "#") {
107
+ const prevChar = codeOnly.length ? codeOnly[codeOnly.length - 1] : "";
108
+ const looksPrivate = /[A-Za-z_$]/.test(nextChar) || prevChar === "." || /[A-Za-z0-9_$]/.test(prevChar);
109
+ if (!looksPrivate) {
110
+ cursor = lineLength;
111
+ continue;
112
+ }
113
+ codeOnly += char;
114
+ cursor++;
115
+ continue;
116
+ }
117
+ if (char === '"' || char === "'") {
118
+ const quote = char;
119
+ let scanIndex = cursor + 1;
120
+ let closed = false;
121
+ while (scanIndex < lineLength) {
122
+ const stringChar = rawLine[scanIndex];
123
+ if (stringChar === "\\") {
124
+ scanIndex += 2;
125
+ continue;
126
+ }
127
+ if (stringChar === quote) {
128
+ closed = true;
129
+ break;
130
+ }
131
+ scanIndex++;
132
+ }
133
+ cursor = closed ? scanIndex + 1 : lineLength; // drop the string content
134
+ continue;
135
+ }
136
+ if (char === "`") {
137
+ let scanIndex = cursor + 1;
138
+ let closed = false;
139
+ while (scanIndex < lineLength) {
140
+ const stringChar = rawLine[scanIndex];
141
+ if (stringChar === "\\") {
142
+ scanIndex += 2;
143
+ continue;
144
+ }
145
+ if (stringChar === "`") {
146
+ closed = true;
147
+ break;
148
+ }
149
+ scanIndex++;
150
+ }
151
+ if (closed) {
152
+ cursor = scanIndex + 1;
153
+ }
154
+ else {
155
+ mode = "template";
156
+ cursor = lineLength;
157
+ }
158
+ continue;
159
+ }
160
+ codeOnly += char;
161
+ cursor++;
162
+ }
163
+ sanitizedLines.push(codeOnly);
164
+ }
165
+ return sanitizedLines;
166
+ }
167
+ /** Split `text` on `separator` only at bracket-nesting depth 0. */
168
+ function topLevelSplit(text, separator) {
169
+ const parts = [];
170
+ let depth = 0;
171
+ let current = "";
172
+ // `<`/`>` are counted so generics like `Map<string, number>` read as one
173
+ // argument. This mis-splits the rarer case of `<`/`>` used as comparison
174
+ // operators in a default value; generics in a type position are the common
175
+ // case in typed languages, so we optimize for them.
176
+ for (const char of text) {
177
+ if ("([{<".includes(char))
178
+ depth++;
179
+ else if (")]}>".includes(char))
180
+ depth = Math.max(0, depth - 1);
181
+ if (char === separator && depth === 0) {
182
+ parts.push(current);
183
+ current = "";
184
+ }
185
+ else
186
+ current += char;
187
+ }
188
+ parts.push(current);
189
+ return parts;
190
+ }
191
+ /** Leading identifier of a trimmed line, or "" if it doesn't start with one. */
192
+ function leadingIdentifier(trimmedLine) {
193
+ const match = trimmedLine.match(/^([A-Za-z_$][\w$]*)/);
194
+ return match ? match[1] : "";
195
+ }
196
+ /** True if the (possibly multi-line-joined) text is a function/method header opening a `{`. */
197
+ function isBraceMethodHeader(headerText) {
198
+ const trimmed = headerText.trim();
199
+ if (!trimmed.endsWith("{"))
200
+ return false;
201
+ if (!trimmed.includes("("))
202
+ return false;
203
+ const leadingWord = leadingIdentifier(trimmed);
204
+ if (leadingWord && (CONTROL_KEYWORDS.has(leadingWord) || TYPE_KEYWORDS.has(leadingWord)))
205
+ return false;
206
+ // Must contain a completed `(...)` param group before the trailing brace.
207
+ return /\)[^()]*\{$/.test(trimmed);
208
+ }
209
+ /** True if the text starts a Python-style `def` signature. */
210
+ function isDefHeader(headerText) {
211
+ return /^\s*(async\s+)?def\s+\w+\s*\(/.test(headerText);
212
+ }
213
+ /**
214
+ * A method/function declaration on a single line, INCLUDING one-line bodies
215
+ * (`m() { return 1 }`). Global so callers can count several on one line.
216
+ * The negative lookahead keeps control-flow headers (`if (x) {`) from being
217
+ * miscounted as methods.
218
+ */
219
+ const METHOD_DECL_G = /(^|[^.\w$])(?!(?:if|for|while|switch|catch|do|else|when|try|finally|return|function)\b)[A-Za-z_$][\w$]*\s*\([^()]*\)\s*(?::[^{;]*)?\{/g;
220
+ /** Count parameters declared in the first `(...)` group of a header. */
221
+ function countParams(headerText) {
222
+ const openParen = headerText.indexOf("(");
223
+ if (openParen === -1)
224
+ return 0;
225
+ let depth = 0;
226
+ let closeParen = -1;
227
+ for (let index = openParen; index < headerText.length; index++) {
228
+ const char = headerText[index];
229
+ if (char === "(")
230
+ depth++;
231
+ else if (char === ")") {
232
+ depth--;
233
+ if (depth === 0) {
234
+ closeParen = index;
235
+ break;
236
+ }
237
+ }
238
+ }
239
+ if (closeParen === -1)
240
+ return 0;
241
+ const paramList = headerText.slice(openParen + 1, closeParen).trim();
242
+ if (!paramList)
243
+ return 0;
244
+ return topLevelSplit(paramList, ",")
245
+ .map((param) => param.trim())
246
+ .filter(Boolean)
247
+ .filter((param) => param !== "this" && param !== "self" && !/^self\b/.test(param)).length;
248
+ }
249
+ /** Net paren delta of a line ('(' minus ')'). */
250
+ function parenBalance(text) {
251
+ let balance = 0;
252
+ for (const char of text) {
253
+ if (char === "(")
254
+ balance++;
255
+ else if (char === ")")
256
+ balance--;
257
+ }
258
+ return balance;
259
+ }
260
+ function logicalLines(sanitized) {
261
+ const result = [];
262
+ let lineIndex = 0;
263
+ while (lineIndex < sanitized.length) {
264
+ const startLine = lineIndex;
265
+ let text = sanitized[lineIndex];
266
+ let balance = parenBalance(text);
267
+ // Merge following lines while parens are left open (cap the reach).
268
+ let mergedCount = 0;
269
+ while (balance > 0 && lineIndex + 1 < sanitized.length && mergedCount < 50) {
270
+ lineIndex++;
271
+ mergedCount++;
272
+ text += " " + sanitized[lineIndex];
273
+ balance += parenBalance(sanitized[lineIndex]);
274
+ }
275
+ result.push({ text, startLine, endLine: lineIndex });
276
+ lineIndex++;
277
+ }
278
+ return result;
279
+ }
280
+ /**
281
+ * Find the 1-based end line of a brace block that opens on `startLine`
282
+ * (0-based) in the sanitized lines, or -1 if unbalanced.
283
+ */
284
+ function braceBlockEnd(sanitized, startLine) {
285
+ let depth = 0;
286
+ let seenBrace = false;
287
+ for (let lineIndex = startLine; lineIndex < sanitized.length; lineIndex++) {
288
+ for (const char of sanitized[lineIndex]) {
289
+ if (char === "{") {
290
+ depth++;
291
+ seenBrace = true;
292
+ }
293
+ else if (char === "}") {
294
+ depth--;
295
+ if (seenBrace && depth === 0)
296
+ return lineIndex + 1;
297
+ }
298
+ }
299
+ }
300
+ return -1;
301
+ }
302
+ /** Detect over-long functions/methods (brace-style + Python `def`). */
303
+ function detectLongMethods(sanitized, options) {
304
+ const findings = [];
305
+ for (let lineIndex = 0; lineIndex < sanitized.length; lineIndex++) {
306
+ const line = sanitized[lineIndex];
307
+ if (isBraceMethodHeader(line)) {
308
+ const endLine = braceBlockEnd(sanitized, lineIndex);
309
+ if (endLine === -1)
310
+ continue;
311
+ // Body = lines strictly between the header (lineIndex+1, 1-based) and the
312
+ // closing brace line (endLine): count = endLine - (lineIndex+1) - 1.
313
+ const bodyLength = Math.max(0, endLine - lineIndex - 2);
314
+ if (bodyLength > options.longMethod) {
315
+ findings.push({
316
+ id: "long-method",
317
+ title: "Long method",
318
+ severity: bodyLength > options.longMethod * 2 ? "high" : "medium",
319
+ line: lineIndex + 1,
320
+ detail: `Function/method body spans ${bodyLength} lines (threshold ${options.longMethod}).`,
321
+ principle: "single-responsibility",
322
+ suggestion: "Extract cohesive chunks into well-named helper methods; a function " +
323
+ "that does one thing rarely needs this much room.",
324
+ });
325
+ }
326
+ lineIndex = endLine - 1; // skip past the body so nested blocks aren't re-reported
327
+ }
328
+ else if (isDefHeader(line)) {
329
+ const headerIndent = line.match(/^(\s*)/)[1].length;
330
+ let endLine = lineIndex;
331
+ for (let scanLine = lineIndex + 1; scanLine < sanitized.length; scanLine++) {
332
+ if (sanitized[scanLine].trim() === "")
333
+ continue;
334
+ const scanIndent = sanitized[scanLine].match(/^(\s*)/)[1].length;
335
+ if (scanIndent <= headerIndent)
336
+ break;
337
+ endLine = scanLine;
338
+ }
339
+ const bodyLength = endLine - lineIndex; // body lines (excludes the `def` header line)
340
+ if (bodyLength > options.longMethod) {
341
+ findings.push({
342
+ id: "long-method",
343
+ title: "Long function",
344
+ severity: bodyLength > options.longMethod * 2 ? "high" : "medium",
345
+ line: lineIndex + 1,
346
+ detail: `Function body spans ${bodyLength} lines (threshold ${options.longMethod}).`,
347
+ principle: "single-responsibility",
348
+ suggestion: "Split the function along its distinct steps into helpers.",
349
+ });
350
+ }
351
+ }
352
+ }
353
+ return findings;
354
+ }
355
+ /** Detect signatures with too many parameters (handles multi-line signatures). */
356
+ function detectTooManyParams(sanitized, options) {
357
+ const findings = [];
358
+ for (const { text, startLine } of logicalLines(sanitized)) {
359
+ if (!isBraceMethodHeader(text) && !isDefHeader(text))
360
+ continue;
361
+ const paramCount = countParams(text);
362
+ if (paramCount > options.maxParams) {
363
+ findings.push({
364
+ id: "too-many-params",
365
+ title: "Long parameter list",
366
+ severity: paramCount > options.maxParams + 2 ? "high" : "medium",
367
+ line: startLine + 1,
368
+ detail: `Signature takes ${paramCount} parameters (threshold ${options.maxParams}).`,
369
+ principle: "single-responsibility",
370
+ suggestion: "Group related arguments into a parameter object / options struct, or " +
371
+ "split the function — a long list often signals it does too much.",
372
+ });
373
+ }
374
+ }
375
+ return findings;
376
+ }
377
+ /**
378
+ * Detect deep CONTROL-FLOW nesting. Braces that open a type body (class/…) or
379
+ * a method/function body are NOT counted — only `if/for/while/switch/catch/…`
380
+ * blocks add to the depth. This measures the nesting a reader must hold in
381
+ * their head, without penalizing ordinary class → method structure.
382
+ */
383
+ function detectDeepNesting(sanitized, options) {
384
+ // Classify a `{` by the clause that precedes it on its logical statement.
385
+ const controlClause = /(^|[^.\w$])(?:else\s+if|if|for|foreach|while|switch|catch|do|try|finally|when|else)\s*(\(|\{|$)/;
386
+ const braceStack = []; // true = this brace opened a control block
387
+ let controlDepth = 0;
388
+ let maxControlDepth = 0;
389
+ let deepestLine = 0;
390
+ let clause = "";
391
+ for (let lineIndex = 0; lineIndex < sanitized.length; lineIndex++) {
392
+ for (const char of sanitized[lineIndex]) {
393
+ if (char === "{") {
394
+ const opensControlBlock = controlClause.test(clause);
395
+ braceStack.push(opensControlBlock);
396
+ if (opensControlBlock) {
397
+ controlDepth++;
398
+ if (controlDepth > maxControlDepth) {
399
+ maxControlDepth = controlDepth;
400
+ deepestLine = lineIndex + 1;
401
+ }
402
+ }
403
+ clause = "";
404
+ }
405
+ else if (char === "}") {
406
+ if (braceStack.pop())
407
+ controlDepth = Math.max(0, controlDepth - 1);
408
+ clause = "";
409
+ }
410
+ else if (char === ";") {
411
+ clause = "";
412
+ }
413
+ else {
414
+ clause += char;
415
+ }
416
+ }
417
+ clause += " "; // preserve a word boundary across the newline
418
+ }
419
+ if (maxControlDepth > options.maxDepth) {
420
+ return [
421
+ {
422
+ id: "deep-nesting",
423
+ title: "Deep nesting",
424
+ severity: maxControlDepth > options.maxDepth + 2 ? "high" : "medium",
425
+ line: deepestLine,
426
+ detail: `Control-flow nesting reaches depth ${maxControlDepth} (threshold ${options.maxDepth}).`,
427
+ principle: "kiss",
428
+ suggestion: "Flatten with guard clauses / early returns, or extract the inner block " +
429
+ "into its own function. Deep nesting hides the happy path.",
430
+ },
431
+ ];
432
+ }
433
+ return [];
434
+ }
435
+ /** Detect large classes by method count (one-line and multi-line methods). */
436
+ function detectLargeClass(sanitized, options) {
437
+ const findings = [];
438
+ for (let lineIndex = 0; lineIndex < sanitized.length; lineIndex++) {
439
+ const trimmed = sanitized[lineIndex].trim();
440
+ const leadingWord = leadingIdentifier(trimmed);
441
+ if (!TYPE_KEYWORDS.has(leadingWord) || !trimmed.endsWith("{"))
442
+ continue;
443
+ const endLine = braceBlockEnd(sanitized, lineIndex);
444
+ if (endLine === -1)
445
+ continue;
446
+ let methodCount = 0;
447
+ for (let bodyLine = lineIndex + 1; bodyLine < endLine - 1; bodyLine++) {
448
+ METHOD_DECL_G.lastIndex = 0;
449
+ const matches = sanitized[bodyLine].match(METHOD_DECL_G);
450
+ if (matches)
451
+ methodCount += matches.length;
452
+ }
453
+ if (methodCount > options.largeClassMethods) {
454
+ findings.push({
455
+ id: "large-class",
456
+ title: "Large class",
457
+ severity: methodCount > options.largeClassMethods * 2 ? "high" : "medium",
458
+ line: lineIndex + 1,
459
+ detail: `Type declares ${methodCount} methods (threshold ${options.largeClassMethods}).`,
460
+ principle: "single-responsibility",
461
+ suggestion: "Split responsibilities into collaborating classes; a class with many " +
462
+ "methods usually serves more than one actor.",
463
+ });
464
+ }
465
+ lineIndex = endLine - 1;
466
+ }
467
+ return findings;
468
+ }
469
+ /** Detect duplicated non-trivial lines (a DRY proxy). */
470
+ function detectDuplication(sanitized, options) {
471
+ const lineCounts = new Map();
472
+ for (let lineIndex = 0; lineIndex < sanitized.length; lineIndex++) {
473
+ const trimmed = sanitized[lineIndex].trim();
474
+ if (trimmed.length < 15)
475
+ continue; // too short to be meaningful
476
+ if (/^[{}()\[\];,]+$/.test(trimmed))
477
+ continue; // pure punctuation
478
+ if (/^(import|from|export|package|using|#include|@)/.test(trimmed))
479
+ continue;
480
+ const record = lineCounts.get(trimmed) ?? { count: 0, firstLine: lineIndex + 1 };
481
+ record.count++;
482
+ lineCounts.set(trimmed, record);
483
+ }
484
+ const findings = [];
485
+ for (const [text, record] of lineCounts) {
486
+ if (record.count >= options.dupThreshold) {
487
+ findings.push({
488
+ id: "duplication",
489
+ title: "Duplicated logic",
490
+ severity: record.count >= options.dupThreshold + 2 ? "high" : "medium",
491
+ line: record.firstLine,
492
+ detail: `Line repeated ${record.count}× (threshold ${options.dupThreshold}): \`${text.slice(0, 60)}\``,
493
+ principle: "dry",
494
+ suggestion: "If these copies encode the same decision, extract them into one " +
495
+ "named function/constant. (If they only look alike, leave them.)",
496
+ });
497
+ }
498
+ }
499
+ return findings.sort((left, right) => (left.line ?? 0) - (right.line ?? 0));
500
+ }
501
+ /** Detect a very large file (a coarse separation-of-concerns proxy). */
502
+ function detectLargeFile(lineCount, options) {
503
+ if (lineCount <= options.maxFileLines)
504
+ return [];
505
+ return [
506
+ {
507
+ id: "large-file",
508
+ title: "Large file",
509
+ severity: lineCount > options.maxFileLines * 2 ? "high" : "low",
510
+ detail: `File has ${lineCount} lines (threshold ${options.maxFileLines}).`,
511
+ principle: "separation-of-concerns",
512
+ suggestion: "Consider splitting unrelated concerns into separate modules so each " +
513
+ "file has a single reason to change.",
514
+ },
515
+ ];
516
+ }
517
+ /**
518
+ * Run every heuristic over a snippet and return the findings, sorted by line.
519
+ * `code` is treated as a single file's worth of source. Non-string input
520
+ * yields no findings (the caller's schema should already enforce a string).
521
+ * Input longer than {@link MAX_CHARS} is truncated with a `large-file` note.
522
+ */
523
+ export function detectSmells(code, options) {
524
+ if (typeof code !== "string")
525
+ return [];
526
+ const config = { ...DEFAULTS, ...(options ?? {}) };
527
+ let truncated = false;
528
+ let source = code;
529
+ if (source.length > MAX_CHARS) {
530
+ source = source.slice(0, MAX_CHARS);
531
+ truncated = true;
532
+ }
533
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
534
+ const sanitized = sanitize(lines);
535
+ const findings = [
536
+ ...detectLargeFile(lines.length, config),
537
+ ...detectLongMethods(sanitized, config),
538
+ ...detectTooManyParams(sanitized, config),
539
+ ...detectDeepNesting(sanitized, config),
540
+ ...detectLargeClass(sanitized, config),
541
+ ...detectDuplication(sanitized, config),
542
+ ];
543
+ if (truncated) {
544
+ findings.unshift({
545
+ id: "input-truncated",
546
+ title: "Input truncated",
547
+ severity: "low",
548
+ detail: `Input exceeded ${MAX_CHARS} chars and was truncated before analysis.`,
549
+ principle: "separation-of-concerns",
550
+ suggestion: "Analyze one file at a time for complete results.",
551
+ });
552
+ }
553
+ return findings.sort((left, right) => (left.line ?? 0) - (right.line ?? 0) || left.id.localeCompare(right.id));
554
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "mcp-software-design",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that teaches and applies software-design guidance: SOLID/OOP/DRY principles, the 23 GoF design patterns, pattern scaffolding, and heuristic code-smell detection.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Muzaffar Qosimov <qwertymuzaffar@gmail.com>",
8
+ "mcpName": "io.github.qwertymuzaffar/software-design",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/qwertymuzaffar/mcp-software-design.git"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "modelcontextprotocol",
16
+ "design-patterns",
17
+ "solid",
18
+ "oop",
19
+ "gof",
20
+ "refactoring",
21
+ "claude"
22
+ ],
23
+ "bin": {
24
+ "mcp-software-design": "build/index.js"
25
+ },
26
+ "files": [
27
+ "build",
28
+ "README.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "scripts": {
34
+ "build": "rm -rf build && tsc && chmod 755 build/index.js",
35
+ "start": "node build/index.js",
36
+ "pretest": "npm run build",
37
+ "test": "node --test test/*.test.mjs",
38
+ "prepublishOnly": "npm test",
39
+ "test:client": "node test-client.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.30.0",
43
+ "zod": "^3.25.76"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.10.0",
47
+ "typescript": "^5.7.2"
48
+ }
49
+ }