@mofaggolhoshen/dev-assist-mcp 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +72 -0
- package/README.md +204 -52
- package/content/concepts/circuit-breaker.md +25 -0
- package/content/setups/jwt-dotnet9.md +18 -0
- package/content/setups/jwt.md +25 -0
- package/content/templates/clean-architecture.md +25 -0
- package/dist/content/snippetSchema.js +39 -0
- package/dist/index.js +8 -0
- package/dist/schemas/markdownKnowledge.js +30 -0
- package/dist/shared/response.js +12 -0
- package/dist/storage/markdownKnowledgeStore.js +267 -0
- package/dist/tools/knowledge/explainConcept.js +46 -0
- package/dist/tools/knowledge/generateSetup.js +46 -0
- package/dist/tools/knowledge/getSnippet.js +52 -39
- package/dist/tools/knowledge/getTemplate.js +46 -0
- package/dist/tools/knowledge/index.js +4 -0
- package/dist/tools/knowledge/searchSnippet.js +56 -0
- package/examples/basic-search/README.md +13 -0
- package/examples/claude-desktop/README.md +10 -0
- package/examples/claude-desktop/claude_desktop_config.json +8 -0
- package/examples/cursor/README.md +15 -0
- package/examples/explain-mode/expected-response.md +16 -0
- package/examples/jwt-setup/expected-response.json +16 -0
- package/examples/polly-retry/expected-response.json +14 -0
- package/package.json +17 -2
- package/scripts/.gitkeep +0 -0
- package/snippets/architecture/.gitkeep +0 -0
- package/snippets/architecture/clean-architecture-api.md +25 -0
- package/snippets/architecture/cqrs-starter.md +25 -0
- package/snippets/architecture/ddd-aggregate.md +25 -0
- package/snippets/architecture/efcore-repository.md +39 -0
- package/snippets/auth/.gitkeep +0 -0
- package/snippets/auth/jwe-setup.md +28 -0
- package/snippets/auth/jwt-setup-dotnet9.md +27 -0
- package/snippets/auth/jwt-setup.md +44 -0
- package/snippets/auth/refresh-token-rotation.md +27 -0
- package/snippets/auth/role-based-authorization.md +28 -0
- package/snippets/caching/.gitkeep +0 -0
- package/snippets/caching/cache-aside-pattern.md +25 -0
- package/snippets/caching/redis-distributed-cache.md +25 -0
- package/snippets/logging/.gitkeep +0 -0
- package/snippets/logging/opentelemetry-tracing.md +25 -0
- package/snippets/logging/serilog-bootstrap.md +25 -0
- package/snippets/logging/structured-logging-guidelines.md +27 -0
- package/snippets/messaging/.gitkeep +0 -0
- package/snippets/messaging/masstransit-rabbitmq.md +25 -0
- package/snippets/messaging/outbox-pattern.md +25 -0
- package/snippets/messaging/saga-state-machine.md +25 -0
- package/snippets/resilience/.gitkeep +0 -0
- package/snippets/resilience/bulkhead-isolation.md +25 -0
- package/snippets/resilience/circuit-breaker-polly.md +25 -0
- package/snippets/resilience/fallback-policy-polly.md +25 -0
- package/snippets/resilience/polly-retry.md +31 -0
- package/snippets/resilience/timeout-policy-polly.md +25 -0
- package/snippets/efcore-repository.json +0 -7
- package/snippets/jwt-setup.json +0 -7
- package/snippets/polly-retry.json +0 -7
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import Fuse from "fuse.js";
|
|
5
|
+
import { glob } from "glob";
|
|
6
|
+
import { resolveSafePath } from "../tools/fs/safePath.js";
|
|
7
|
+
import { markdownDocFrontmatterSchema, markdownSnippetFrontmatterSchema, } from "../schemas/markdownKnowledge.js";
|
|
8
|
+
const TOKEN_SYNONYMS = {
|
|
9
|
+
jwt: ["bearer", "token", "auth"],
|
|
10
|
+
bearer: ["jwt", "token"],
|
|
11
|
+
polly: ["resilience", "retry", "circuit-breaker"],
|
|
12
|
+
resiliency: ["resilience", "polly"],
|
|
13
|
+
efcore: ["entityframework", "entity-framework"],
|
|
14
|
+
entityframework: ["efcore", "entity-framework"],
|
|
15
|
+
redis: ["cache", "caching"],
|
|
16
|
+
masstransit: ["messaging", "bus", "saga"],
|
|
17
|
+
saga: ["state-machine", "workflow"],
|
|
18
|
+
};
|
|
19
|
+
async function readMarkdownFile(filePath) {
|
|
20
|
+
return fs.readFile(filePath, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
export async function loadSnippetDocuments() {
|
|
23
|
+
const snippetsRoot = resolveSafePath("snippets");
|
|
24
|
+
const files = await glob("**/*.md", {
|
|
25
|
+
cwd: snippetsRoot,
|
|
26
|
+
nodir: true,
|
|
27
|
+
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
28
|
+
});
|
|
29
|
+
const docs = [];
|
|
30
|
+
for (const relativePath of files) {
|
|
31
|
+
const absolutePath = path.join(snippetsRoot, relativePath);
|
|
32
|
+
const raw = await readMarkdownFile(absolutePath);
|
|
33
|
+
const parsed = matter(raw);
|
|
34
|
+
const fm = markdownSnippetFrontmatterSchema.parse(parsed.data);
|
|
35
|
+
const stat = await fs.stat(absolutePath);
|
|
36
|
+
const updatedAt = fm.updatedAt ?? stat.mtime.toISOString();
|
|
37
|
+
docs.push({
|
|
38
|
+
id: fm.id,
|
|
39
|
+
name: fm.name ?? fm.id,
|
|
40
|
+
title: fm.title,
|
|
41
|
+
summary: fm.summary,
|
|
42
|
+
updatedAt,
|
|
43
|
+
framework: fm.framework,
|
|
44
|
+
version: fm.version,
|
|
45
|
+
language: fm.language,
|
|
46
|
+
category: fm.category,
|
|
47
|
+
tags: fm.tags ?? [],
|
|
48
|
+
difficulty: fm.difficulty,
|
|
49
|
+
bestPractices: fm.bestPractices ?? [],
|
|
50
|
+
pitfalls: fm.pitfalls ?? [],
|
|
51
|
+
securityNotes: fm.securityNotes ?? [],
|
|
52
|
+
body: parsed.content.trim(),
|
|
53
|
+
path: `snippets/${relativePath.replace(/\\/g, "/")}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return docs;
|
|
57
|
+
}
|
|
58
|
+
export async function findSnippetByName(snippetName) {
|
|
59
|
+
const docs = await loadSnippetDocuments();
|
|
60
|
+
return (docs.find((d) => d.name === snippetName || d.id === snippetName) ?? null);
|
|
61
|
+
}
|
|
62
|
+
export async function searchSnippetDocuments(query) {
|
|
63
|
+
return searchSnippetDocumentsWithOptions(query, {});
|
|
64
|
+
}
|
|
65
|
+
function normalize(value) {
|
|
66
|
+
return value.trim().toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
function normalizeText(value) {
|
|
69
|
+
return normalize(value).replace(/[^a-z0-9.+-]+/g, " ").replace(/\s+/g, " ");
|
|
70
|
+
}
|
|
71
|
+
function tokenize(value) {
|
|
72
|
+
return normalizeText(value)
|
|
73
|
+
.split(" ")
|
|
74
|
+
.map((token) => token.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
function expandQuery(query) {
|
|
78
|
+
const tokens = tokenize(query);
|
|
79
|
+
const expanded = new Set(tokens);
|
|
80
|
+
for (const token of tokens) {
|
|
81
|
+
const synonyms = TOKEN_SYNONYMS[token] ?? [];
|
|
82
|
+
for (const synonym of synonyms)
|
|
83
|
+
expanded.add(synonym);
|
|
84
|
+
}
|
|
85
|
+
const expandedQuery = Array.from(expanded).join(" ");
|
|
86
|
+
return {
|
|
87
|
+
normalized: normalizeText(query),
|
|
88
|
+
expanded: expandedQuery,
|
|
89
|
+
tokens,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function extractVersionHints(query, explicitVersion) {
|
|
93
|
+
const hints = new Set();
|
|
94
|
+
if (explicitVersion)
|
|
95
|
+
hints.add(normalize(explicitVersion));
|
|
96
|
+
const normalizedQuery = normalizeText(query);
|
|
97
|
+
const patterns = [
|
|
98
|
+
/\.net\s*\d+/g,
|
|
99
|
+
/dotnet\s*\d+/g,
|
|
100
|
+
/asp\.net\s*\d+/g,
|
|
101
|
+
/net\s*\d+/g,
|
|
102
|
+
];
|
|
103
|
+
for (const pattern of patterns) {
|
|
104
|
+
const matches = normalizedQuery.match(pattern) ?? [];
|
|
105
|
+
for (const match of matches) {
|
|
106
|
+
hints.add(normalize(match).replace(/\s+/g, ""));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return Array.from(hints);
|
|
110
|
+
}
|
|
111
|
+
function matchesVersion(docVersion, requested) {
|
|
112
|
+
if (!docVersion)
|
|
113
|
+
return false;
|
|
114
|
+
const a = normalize(docVersion).replace(/\s+/g, "");
|
|
115
|
+
const b = normalize(requested).replace(/\s+/g, "");
|
|
116
|
+
return a.includes(b) || b.includes(a);
|
|
117
|
+
}
|
|
118
|
+
function computeFreshnessScore(updatedAt) {
|
|
119
|
+
if (!updatedAt)
|
|
120
|
+
return 0;
|
|
121
|
+
const date = new Date(updatedAt);
|
|
122
|
+
if (Number.isNaN(date.getTime()))
|
|
123
|
+
return 0;
|
|
124
|
+
const ageDays = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24);
|
|
125
|
+
if (ageDays <= 7)
|
|
126
|
+
return 1;
|
|
127
|
+
if (ageDays <= 30)
|
|
128
|
+
return 0.8;
|
|
129
|
+
if (ageDays <= 90)
|
|
130
|
+
return 0.6;
|
|
131
|
+
if (ageDays <= 180)
|
|
132
|
+
return 0.4;
|
|
133
|
+
if (ageDays <= 365)
|
|
134
|
+
return 0.2;
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
function toConfidence(score) {
|
|
138
|
+
if (score >= 0.75)
|
|
139
|
+
return "high";
|
|
140
|
+
if (score >= 0.5)
|
|
141
|
+
return "medium";
|
|
142
|
+
return "low";
|
|
143
|
+
}
|
|
144
|
+
export async function searchSnippetDocumentsWithOptions(query, options) {
|
|
145
|
+
const docs = await loadSnippetDocuments();
|
|
146
|
+
if (!docs.length)
|
|
147
|
+
return [];
|
|
148
|
+
const { normalized: normalizedQuery, expanded: expandedQuery, tokens } = expandQuery(query);
|
|
149
|
+
const versionHints = extractVersionHints(query, options.version);
|
|
150
|
+
const filteredDocs = docs.filter((doc) => {
|
|
151
|
+
if (options.category) {
|
|
152
|
+
if (normalize(doc.category ?? "") !== normalize(options.category))
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (options.framework) {
|
|
156
|
+
if (!normalize(doc.framework ?? "").includes(normalize(options.framework))) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (options.version) {
|
|
161
|
+
if (!matchesVersion(doc.version, options.version))
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (options.difficulty) {
|
|
165
|
+
if (doc.difficulty !== options.difficulty)
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
170
|
+
if (!filteredDocs.length)
|
|
171
|
+
return [];
|
|
172
|
+
const fuse = new Fuse(filteredDocs, {
|
|
173
|
+
includeScore: true,
|
|
174
|
+
threshold: 0.4,
|
|
175
|
+
keys: ["title", "summary", "tags", "framework", "category", "body"],
|
|
176
|
+
});
|
|
177
|
+
const results = fuse.search(expandedQuery || query, {
|
|
178
|
+
limit: Math.max(options.limit ?? 10, 50),
|
|
179
|
+
});
|
|
180
|
+
const scored = results.map((r) => {
|
|
181
|
+
const doc = r.item;
|
|
182
|
+
const fuseScore = r.score ?? 1;
|
|
183
|
+
const baseRelevance = 1 - Math.min(Math.max(fuseScore, 0), 1);
|
|
184
|
+
const reasons = [];
|
|
185
|
+
const titleNormalized = normalizeText(doc.title);
|
|
186
|
+
const exactTitleMatch = titleNormalized === normalizedQuery || titleNormalized.includes(normalizedQuery);
|
|
187
|
+
if (exactTitleMatch)
|
|
188
|
+
reasons.push("title_match");
|
|
189
|
+
const tagMatches = doc.tags.filter((tag) => tokens.some((token) => normalize(tag).includes(token))).length;
|
|
190
|
+
if (tagMatches > 0)
|
|
191
|
+
reasons.push(`tag_matches:${tagMatches}`);
|
|
192
|
+
const frameworkMatch = options.framework
|
|
193
|
+
? normalize(doc.framework ?? "").includes(normalize(options.framework))
|
|
194
|
+
: false;
|
|
195
|
+
if (frameworkMatch)
|
|
196
|
+
reasons.push("framework_match");
|
|
197
|
+
const versionMatch = versionHints.some((hint) => matchesVersion(doc.version, hint));
|
|
198
|
+
if (versionMatch)
|
|
199
|
+
reasons.push("version_precedence");
|
|
200
|
+
const freshnessScore = computeFreshnessScore(doc.updatedAt);
|
|
201
|
+
if (freshnessScore >= 0.6)
|
|
202
|
+
reasons.push("fresh_content");
|
|
203
|
+
const rankScore = baseRelevance * 0.55 +
|
|
204
|
+
(exactTitleMatch ? 1 : 0) * 0.15 +
|
|
205
|
+
Math.min(tagMatches, 3) / 3 * 0.1 +
|
|
206
|
+
(frameworkMatch ? 1 : 0) * 0.08 +
|
|
207
|
+
(versionMatch ? 1 : 0) * 0.07 +
|
|
208
|
+
freshnessScore * 0.05;
|
|
209
|
+
return {
|
|
210
|
+
...doc,
|
|
211
|
+
score: fuseScore,
|
|
212
|
+
rankScore,
|
|
213
|
+
confidence: toConfidence(rankScore),
|
|
214
|
+
reasons,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
scored.sort((a, b) => {
|
|
218
|
+
if (b.rankScore !== a.rankScore)
|
|
219
|
+
return b.rankScore - a.rankScore;
|
|
220
|
+
const titleCmp = a.title.localeCompare(b.title);
|
|
221
|
+
if (titleCmp !== 0)
|
|
222
|
+
return titleCmp;
|
|
223
|
+
return a.id.localeCompare(b.id);
|
|
224
|
+
});
|
|
225
|
+
return scored.slice(0, options.limit ?? 10);
|
|
226
|
+
}
|
|
227
|
+
export async function loadNamedMarkdownDocument(folder, id) {
|
|
228
|
+
const root = resolveSafePath(folder);
|
|
229
|
+
const idPattern = /^[A-Za-z0-9-]+$/;
|
|
230
|
+
if (!idPattern.test(id)) {
|
|
231
|
+
throw new Error("Identifier can only contain letters, numbers, and hyphens");
|
|
232
|
+
}
|
|
233
|
+
const filePath = path.join(root, `${id}.md`);
|
|
234
|
+
try {
|
|
235
|
+
const raw = await readMarkdownFile(filePath);
|
|
236
|
+
const parsed = matter(raw);
|
|
237
|
+
const fm = markdownDocFrontmatterSchema.parse(parsed.data);
|
|
238
|
+
return {
|
|
239
|
+
id: fm.id ?? id,
|
|
240
|
+
title: fm.title,
|
|
241
|
+
summary: fm.summary,
|
|
242
|
+
framework: fm.framework,
|
|
243
|
+
version: fm.version,
|
|
244
|
+
tags: fm.tags ?? [],
|
|
245
|
+
body: parsed.content.trim(),
|
|
246
|
+
path: `${folder}/${id}.md`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
if (err?.code === "ENOENT")
|
|
251
|
+
return null;
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function listMarkdownDocNames(folder) {
|
|
256
|
+
const root = resolveSafePath(folder);
|
|
257
|
+
try {
|
|
258
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
259
|
+
return entries
|
|
260
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
261
|
+
.map((e) => e.name.replace(/\.md$/i, ""))
|
|
262
|
+
.sort();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { textResponse } from "../../shared/response.js";
|
|
3
|
+
import { listMarkdownDocNames, loadNamedMarkdownDocument, searchSnippetDocumentsWithOptions, } from "../../storage/markdownKnowledgeStore.js";
|
|
4
|
+
export const explainConceptTool = {
|
|
5
|
+
name: "explain_concept",
|
|
6
|
+
description: "Explain engineering concepts with practical guidance",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
concept: z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^[A-Za-z0-9-]+$/)
|
|
11
|
+
.describe("Concept id, e.g. circuit-breaker"),
|
|
12
|
+
}),
|
|
13
|
+
async execute(input) {
|
|
14
|
+
const doc = await loadNamedMarkdownDocument("content/concepts", input.concept);
|
|
15
|
+
if (!doc) {
|
|
16
|
+
const available = await listMarkdownDocNames("content/concepts");
|
|
17
|
+
return textResponse(`Concept '${input.concept}' was not found. Available concepts: ${available.join(", ") || "none"}`);
|
|
18
|
+
}
|
|
19
|
+
const lines = [
|
|
20
|
+
`# ${doc.title}`,
|
|
21
|
+
"",
|
|
22
|
+
doc.summary ?? "",
|
|
23
|
+
"",
|
|
24
|
+
"## Metadata",
|
|
25
|
+
`- Framework: ${doc.framework ?? "n/a"}`,
|
|
26
|
+
`- Version: ${doc.version ?? "n/a"}`,
|
|
27
|
+
`- Tags: ${doc.tags.length ? doc.tags.join(", ") : "n/a"}`,
|
|
28
|
+
"",
|
|
29
|
+
`Path: ${doc.path}`,
|
|
30
|
+
"",
|
|
31
|
+
doc.body,
|
|
32
|
+
];
|
|
33
|
+
const related = await searchSnippetDocumentsWithOptions(`${doc.title} ${doc.tags.join(" ")}`, {
|
|
34
|
+
framework: doc.framework,
|
|
35
|
+
version: doc.version,
|
|
36
|
+
limit: 3,
|
|
37
|
+
});
|
|
38
|
+
if (related.length) {
|
|
39
|
+
lines.push("", "## Related Snippets");
|
|
40
|
+
related.forEach((item) => {
|
|
41
|
+
lines.push(`- ${item.id}: ${item.title} (${item.confidence}, score=${item.rankScore.toFixed(2)})`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return textResponse(lines.join("\n"));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { textResponse } from "../../shared/response.js";
|
|
3
|
+
import { listMarkdownDocNames, loadNamedMarkdownDocument, } from "../../storage/markdownKnowledgeStore.js";
|
|
4
|
+
function normalize(value) {
|
|
5
|
+
return value
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
8
|
+
.replace(/^-+|-+$/g, "");
|
|
9
|
+
}
|
|
10
|
+
export const generateSetupTool = {
|
|
11
|
+
name: "generate_setup",
|
|
12
|
+
description: "Generate production-ready setup guidance for a technology type",
|
|
13
|
+
inputSchema: z.object({
|
|
14
|
+
type: z.string().min(2).describe("Setup type, e.g. jwt"),
|
|
15
|
+
framework: z.string().optional().describe("Framework hint, e.g. dotnet9"),
|
|
16
|
+
}),
|
|
17
|
+
async execute(input) {
|
|
18
|
+
const normalizedType = normalize(input.type);
|
|
19
|
+
const normalizedFramework = input.framework
|
|
20
|
+
? normalize(input.framework)
|
|
21
|
+
: undefined;
|
|
22
|
+
const candidateNames = [
|
|
23
|
+
normalizedFramework
|
|
24
|
+
? `${normalizedType}-${normalizedFramework}`
|
|
25
|
+
: undefined,
|
|
26
|
+
normalizedType,
|
|
27
|
+
].filter((n) => Boolean(n));
|
|
28
|
+
for (const candidate of candidateNames) {
|
|
29
|
+
const doc = await loadNamedMarkdownDocument("content/setups", candidate);
|
|
30
|
+
if (doc) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`# ${doc.title}`,
|
|
33
|
+
"",
|
|
34
|
+
doc.summary ?? "",
|
|
35
|
+
"",
|
|
36
|
+
`Path: ${doc.path}`,
|
|
37
|
+
"",
|
|
38
|
+
doc.body,
|
|
39
|
+
];
|
|
40
|
+
return textResponse(lines.join("\n"));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const available = await listMarkdownDocNames("content/setups");
|
|
44
|
+
return textResponse(`No setup found for type='${input.type}'${input.framework ? ` framework='${input.framework}'` : ""}. Available setups: ${available.join(", ") || "none"}`);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import { z } from "zod";
|
|
3
|
+
import { glob } from "glob";
|
|
4
4
|
import { resolveSafePath } from "../fs/safePath.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
description: z.string(),
|
|
10
|
-
code: z.string(),
|
|
11
|
-
});
|
|
12
|
-
async function listAvailableSnippetNames(snippetsDir) {
|
|
5
|
+
import { textResponse } from "../../shared/response.js";
|
|
6
|
+
import { findSnippetByName } from "../../storage/markdownKnowledgeStore.js";
|
|
7
|
+
async function listAvailableMarkdownSnippetNames() {
|
|
8
|
+
const snippetsDir = resolveSafePath("snippets");
|
|
13
9
|
try {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const files = await glob("**/*.md", {
|
|
11
|
+
cwd: snippetsDir,
|
|
12
|
+
nodir: true,
|
|
13
|
+
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
14
|
+
});
|
|
15
|
+
return files
|
|
16
|
+
.map((file) => path.basename(file).replace(/\.md$/i, ""))
|
|
18
17
|
.sort();
|
|
19
18
|
}
|
|
20
19
|
catch {
|
|
@@ -31,34 +30,48 @@ export const getSnippetTool = {
|
|
|
31
30
|
.describe("Snippet name, e.g. 'polly-retry', 'jwt-setup'"),
|
|
32
31
|
}),
|
|
33
32
|
async execute(input) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const snippet = snippetFileSchema.parse(JSON.parse(raw));
|
|
39
|
-
const text = [
|
|
40
|
-
`# ${snippet.title}`,
|
|
33
|
+
const markdownSnippet = await findSnippetByName(input.name);
|
|
34
|
+
if (markdownSnippet) {
|
|
35
|
+
const lines = [
|
|
36
|
+
`# ${markdownSnippet.title}`,
|
|
41
37
|
"",
|
|
42
|
-
|
|
38
|
+
markdownSnippet.summary,
|
|
43
39
|
"",
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
40
|
+
];
|
|
41
|
+
const meta = [];
|
|
42
|
+
if (markdownSnippet.language)
|
|
43
|
+
meta.push(`Language: ${markdownSnippet.language}`);
|
|
44
|
+
if (markdownSnippet.framework)
|
|
45
|
+
meta.push(`Framework: ${markdownSnippet.framework}`);
|
|
46
|
+
if (markdownSnippet.version)
|
|
47
|
+
meta.push(`Version: ${markdownSnippet.version}`);
|
|
48
|
+
if (markdownSnippet.difficulty)
|
|
49
|
+
meta.push(`Difficulty: ${markdownSnippet.difficulty}`);
|
|
50
|
+
if (markdownSnippet.category)
|
|
51
|
+
meta.push(`Category: ${markdownSnippet.category}`);
|
|
52
|
+
if (markdownSnippet.tags.length)
|
|
53
|
+
meta.push(`Tags: ${markdownSnippet.tags.join(", ")}`);
|
|
54
|
+
if (meta.length)
|
|
55
|
+
lines.push(meta.join(" | "), "");
|
|
56
|
+
lines.push(markdownSnippet.body);
|
|
57
|
+
if (markdownSnippet.bestPractices.length) {
|
|
58
|
+
lines.push("", "## Best Practices");
|
|
59
|
+
markdownSnippet.bestPractices.forEach((bp) => lines.push(`- ${bp}`));
|
|
60
|
+
}
|
|
61
|
+
if (markdownSnippet.pitfalls.length) {
|
|
62
|
+
lines.push("", "## Pitfalls");
|
|
63
|
+
markdownSnippet.pitfalls.forEach((p) => lines.push(`- ${p}`));
|
|
64
|
+
}
|
|
65
|
+
if (markdownSnippet.securityNotes.length) {
|
|
66
|
+
lines.push("", "## Security Notes");
|
|
67
|
+
markdownSnippet.securityNotes.forEach((s) => lines.push(`- ${s}`));
|
|
68
|
+
}
|
|
69
|
+
return textResponse(lines.join("\n"));
|
|
62
70
|
}
|
|
71
|
+
const markdownAvailable = await listAvailableMarkdownSnippetNames();
|
|
72
|
+
const availableText = markdownAvailable.length
|
|
73
|
+
? markdownAvailable.join(", ")
|
|
74
|
+
: "No snippets found. Add markdown files under snippets/.";
|
|
75
|
+
return textResponse(`Snippet '${input.name}' was not found. Available snippets: ${availableText}`);
|
|
63
76
|
},
|
|
64
77
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { textResponse } from "../../shared/response.js";
|
|
3
|
+
import { listMarkdownDocNames, loadNamedMarkdownDocument, searchSnippetDocumentsWithOptions, } from "../../storage/markdownKnowledgeStore.js";
|
|
4
|
+
export const getTemplateTool = {
|
|
5
|
+
name: "get_template",
|
|
6
|
+
description: "Return complete implementation templates",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
template: z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^[A-Za-z0-9-]+$/)
|
|
11
|
+
.describe("Template id, e.g. clean-architecture"),
|
|
12
|
+
}),
|
|
13
|
+
async execute(input) {
|
|
14
|
+
const doc = await loadNamedMarkdownDocument("content/templates", input.template);
|
|
15
|
+
if (!doc) {
|
|
16
|
+
const available = await listMarkdownDocNames("content/templates");
|
|
17
|
+
return textResponse(`Template '${input.template}' was not found. Available templates: ${available.join(", ") || "none"}`);
|
|
18
|
+
}
|
|
19
|
+
const lines = [
|
|
20
|
+
`# ${doc.title}`,
|
|
21
|
+
"",
|
|
22
|
+
doc.summary ?? "",
|
|
23
|
+
"",
|
|
24
|
+
"## Metadata",
|
|
25
|
+
`- Framework: ${doc.framework ?? "n/a"}`,
|
|
26
|
+
`- Version: ${doc.version ?? "n/a"}`,
|
|
27
|
+
`- Tags: ${doc.tags.length ? doc.tags.join(", ") : "n/a"}`,
|
|
28
|
+
"",
|
|
29
|
+
`Path: ${doc.path}`,
|
|
30
|
+
"",
|
|
31
|
+
doc.body,
|
|
32
|
+
];
|
|
33
|
+
const related = await searchSnippetDocumentsWithOptions(`${doc.title} ${doc.tags.join(" ")}`, {
|
|
34
|
+
framework: doc.framework,
|
|
35
|
+
version: doc.version,
|
|
36
|
+
limit: 3,
|
|
37
|
+
});
|
|
38
|
+
if (related.length) {
|
|
39
|
+
lines.push("", "## Related Snippets");
|
|
40
|
+
related.forEach((item) => {
|
|
41
|
+
lines.push(`- ${item.id}: ${item.title} (${item.confidence}, score=${item.rankScore.toFixed(2)})`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return textResponse(lines.join("\n"));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { textResponse } from "../../shared/response.js";
|
|
3
|
+
import { searchSnippetDocumentsWithOptions } from "../../storage/markdownKnowledgeStore.js";
|
|
4
|
+
export const searchSnippetTool = {
|
|
5
|
+
name: "search_snippet",
|
|
6
|
+
description: "Search reusable engineering snippets by query text",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
query: z.string().min(2).describe("Snippet search query"),
|
|
9
|
+
framework: z.string().optional().describe("Optional framework filter"),
|
|
10
|
+
version: z.string().optional().describe("Optional version filter"),
|
|
11
|
+
category: z.string().optional().describe("Optional category filter"),
|
|
12
|
+
difficulty: z
|
|
13
|
+
.enum(["beginner", "medium", "advanced"])
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Optional difficulty filter"),
|
|
16
|
+
limit: z.number().int().min(1).max(50).optional().describe("Max results"),
|
|
17
|
+
}),
|
|
18
|
+
async execute(input) {
|
|
19
|
+
const results = await searchSnippetDocumentsWithOptions(input.query, {
|
|
20
|
+
framework: input.framework,
|
|
21
|
+
version: input.version,
|
|
22
|
+
category: input.category,
|
|
23
|
+
difficulty: input.difficulty,
|
|
24
|
+
limit: input.limit,
|
|
25
|
+
});
|
|
26
|
+
const payload = {
|
|
27
|
+
query: input.query,
|
|
28
|
+
filters: {
|
|
29
|
+
framework: input.framework,
|
|
30
|
+
version: input.version,
|
|
31
|
+
category: input.category,
|
|
32
|
+
difficulty: input.difficulty,
|
|
33
|
+
limit: input.limit ?? 10,
|
|
34
|
+
},
|
|
35
|
+
total: results.length,
|
|
36
|
+
results: results.map((r) => ({
|
|
37
|
+
id: r.id,
|
|
38
|
+
name: r.name,
|
|
39
|
+
title: r.title,
|
|
40
|
+
summary: r.summary,
|
|
41
|
+
updatedAt: r.updatedAt,
|
|
42
|
+
framework: r.framework,
|
|
43
|
+
version: r.version,
|
|
44
|
+
category: r.category,
|
|
45
|
+
difficulty: r.difficulty,
|
|
46
|
+
tags: r.tags,
|
|
47
|
+
fuseScore: r.score,
|
|
48
|
+
rankScore: Number(r.rankScore.toFixed(4)),
|
|
49
|
+
confidence: r.confidence,
|
|
50
|
+
reasons: r.reasons,
|
|
51
|
+
path: r.path,
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
return textResponse(JSON.stringify(payload, null, 2));
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Basic Search Example
|
|
2
|
+
|
|
3
|
+
This example demonstrates how to call the `search_snippet` MCP tool for practical snippet discovery.
|
|
4
|
+
|
|
5
|
+
## Scenario
|
|
6
|
+
|
|
7
|
+
You need JWT setup guidance for ASP.NET 9 and want ranked, confidence-labeled results.
|
|
8
|
+
|
|
9
|
+
## Run
|
|
10
|
+
|
|
11
|
+
1. Start an MCP client connected to this server.
|
|
12
|
+
2. Execute `search-snippet.ts`.
|
|
13
|
+
3. Inspect the structured output fields (`rankScore`, `confidence`, `reasons`).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Claude Desktop Integration
|
|
2
|
+
|
|
3
|
+
Use this folder to configure DevAssist MCP in Claude Desktop.
|
|
4
|
+
|
|
5
|
+
## Steps
|
|
6
|
+
|
|
7
|
+
1. Open Claude Desktop settings for MCP servers.
|
|
8
|
+
2. Merge or copy `claude_desktop_config.json` into your app config.
|
|
9
|
+
3. Restart Claude Desktop.
|
|
10
|
+
4. Verify tools like `search_snippet` and `explain_concept` are available.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Cursor Integration
|
|
2
|
+
|
|
3
|
+
This folder documents a minimal setup approach for Cursor IDE MCP usage.
|
|
4
|
+
|
|
5
|
+
## Suggested Setup
|
|
6
|
+
|
|
7
|
+
1. Install Node.js 18+.
|
|
8
|
+
2. Add MCP server command using `npx @mofaggolhoshen/dev-assist-mcp`.
|
|
9
|
+
3. Reload Cursor and verify tool discovery.
|
|
10
|
+
|
|
11
|
+
## Suggested First Calls
|
|
12
|
+
|
|
13
|
+
- `search_snippet` with: `jwt authentication asp.net 9`
|
|
14
|
+
- `explain_concept` with: `circuit-breaker`
|
|
15
|
+
- `generate_setup` with: `jwt`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Circuit Breaker
|
|
2
|
+
|
|
3
|
+
Circuit Breaker prevents repeated calls to a failing dependency by opening the circuit after failure thresholds are crossed.
|
|
4
|
+
|
|
5
|
+
## Metadata
|
|
6
|
+
- Framework: dotnet
|
|
7
|
+
- Version: .net8
|
|
8
|
+
- Tags: resilience, retry, faults
|
|
9
|
+
|
|
10
|
+
## Why it matters
|
|
11
|
+
- Reduces cascading failures
|
|
12
|
+
- Improves recovery behavior under downstream outages
|
|
13
|
+
|
|
14
|
+
## Related Snippets
|
|
15
|
+
- polly-retry: Polly Retry Policy (high, score=0.82)
|
|
16
|
+
- polly-circuit-breaker: Polly Circuit Breaker Policy (high, score=0.80)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"total": 1,
|
|
3
|
+
"results": [
|
|
4
|
+
{
|
|
5
|
+
"id": "jwt-setup-dotnet9",
|
|
6
|
+
"title": "JWT Setup (.NET 9)",
|
|
7
|
+
"category": "auth",
|
|
8
|
+
"framework": "aspnet",
|
|
9
|
+
"version": ".net9",
|
|
10
|
+
"difficulty": "medium",
|
|
11
|
+
"confidence": "high",
|
|
12
|
+
"rankScore": 0.87,
|
|
13
|
+
"reasons": ["tag_matches:2", "version_precedence", "fresh_content"]
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|