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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/build/catalog.js +978 -0
- package/build/index.js +297 -0
- package/build/scaffold.js +78 -0
- package/build/smells.js +554 -0
- package/package.json +49 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ALL, CATEGORY_LABEL, conceptLine, conceptToMarkdown, findConcept, principlesMarkdown, patternsMarkdown, } from "./catalog.js";
|
|
6
|
+
import { detectSmells, DEFAULTS } from "./smells.js";
|
|
7
|
+
import { scaffoldPattern } from "./scaffold.js";
|
|
8
|
+
const VERSION = "0.1.0";
|
|
9
|
+
/** Human-readable summary of what the smell checker looks for. */
|
|
10
|
+
const SMELLS_GUIDE = `# Code-smell heuristics (design://smells)
|
|
11
|
+
|
|
12
|
+
These detectors are **heuristics, not proofs**. A finding means a principle
|
|
13
|
+
*might* be strained — it is never a verdict, and a clean run does not certify
|
|
14
|
+
good design. Detection is language-agnostic and best-effort (it reasons about
|
|
15
|
+
braces, parentheses, and indentation, not a real parser).
|
|
16
|
+
|
|
17
|
+
| Smell | Default trigger | Hints at |
|
|
18
|
+
|-------|-----------------|----------|
|
|
19
|
+
| long-method | body > ${DEFAULTS.longMethod} lines | single-responsibility |
|
|
20
|
+
| large-class | > ${DEFAULTS.largeClassMethods} methods | single-responsibility |
|
|
21
|
+
| too-many-params | > ${DEFAULTS.maxParams} parameters | single-responsibility |
|
|
22
|
+
| deep-nesting | nesting depth > ${DEFAULTS.maxDepth} | kiss |
|
|
23
|
+
| duplication | identical line ≥ ${DEFAULTS.dupThreshold}× | dry |
|
|
24
|
+
| large-file | > ${DEFAULTS.maxFileLines} lines | separation-of-concerns |
|
|
25
|
+
|
|
26
|
+
All thresholds are overridable per call on the \`check_smells\` tool.
|
|
27
|
+
`;
|
|
28
|
+
/** Render smells as a compact text report. */
|
|
29
|
+
function renderSmells(smells) {
|
|
30
|
+
if (smells.length === 0) {
|
|
31
|
+
return "✅ No heuristic smells tripped. (Heuristics only — not a proof of good design.)";
|
|
32
|
+
}
|
|
33
|
+
const severityIcon = { high: "🔴", medium: "🟡", low: "⚪" };
|
|
34
|
+
const renderedFindings = smells
|
|
35
|
+
.map((smell) => {
|
|
36
|
+
const location = smell.line ? ` (line ${smell.line})` : "";
|
|
37
|
+
return (`${severityIcon[smell.severity]} ${smell.title}${location} — ${smell.detail}\n` +
|
|
38
|
+
` ↳ hints at ${smell.principle}: ${smell.suggestion}`);
|
|
39
|
+
})
|
|
40
|
+
.join("\n\n");
|
|
41
|
+
return `${smells.length} heuristic smell(s) found (hints, not verdicts):\n\n${renderedFindings}`;
|
|
42
|
+
}
|
|
43
|
+
export function createServer() {
|
|
44
|
+
const server = new McpServer({ name: "software-design", version: VERSION });
|
|
45
|
+
/* ---------------------------- Resources ---------------------------- */
|
|
46
|
+
server.registerResource("principles", "design://principles", {
|
|
47
|
+
title: "Software-Design Principles",
|
|
48
|
+
description: "SOLID, the OOP pillars, DRY, KISS, YAGNI, and related heuristics.",
|
|
49
|
+
mimeType: "text/markdown",
|
|
50
|
+
}, async (uri) => ({ contents: [{ uri: uri.href, text: principlesMarkdown() }] }));
|
|
51
|
+
server.registerResource("patterns", "design://patterns", {
|
|
52
|
+
title: "Gang-of-Four Design Patterns",
|
|
53
|
+
description: "The 23 GoF patterns, grouped creational / structural / behavioral.",
|
|
54
|
+
mimeType: "text/markdown",
|
|
55
|
+
}, async (uri) => ({ contents: [{ uri: uri.href, text: patternsMarkdown() }] }));
|
|
56
|
+
server.registerResource("smells", "design://smells", {
|
|
57
|
+
title: "Code-Smell Heuristics",
|
|
58
|
+
description: "What the check_smells tool detects, its thresholds, and caveats.",
|
|
59
|
+
mimeType: "text/markdown",
|
|
60
|
+
}, async (uri) => ({ contents: [{ uri: uri.href, text: SMELLS_GUIDE }] }));
|
|
61
|
+
/* ------------------------------ Tools ------------------------------ */
|
|
62
|
+
server.registerTool("list_catalog", {
|
|
63
|
+
title: "List design principles & patterns",
|
|
64
|
+
description: "List the catalog of software-design concepts, optionally filtered by " +
|
|
65
|
+
"kind. Returns each concept's slug, name, category, and one-line summary.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
kind: z
|
|
68
|
+
.enum(["all", "principle", "pattern", "creational", "structural", "behavioral"])
|
|
69
|
+
.optional()
|
|
70
|
+
.describe('Filter by kind. "pattern" = all GoF patterns. Default "all".'),
|
|
71
|
+
},
|
|
72
|
+
outputSchema: {
|
|
73
|
+
count: z.number(),
|
|
74
|
+
concepts: z.array(z.object({
|
|
75
|
+
slug: z.string(),
|
|
76
|
+
name: z.string(),
|
|
77
|
+
category: z.string(),
|
|
78
|
+
summary: z.string(),
|
|
79
|
+
})),
|
|
80
|
+
},
|
|
81
|
+
}, async ({ kind }) => {
|
|
82
|
+
const selectedKind = kind ?? "all";
|
|
83
|
+
const matchesKind = (concept) => {
|
|
84
|
+
if (selectedKind === "all")
|
|
85
|
+
return true;
|
|
86
|
+
if (selectedKind === "principle")
|
|
87
|
+
return concept.category === "principle";
|
|
88
|
+
if (selectedKind === "pattern")
|
|
89
|
+
return concept.category !== "principle";
|
|
90
|
+
return concept.category === selectedKind;
|
|
91
|
+
};
|
|
92
|
+
const matched = ALL.filter(matchesKind);
|
|
93
|
+
const concepts = matched.map((concept) => ({
|
|
94
|
+
slug: concept.slug,
|
|
95
|
+
name: concept.name,
|
|
96
|
+
category: CATEGORY_LABEL[concept.category],
|
|
97
|
+
summary: concept.summary,
|
|
98
|
+
}));
|
|
99
|
+
const text = `${concepts.length} concept(s):\n` +
|
|
100
|
+
matched.map((concept) => `- ${conceptLine(concept)}`).join("\n");
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text }],
|
|
103
|
+
structuredContent: { count: concepts.length, concepts },
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
server.registerTool("explain_concept", {
|
|
107
|
+
title: "Explain a principle or pattern",
|
|
108
|
+
description: "Return an authoritative explanation of one design principle or GoF " +
|
|
109
|
+
"pattern: intent, when to use it, trade-offs, participants, and related " +
|
|
110
|
+
"concepts. Accepts a slug, full name, or alias (e.g. \"SRP\", \"factory\").",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
name: z.string().describe('Concept to explain, e.g. "open-closed", "SRP", "observer".'),
|
|
113
|
+
},
|
|
114
|
+
outputSchema: {
|
|
115
|
+
found: z.boolean(),
|
|
116
|
+
slug: z.string().optional(),
|
|
117
|
+
name: z.string().optional(),
|
|
118
|
+
category: z.string().optional(),
|
|
119
|
+
summary: z.string().optional(),
|
|
120
|
+
markdown: z.string().optional(),
|
|
121
|
+
},
|
|
122
|
+
}, async ({ name }) => {
|
|
123
|
+
const concept = findConcept(name);
|
|
124
|
+
if (!concept) {
|
|
125
|
+
const text = `❓ No concept matches "${name}". Use list_catalog to see valid slugs.`;
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text", text }],
|
|
128
|
+
structuredContent: { found: false },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const markdown = conceptToMarkdown(concept);
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: "text", text: markdown }],
|
|
134
|
+
structuredContent: {
|
|
135
|
+
found: true,
|
|
136
|
+
slug: concept.slug,
|
|
137
|
+
name: concept.name,
|
|
138
|
+
category: CATEGORY_LABEL[concept.category],
|
|
139
|
+
summary: concept.summary,
|
|
140
|
+
markdown,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
server.registerTool("scaffold_pattern", {
|
|
145
|
+
title: "Scaffold a GoF pattern",
|
|
146
|
+
description: "Generate a language-agnostic pseudo-code skeleton for a GoF pattern, " +
|
|
147
|
+
"showing its participants and how they collaborate. Optionally rename " +
|
|
148
|
+
"roles to your domain (e.g. Product -> Notification). Translate the " +
|
|
149
|
+
"result into your target language.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
pattern: z.string().describe('Pattern slug/name/alias, e.g. "observer", "factory-method".'),
|
|
152
|
+
names: z
|
|
153
|
+
.record(z.string())
|
|
154
|
+
.optional()
|
|
155
|
+
.describe('Optional role→name map, e.g. {"Product":"Notification","Creator":"Dispatcher"}.'),
|
|
156
|
+
},
|
|
157
|
+
outputSchema: {
|
|
158
|
+
ok: z.boolean(),
|
|
159
|
+
slug: z.string().optional(),
|
|
160
|
+
name: z.string().optional(),
|
|
161
|
+
roles: z.array(z.string()).optional(),
|
|
162
|
+
code: z.string().optional(),
|
|
163
|
+
error: z.string().optional(),
|
|
164
|
+
},
|
|
165
|
+
}, async ({ pattern, names }) => {
|
|
166
|
+
try {
|
|
167
|
+
const result = scaffoldPattern(pattern, names ?? {});
|
|
168
|
+
const rolesNote = `\n\n// Roles you can rename: ${result.roles.join(", ")}`;
|
|
169
|
+
return {
|
|
170
|
+
content: [{ type: "text", text: result.code + rolesNote }],
|
|
171
|
+
structuredContent: {
|
|
172
|
+
ok: true,
|
|
173
|
+
slug: result.slug,
|
|
174
|
+
name: result.name,
|
|
175
|
+
roles: result.roles,
|
|
176
|
+
code: result.code,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch (caught) {
|
|
181
|
+
const error = caught instanceof Error ? caught.message : String(caught);
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: `❌ ${error}` }],
|
|
184
|
+
structuredContent: { ok: false, error },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
server.registerTool("check_smells", {
|
|
189
|
+
title: "Detect code smells (heuristic)",
|
|
190
|
+
description: "Scan a code snippet for heuristic design smells (long method, large " +
|
|
191
|
+
"class, long parameter list, deep nesting, duplication, large file). " +
|
|
192
|
+
"Each finding maps to the principle it hints at plus a suggested " +
|
|
193
|
+
"refactor. HEURISTICS ONLY — hints, never verdicts; a clean run does " +
|
|
194
|
+
"not certify good design.",
|
|
195
|
+
inputSchema: {
|
|
196
|
+
code: z.string().describe("The source snippet to analyze (one file's worth)."),
|
|
197
|
+
longMethod: z.number().int().positive().optional().describe(`Max method lines (default ${DEFAULTS.longMethod}).`),
|
|
198
|
+
maxParams: z.number().int().positive().optional().describe(`Max parameters (default ${DEFAULTS.maxParams}).`),
|
|
199
|
+
maxDepth: z.number().int().positive().optional().describe(`Max nesting depth (default ${DEFAULTS.maxDepth}).`),
|
|
200
|
+
maxFileLines: z.number().int().positive().optional().describe(`Max file lines (default ${DEFAULTS.maxFileLines}).`),
|
|
201
|
+
largeClassMethods: z.number().int().positive().optional().describe(`Max methods per class (default ${DEFAULTS.largeClassMethods}).`),
|
|
202
|
+
dupThreshold: z.number().int().positive().optional().describe(`Duplicate-line count (default ${DEFAULTS.dupThreshold}).`),
|
|
203
|
+
},
|
|
204
|
+
outputSchema: {
|
|
205
|
+
count: z.number(),
|
|
206
|
+
smells: z.array(z.object({
|
|
207
|
+
id: z.string(),
|
|
208
|
+
title: z.string(),
|
|
209
|
+
severity: z.string(),
|
|
210
|
+
line: z.number().optional(),
|
|
211
|
+
detail: z.string(),
|
|
212
|
+
principle: z.string(),
|
|
213
|
+
suggestion: z.string(),
|
|
214
|
+
})),
|
|
215
|
+
},
|
|
216
|
+
}, async ({ code, ...thresholds }) => {
|
|
217
|
+
const smells = detectSmells(code, thresholds);
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text", text: renderSmells(smells) }],
|
|
220
|
+
structuredContent: { count: smells.length, smells },
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
/* ----------------------------- Prompts ----------------------------- *
|
|
224
|
+
* The "explain / apply" helper. Design analysis is a judgment call, so it
|
|
225
|
+
* belongs to the client's model, not deterministic server code. These
|
|
226
|
+
* prompts prime that model with the right task, and point it at the tools
|
|
227
|
+
* and resources above to ground its answer.
|
|
228
|
+
* ------------------------------------------------------------------ */
|
|
229
|
+
server.registerPrompt("review_design", {
|
|
230
|
+
title: "Review a snippet against design principles",
|
|
231
|
+
description: "Prime the model to review a code snippet against SOLID/OOP/DRY and the " +
|
|
232
|
+
"GoF patterns, grounded in this server's catalog and check_smells output. " +
|
|
233
|
+
"Assumes the client lets its model call this server's tools; where it " +
|
|
234
|
+
"doesn't, the model reviews from the inlined code alone.",
|
|
235
|
+
argsSchema: {
|
|
236
|
+
code: z.string().describe("The code to review."),
|
|
237
|
+
focus: z
|
|
238
|
+
.string()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe('Optional focus, e.g. "SOLID", "coupling", "a specific pattern".'),
|
|
241
|
+
},
|
|
242
|
+
}, ({ code, focus }) => ({
|
|
243
|
+
messages: [
|
|
244
|
+
{
|
|
245
|
+
role: "user",
|
|
246
|
+
content: {
|
|
247
|
+
type: "text",
|
|
248
|
+
text: "Review the code below for software-design quality" +
|
|
249
|
+
(focus ? ` with a focus on: ${focus}.` : ".") +
|
|
250
|
+
"\n\nGround your review in this server's catalog:\n" +
|
|
251
|
+
"1. Run the `check_smells` tool on the code and treat its output as HINTS, not verdicts.\n" +
|
|
252
|
+
"2. Read the `design://principles` and `design://patterns` resources for the concepts you cite.\n" +
|
|
253
|
+
"3. For each issue: name the principle at stake, explain why it applies HERE (not in the abstract), " +
|
|
254
|
+
"and give a concrete, minimal refactor. If a GoF pattern would help, name it and say why; " +
|
|
255
|
+
"if the code is already fine, say so plainly — do not invent problems.\n\n" +
|
|
256
|
+
"```\n" + code + "\n```",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
}));
|
|
261
|
+
server.registerPrompt("apply_pattern", {
|
|
262
|
+
title: "Refactor a snippet to apply a pattern",
|
|
263
|
+
description: "Prime the model to refactor a snippet to apply a named GoF pattern, " +
|
|
264
|
+
"using the server's scaffold_pattern + explain_concept as reference. " +
|
|
265
|
+
"Assumes the client lets its model call this server's tools; where it " +
|
|
266
|
+
"doesn't, the model works from the inlined code and pattern name alone.",
|
|
267
|
+
argsSchema: {
|
|
268
|
+
pattern: z.string().describe('The pattern to apply, e.g. "strategy", "observer".'),
|
|
269
|
+
code: z.string().describe("The code to refactor."),
|
|
270
|
+
},
|
|
271
|
+
}, ({ pattern, code }) => ({
|
|
272
|
+
messages: [
|
|
273
|
+
{
|
|
274
|
+
role: "user",
|
|
275
|
+
content: {
|
|
276
|
+
type: "text",
|
|
277
|
+
text: `Refactor the code below to apply the ${pattern} pattern.\n\n` +
|
|
278
|
+
`1. Call \`explain_concept\` with "${pattern}" and \`scaffold_pattern\` with "${pattern}" for the canonical structure.\n` +
|
|
279
|
+
"2. First judge whether the pattern genuinely fits this code. If it would be over-engineering " +
|
|
280
|
+
"(see the YAGNI/KISS principles), say so and stop — do not force it.\n" +
|
|
281
|
+
"3. If it fits, map the pattern's participants onto the domain here and produce the refactored code, " +
|
|
282
|
+
"preserving existing behavior. Briefly note what improved and any trade-off introduced.\n\n" +
|
|
283
|
+
"```\n" + code + "\n```",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}));
|
|
288
|
+
return server;
|
|
289
|
+
}
|
|
290
|
+
async function main() {
|
|
291
|
+
const server = createServer();
|
|
292
|
+
await server.connect(new StdioServerTransport());
|
|
293
|
+
}
|
|
294
|
+
main().catch((err) => {
|
|
295
|
+
console.error(err);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic pattern scaffolding. Renders a GoF pattern's participants
|
|
3
|
+
* (declared in catalog.ts) into a pseudo-code skeleton the model can then
|
|
4
|
+
* translate into the target language.
|
|
5
|
+
*
|
|
6
|
+
* Pure and side-effect-free so it can be unit-tested in isolation. The output
|
|
7
|
+
* is deliberately pseudo-code, not any real language: it names the roles,
|
|
8
|
+
* their kind (interface / abstract / class), members, and how they collaborate
|
|
9
|
+
* — the structural decisions — and leaves syntax to the caller.
|
|
10
|
+
*/
|
|
11
|
+
import { findConcept } from "./catalog.js";
|
|
12
|
+
const KIND_KEYWORD = {
|
|
13
|
+
interface: "interface",
|
|
14
|
+
abstract: "abstract class",
|
|
15
|
+
class: "class",
|
|
16
|
+
};
|
|
17
|
+
/** Apply role→name substitutions to a member signature (whole-word only). */
|
|
18
|
+
function applyRenames(text, renamed) {
|
|
19
|
+
let result = text;
|
|
20
|
+
for (const [role, name] of Object.entries(renamed)) {
|
|
21
|
+
// A function replacer, so `$`-sequences in the user-supplied name (`$&`,
|
|
22
|
+
// `$1`, …) are inserted literally rather than interpreted as replacement
|
|
23
|
+
// patterns.
|
|
24
|
+
result = result.replace(new RegExp(`\\b${role}\\b`, "g"), () => name);
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
/** Render one participant as a pseudo-code declaration block. */
|
|
29
|
+
function renderParticipant(participant, renamed) {
|
|
30
|
+
const roleName = renamed[participant.role] ?? participant.role;
|
|
31
|
+
const lines = [];
|
|
32
|
+
if (participant.note)
|
|
33
|
+
lines.push(`// ${applyRenames(participant.note, renamed)}`);
|
|
34
|
+
const header = `${KIND_KEYWORD[participant.kind]} ${roleName} {`;
|
|
35
|
+
lines.push(applyRenames(header, renamed));
|
|
36
|
+
for (const member of participant.members ?? []) {
|
|
37
|
+
lines.push(` ${applyRenames(member, renamed)}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push("}");
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a scaffold for the pattern identified by `query` (slug / name /
|
|
44
|
+
* alias). `names` optionally maps a role (e.g. "Product") to a concrete name
|
|
45
|
+
* (e.g. "Notification"). Throws if the query doesn't resolve to a *pattern*
|
|
46
|
+
* with participants.
|
|
47
|
+
*/
|
|
48
|
+
export function scaffoldPattern(query, names = {}) {
|
|
49
|
+
const concept = findConcept(query);
|
|
50
|
+
if (!concept) {
|
|
51
|
+
throw new Error(`No design concept matches "${query}". Try a pattern slug like "observer" or "factory-method".`);
|
|
52
|
+
}
|
|
53
|
+
if (concept.category === "principle" || !concept.participants?.length) {
|
|
54
|
+
throw new Error(`"${concept.name}" is a ${concept.category === "principle" ? "principle" : "concept"}, ` +
|
|
55
|
+
`not a scaffoldable pattern. Use explain_concept for guidance instead.`);
|
|
56
|
+
}
|
|
57
|
+
const roles = concept.participants.map((participant) => participant.role);
|
|
58
|
+
// Keep only rename keys that actually name a role in this pattern.
|
|
59
|
+
const renamed = {};
|
|
60
|
+
for (const [role, name] of Object.entries(names)) {
|
|
61
|
+
if (roles.includes(role) && name.trim())
|
|
62
|
+
renamed[role] = name.trim();
|
|
63
|
+
}
|
|
64
|
+
const body = concept.participants
|
|
65
|
+
.map((participant) => renderParticipant(participant, renamed))
|
|
66
|
+
.join("\n\n");
|
|
67
|
+
const header = `// ${concept.name} — pseudo-code skeleton (language-agnostic).\n` +
|
|
68
|
+
`// Intent: ${concept.summary}\n` +
|
|
69
|
+
(concept.collaboration ? `// Collaboration: ${concept.collaboration}\n` : "") +
|
|
70
|
+
`// Translate the roles below into your target language.\n`;
|
|
71
|
+
return {
|
|
72
|
+
slug: concept.slug,
|
|
73
|
+
name: concept.name,
|
|
74
|
+
code: `${header}\n${body}\n`,
|
|
75
|
+
renamed,
|
|
76
|
+
roles,
|
|
77
|
+
};
|
|
78
|
+
}
|