coding-friend-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-6CGGT2FD.js +32 -0
- package/dist/chunk-6DUFTBTO.js +14 -0
- package/dist/chunk-AQXTNLQD.js +39 -0
- package/dist/chunk-HRVSKMNA.js +31 -0
- package/dist/chunk-IUTXHCP7.js +28 -0
- package/dist/chunk-KZT4AFDW.js +44 -0
- package/dist/chunk-VHZQ6KEU.js +73 -0
- package/dist/host-3GAEZKKJ.js +83 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +34 -0
- package/dist/init-ONRXFOZ5.js +431 -0
- package/dist/json-2XS56OJY.js +10 -0
- package/dist/mcp-LMMIFH4B.js +104 -0
- package/dist/postinstall.d.ts +1 -0
- package/dist/postinstall.js +8 -0
- package/dist/statusline-7D6YU5YM.js +64 -0
- package/dist/update-K5PYOB52.js +160 -0
- package/lib/learn-host/next-env.d.ts +6 -0
- package/lib/learn-host/next.config.ts +9 -0
- package/lib/learn-host/package-lock.json +3943 -0
- package/lib/learn-host/package.json +31 -0
- package/lib/learn-host/postcss.config.mjs +7 -0
- package/lib/learn-host/scripts/build-search-index.ts +11 -0
- package/lib/learn-host/src/app/[category]/[slug]/page.tsx +61 -0
- package/lib/learn-host/src/app/[category]/page.tsx +35 -0
- package/lib/learn-host/src/app/globals.css +31 -0
- package/lib/learn-host/src/app/layout.tsx +32 -0
- package/lib/learn-host/src/app/page.tsx +63 -0
- package/lib/learn-host/src/app/search/page.tsx +94 -0
- package/lib/learn-host/src/app/search/search-index.json +42 -0
- package/lib/learn-host/src/components/Breadcrumbs.tsx +28 -0
- package/lib/learn-host/src/components/DocCard.tsx +32 -0
- package/lib/learn-host/src/components/MarkdownRenderer.tsx +13 -0
- package/lib/learn-host/src/components/MobileNav.tsx +56 -0
- package/lib/learn-host/src/components/SearchBar.tsx +36 -0
- package/lib/learn-host/src/components/Sidebar.tsx +44 -0
- package/lib/learn-host/src/components/TagBadge.tsx +12 -0
- package/lib/learn-host/src/components/ThemeToggle.tsx +30 -0
- package/lib/learn-host/src/lib/docs.ts +113 -0
- package/lib/learn-host/src/lib/search.ts +27 -0
- package/lib/learn-host/src/lib/types.ts +31 -0
- package/lib/learn-host/tsconfig.json +21 -0
- package/lib/learn-mcp/package-lock.json +1829 -0
- package/lib/learn-mcp/package.json +24 -0
- package/lib/learn-mcp/src/bin/learn-mcp.ts +2 -0
- package/lib/learn-mcp/src/index.ts +17 -0
- package/lib/learn-mcp/src/lib/docs.ts +199 -0
- package/lib/learn-mcp/src/lib/knowledge.ts +95 -0
- package/lib/learn-mcp/src/lib/types.ts +36 -0
- package/lib/learn-mcp/src/server.ts +22 -0
- package/lib/learn-mcp/src/tools/create-doc.ts +29 -0
- package/lib/learn-mcp/src/tools/get-review-list.ts +29 -0
- package/lib/learn-mcp/src/tools/improve-doc.ts +95 -0
- package/lib/learn-mcp/src/tools/list-categories.ts +19 -0
- package/lib/learn-mcp/src/tools/list-docs.ts +30 -0
- package/lib/learn-mcp/src/tools/read-doc.ts +29 -0
- package/lib/learn-mcp/src/tools/search-docs.ts +23 -0
- package/lib/learn-mcp/src/tools/track-knowledge.ts +35 -0
- package/lib/learn-mcp/src/tools/update-doc.ts +43 -0
- package/lib/learn-mcp/tsconfig.json +15 -0
- package/package.json +47 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "coding-friend-learn-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"learn-mcp": "./dist/bin/learn-mcp.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
16
|
+
"gray-matter": "^4.0.3",
|
|
17
|
+
"zod": "^3.25.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"tsx": "^4.0.0",
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerAllTools } from "./server.js";
|
|
5
|
+
|
|
6
|
+
const rawDir = process.argv[2] ?? process.env.LEARN_DOCS_DIR ?? "./docs/learn";
|
|
7
|
+
const docsDir = path.resolve(rawDir);
|
|
8
|
+
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "coding-friend-learn",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
registerAllTools(server, docsDir);
|
|
15
|
+
|
|
16
|
+
const transport = new StdioServerTransport();
|
|
17
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import type { CategoryInfo, Doc, DocFrontmatter, DocMeta } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function isMarkdownDoc(filePath: string): boolean {
|
|
7
|
+
const name = path.basename(filePath);
|
|
8
|
+
return name.endsWith(".md") && name !== "README.md";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseFrontmatter(raw: matter.GrayMatterFile<string>): DocFrontmatter {
|
|
12
|
+
const d = raw.data;
|
|
13
|
+
return {
|
|
14
|
+
title: String(d.title ?? "Untitled"),
|
|
15
|
+
category: String(d.category ?? ""),
|
|
16
|
+
tags: Array.isArray(d.tags) ? d.tags.map(String) : [],
|
|
17
|
+
created: String(d.created ?? ""),
|
|
18
|
+
updated: String(d.updated ?? ""),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeExcerpt(content: string, maxLen = 160): string {
|
|
23
|
+
const text = content
|
|
24
|
+
.replace(/^#+\s.*/gm, "")
|
|
25
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
26
|
+
.replace(/\n{2,}/g, " ")
|
|
27
|
+
.replace(/\n/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getCategories(docsDir: string): CategoryInfo[] {
|
|
33
|
+
if (!fs.existsSync(docsDir)) return [];
|
|
34
|
+
return fs
|
|
35
|
+
.readdirSync(docsDir, { withFileTypes: true })
|
|
36
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
37
|
+
.map((d) => {
|
|
38
|
+
const catPath = path.join(docsDir, d.name);
|
|
39
|
+
const count = fs
|
|
40
|
+
.readdirSync(catPath)
|
|
41
|
+
.filter((f) => isMarkdownDoc(f)).length;
|
|
42
|
+
return { name: d.name, docCount: count };
|
|
43
|
+
})
|
|
44
|
+
.filter((c) => c.docCount > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getAllDocs(docsDir: string): DocMeta[] {
|
|
48
|
+
const docs: DocMeta[] = [];
|
|
49
|
+
const categories = getCategories(docsDir);
|
|
50
|
+
|
|
51
|
+
for (const cat of categories) {
|
|
52
|
+
const catPath = path.join(docsDir, cat.name);
|
|
53
|
+
const files = fs.readdirSync(catPath).filter(isMarkdownDoc);
|
|
54
|
+
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const filePath = path.join(catPath, file);
|
|
57
|
+
const raw = matter(fs.readFileSync(filePath, "utf-8"));
|
|
58
|
+
const frontmatter = parseFrontmatter(raw);
|
|
59
|
+
|
|
60
|
+
docs.push({
|
|
61
|
+
slug: path.basename(file, ".md"),
|
|
62
|
+
category: cat.name,
|
|
63
|
+
frontmatter: { ...frontmatter, category: cat.name },
|
|
64
|
+
excerpt: makeExcerpt(raw.content),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return docs.sort(
|
|
70
|
+
(a, b) =>
|
|
71
|
+
new Date(b.frontmatter.updated || b.frontmatter.created).getTime() -
|
|
72
|
+
new Date(a.frontmatter.updated || a.frontmatter.created).getTime(),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getDocsByCategory(
|
|
77
|
+
docsDir: string,
|
|
78
|
+
category: string,
|
|
79
|
+
): DocMeta[] {
|
|
80
|
+
return getAllDocs(docsDir).filter((d) => d.category === category);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getDocsByTag(docsDir: string, tag: string): DocMeta[] {
|
|
84
|
+
const lowerTag = tag.toLowerCase();
|
|
85
|
+
return getAllDocs(docsDir).filter((d) =>
|
|
86
|
+
d.frontmatter.tags.some((t) => t.toLowerCase() === lowerTag),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readDoc(
|
|
91
|
+
docsDir: string,
|
|
92
|
+
category: string,
|
|
93
|
+
slug: string,
|
|
94
|
+
): Doc | null {
|
|
95
|
+
const filePath = path.join(docsDir, category, `${slug}.md`);
|
|
96
|
+
if (!fs.existsSync(filePath)) return null;
|
|
97
|
+
|
|
98
|
+
const raw = matter(fs.readFileSync(filePath, "utf-8"));
|
|
99
|
+
const frontmatter = parseFrontmatter(raw);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
slug,
|
|
103
|
+
category,
|
|
104
|
+
frontmatter: { ...frontmatter, category },
|
|
105
|
+
excerpt: makeExcerpt(raw.content),
|
|
106
|
+
content: raw.content,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function searchDocs(
|
|
111
|
+
docsDir: string,
|
|
112
|
+
query: string,
|
|
113
|
+
category?: string,
|
|
114
|
+
): DocMeta[] {
|
|
115
|
+
const lowerQuery = query.toLowerCase();
|
|
116
|
+
let docs = getAllDocs(docsDir);
|
|
117
|
+
|
|
118
|
+
if (category) {
|
|
119
|
+
docs = docs.filter((d) => d.category === category);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return docs.filter((d) => {
|
|
123
|
+
const titleMatch = d.frontmatter.title.toLowerCase().includes(lowerQuery);
|
|
124
|
+
const tagMatch = d.frontmatter.tags.some((t) =>
|
|
125
|
+
t.toLowerCase().includes(lowerQuery),
|
|
126
|
+
);
|
|
127
|
+
const excerptMatch = d.excerpt.toLowerCase().includes(lowerQuery);
|
|
128
|
+
|
|
129
|
+
if (titleMatch || tagMatch || excerptMatch) return true;
|
|
130
|
+
|
|
131
|
+
const full = readDoc(docsDir, d.category, d.slug);
|
|
132
|
+
return full?.content.toLowerCase().includes(lowerQuery) ?? false;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function slugify(title: string): string {
|
|
137
|
+
return title
|
|
138
|
+
.toLowerCase()
|
|
139
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
140
|
+
.replace(/^-|-$/g, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createDoc(
|
|
144
|
+
docsDir: string,
|
|
145
|
+
category: string,
|
|
146
|
+
title: string,
|
|
147
|
+
tags: string[],
|
|
148
|
+
content: string,
|
|
149
|
+
): string {
|
|
150
|
+
const catDir = path.join(docsDir, category);
|
|
151
|
+
if (!fs.existsSync(catDir)) {
|
|
152
|
+
fs.mkdirSync(catDir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const slug = slugify(title);
|
|
156
|
+
const filePath = path.join(catDir, `${slug}.md`);
|
|
157
|
+
const today = new Date().toISOString().split("T")[0];
|
|
158
|
+
|
|
159
|
+
const doc = matter.stringify(content, {
|
|
160
|
+
title,
|
|
161
|
+
category,
|
|
162
|
+
tags,
|
|
163
|
+
created: today,
|
|
164
|
+
updated: today,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
fs.writeFileSync(filePath, doc, "utf-8");
|
|
168
|
+
return filePath;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function updateDoc(
|
|
172
|
+
docsDir: string,
|
|
173
|
+
category: string,
|
|
174
|
+
slug: string,
|
|
175
|
+
updates: { content?: string; tags?: string[]; title?: string },
|
|
176
|
+
): string | null {
|
|
177
|
+
const filePath = path.join(docsDir, category, `${slug}.md`);
|
|
178
|
+
if (!fs.existsSync(filePath)) return null;
|
|
179
|
+
|
|
180
|
+
const raw = matter(fs.readFileSync(filePath, "utf-8"));
|
|
181
|
+
const today = new Date().toISOString().split("T")[0];
|
|
182
|
+
|
|
183
|
+
if (updates.tags) {
|
|
184
|
+
raw.data.tags = [
|
|
185
|
+
...new Set([...(raw.data.tags || []), ...updates.tags]),
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
if (updates.title) {
|
|
189
|
+
raw.data.title = updates.title;
|
|
190
|
+
}
|
|
191
|
+
raw.data.updated = today;
|
|
192
|
+
|
|
193
|
+
const newContent = updates.content
|
|
194
|
+
? raw.content + "\n\n" + updates.content
|
|
195
|
+
: raw.content;
|
|
196
|
+
|
|
197
|
+
fs.writeFileSync(filePath, matter.stringify(newContent, raw.data), "utf-8");
|
|
198
|
+
return filePath;
|
|
199
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { KnowledgeEntry, KnowledgeTracking } from "./types.js";
|
|
4
|
+
import { getAllDocs } from "./docs.js";
|
|
5
|
+
|
|
6
|
+
const TRACKING_FILE = ".knowledge-tracking.json";
|
|
7
|
+
|
|
8
|
+
function trackingPath(docsDir: string): string {
|
|
9
|
+
return path.join(docsDir, TRACKING_FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readTracking(docsDir: string): KnowledgeTracking {
|
|
13
|
+
const filePath = trackingPath(docsDir);
|
|
14
|
+
if (!fs.existsSync(filePath)) {
|
|
15
|
+
return { version: 1, entries: {} };
|
|
16
|
+
}
|
|
17
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
18
|
+
return { version: 1, entries: raw.entries ?? {} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function writeTracking(
|
|
22
|
+
docsDir: string,
|
|
23
|
+
tracking: KnowledgeTracking,
|
|
24
|
+
): void {
|
|
25
|
+
fs.writeFileSync(
|
|
26
|
+
trackingPath(docsDir),
|
|
27
|
+
JSON.stringify(tracking, null, 2) + "\n",
|
|
28
|
+
"utf-8",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function trackKnowledge(
|
|
33
|
+
docsDir: string,
|
|
34
|
+
category: string,
|
|
35
|
+
slug: string,
|
|
36
|
+
status: KnowledgeEntry["status"],
|
|
37
|
+
notes?: string,
|
|
38
|
+
): KnowledgeEntry {
|
|
39
|
+
const tracking = readTracking(docsDir);
|
|
40
|
+
const key = `${category}/${slug}`;
|
|
41
|
+
const today = new Date().toISOString().split("T")[0]!;
|
|
42
|
+
const existing = tracking.entries[key];
|
|
43
|
+
|
|
44
|
+
const entry: KnowledgeEntry = {
|
|
45
|
+
status,
|
|
46
|
+
lastReviewed: today,
|
|
47
|
+
reviewCount: (existing?.reviewCount ?? 0) + 1,
|
|
48
|
+
notes: notes ?? existing?.notes ?? "",
|
|
49
|
+
firstSeen: existing?.firstSeen ?? today,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
tracking.entries[key] = entry;
|
|
53
|
+
writeTracking(docsDir, tracking);
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getReviewList(
|
|
58
|
+
docsDir: string,
|
|
59
|
+
statusFilter?: "needs-review" | "new",
|
|
60
|
+
limit?: number,
|
|
61
|
+
): Array<{ key: string; entry: KnowledgeEntry }> {
|
|
62
|
+
const tracking = readTracking(docsDir);
|
|
63
|
+
const allDocs = getAllDocs(docsDir);
|
|
64
|
+
|
|
65
|
+
const results: Array<{ key: string; entry: KnowledgeEntry }> = [];
|
|
66
|
+
|
|
67
|
+
for (const doc of allDocs) {
|
|
68
|
+
const key = `${doc.category}/${doc.slug}`;
|
|
69
|
+
const entry = tracking.entries[key] ?? {
|
|
70
|
+
status: "new" as const,
|
|
71
|
+
lastReviewed: null,
|
|
72
|
+
reviewCount: 0,
|
|
73
|
+
notes: "",
|
|
74
|
+
firstSeen: doc.frontmatter.created || new Date().toISOString().split("T")[0]!,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (statusFilter && entry.status !== statusFilter) continue;
|
|
78
|
+
if (!statusFilter && entry.status === "remembered") continue;
|
|
79
|
+
|
|
80
|
+
results.push({ key, entry });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
results.sort((a, b) => {
|
|
84
|
+
if (a.entry.status === "needs-review" && b.entry.status !== "needs-review")
|
|
85
|
+
return -1;
|
|
86
|
+
if (a.entry.status !== "needs-review" && b.entry.status === "needs-review")
|
|
87
|
+
return 1;
|
|
88
|
+
|
|
89
|
+
const dateA = a.entry.lastReviewed ?? a.entry.firstSeen;
|
|
90
|
+
const dateB = b.entry.lastReviewed ?? b.entry.firstSeen;
|
|
91
|
+
return new Date(dateA).getTime() - new Date(dateB).getTime();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return limit ? results.slice(0, limit) : results;
|
|
95
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface DocFrontmatter {
|
|
2
|
+
title: string;
|
|
3
|
+
category: string;
|
|
4
|
+
tags: string[];
|
|
5
|
+
created: string;
|
|
6
|
+
updated: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DocMeta {
|
|
10
|
+
slug: string;
|
|
11
|
+
category: string;
|
|
12
|
+
frontmatter: DocFrontmatter;
|
|
13
|
+
excerpt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Doc extends DocMeta {
|
|
17
|
+
content: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CategoryInfo {
|
|
21
|
+
name: string;
|
|
22
|
+
docCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KnowledgeEntry {
|
|
26
|
+
status: "remembered" | "needs-review" | "new";
|
|
27
|
+
lastReviewed: string | null;
|
|
28
|
+
reviewCount: number;
|
|
29
|
+
notes: string;
|
|
30
|
+
firstSeen: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KnowledgeTracking {
|
|
34
|
+
version: 1;
|
|
35
|
+
entries: Record<string, KnowledgeEntry>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerListCategories } from "./tools/list-categories.js";
|
|
3
|
+
import { registerListDocs } from "./tools/list-docs.js";
|
|
4
|
+
import { registerReadDoc } from "./tools/read-doc.js";
|
|
5
|
+
import { registerSearchDocs } from "./tools/search-docs.js";
|
|
6
|
+
import { registerCreateDoc } from "./tools/create-doc.js";
|
|
7
|
+
import { registerUpdateDoc } from "./tools/update-doc.js";
|
|
8
|
+
import { registerImproveDoc } from "./tools/improve-doc.js";
|
|
9
|
+
import { registerTrackKnowledge } from "./tools/track-knowledge.js";
|
|
10
|
+
import { registerGetReviewList } from "./tools/get-review-list.js";
|
|
11
|
+
|
|
12
|
+
export function registerAllTools(server: McpServer, docsDir: string): void {
|
|
13
|
+
registerListCategories(server, docsDir);
|
|
14
|
+
registerListDocs(server, docsDir);
|
|
15
|
+
registerReadDoc(server, docsDir);
|
|
16
|
+
registerSearchDocs(server, docsDir);
|
|
17
|
+
registerCreateDoc(server, docsDir);
|
|
18
|
+
registerUpdateDoc(server, docsDir);
|
|
19
|
+
registerImproveDoc(server, docsDir);
|
|
20
|
+
registerTrackKnowledge(server, docsDir);
|
|
21
|
+
registerGetReviewList(server, docsDir);
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createDoc } from "../lib/docs.js";
|
|
4
|
+
|
|
5
|
+
export function registerCreateDoc(server: McpServer, docsDir: string): void {
|
|
6
|
+
server.tool(
|
|
7
|
+
"create-doc",
|
|
8
|
+
"Create a new learning doc with proper YAML frontmatter. Provide the category, title, tags, and markdown content.",
|
|
9
|
+
{
|
|
10
|
+
category: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe("Category folder name (created if missing)"),
|
|
13
|
+
title: z.string().describe("Doc title"),
|
|
14
|
+
tags: z.array(z.string()).describe("Tags for the doc"),
|
|
15
|
+
content: z.string().describe("Markdown content (without frontmatter)"),
|
|
16
|
+
},
|
|
17
|
+
async ({ category, title, tags, content }) => {
|
|
18
|
+
const filePath = createDoc(docsDir, category, title, tags, content);
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: JSON.stringify({ path: filePath, created: true }, null, 2),
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getReviewList } from "../lib/knowledge.js";
|
|
4
|
+
|
|
5
|
+
export function registerGetReviewList(
|
|
6
|
+
server: McpServer,
|
|
7
|
+
docsDir: string,
|
|
8
|
+
): void {
|
|
9
|
+
server.tool(
|
|
10
|
+
"get-review-list",
|
|
11
|
+
"Get learning docs that need review. Returns docs marked as 'needs-review' or 'new', sorted by staleness. Docs marked 'remembered' are excluded.",
|
|
12
|
+
{
|
|
13
|
+
status: z
|
|
14
|
+
.enum(["needs-review", "new"])
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Filter by specific status"),
|
|
17
|
+
limit: z
|
|
18
|
+
.number()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Max number of results to return"),
|
|
21
|
+
},
|
|
22
|
+
async ({ status, limit }) => {
|
|
23
|
+
const results = getReviewList(docsDir, status, limit);
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readDoc } from "../lib/docs.js";
|
|
4
|
+
|
|
5
|
+
const EXPECTED_SECTIONS = ["What", "Why", "How", "Gotchas", "Read More"];
|
|
6
|
+
|
|
7
|
+
export function registerImproveDoc(server: McpServer, docsDir: string): void {
|
|
8
|
+
server.tool(
|
|
9
|
+
"improve-doc",
|
|
10
|
+
"Analyze a learning doc and return improvement suggestions. Checks for missing sections, short content, missing code examples, stale dates, etc.",
|
|
11
|
+
{
|
|
12
|
+
category: z.string().describe("Category folder name"),
|
|
13
|
+
slug: z.string().describe("Doc filename without .md extension"),
|
|
14
|
+
},
|
|
15
|
+
async ({ category, slug }) => {
|
|
16
|
+
const doc = readDoc(docsDir, category, slug);
|
|
17
|
+
if (!doc) {
|
|
18
|
+
return {
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "text", text: `Doc not found: ${category}/${slug}.md` },
|
|
21
|
+
],
|
|
22
|
+
isError: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const suggestions: string[] = [];
|
|
27
|
+
|
|
28
|
+
for (const section of EXPECTED_SECTIONS) {
|
|
29
|
+
const regex = new RegExp(`^##\\s+${section}`, "m");
|
|
30
|
+
if (!regex.test(doc.content)) {
|
|
31
|
+
suggestions.push(`Missing section: ## ${section}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const howMatch = doc.content.match(/## How\n([\s\S]*?)(?=\n## |$)/);
|
|
36
|
+
if (howMatch && !howMatch[1]!.includes("```")) {
|
|
37
|
+
suggestions.push(
|
|
38
|
+
"The 'How' section has no code examples. Add real code from the project.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sections = doc.content.split(/^## /m).slice(1);
|
|
43
|
+
for (const section of sections) {
|
|
44
|
+
const lines = section.split("\n").filter((l) => l.trim()).length;
|
|
45
|
+
const name = section.split("\n")[0]?.trim();
|
|
46
|
+
if (lines < 3 && name) {
|
|
47
|
+
suggestions.push(
|
|
48
|
+
`Section '${name}' is very short (${lines} lines). Consider expanding.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (doc.frontmatter.tags.length === 0) {
|
|
54
|
+
suggestions.push(
|
|
55
|
+
"No tags found. Add relevant tags for discoverability.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (doc.frontmatter.updated) {
|
|
60
|
+
const updated = new Date(doc.frontmatter.updated);
|
|
61
|
+
const daysSince = Math.floor(
|
|
62
|
+
(Date.now() - updated.getTime()) / (1000 * 60 * 60 * 24),
|
|
63
|
+
);
|
|
64
|
+
if (daysSince > 90) {
|
|
65
|
+
suggestions.push(
|
|
66
|
+
`Doc hasn't been updated in ${daysSince} days. Review if content is still accurate.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const readMoreMatch = doc.content.match(
|
|
72
|
+
/## Read More\n([\s\S]*?)(?=\n## |$)/,
|
|
73
|
+
);
|
|
74
|
+
if (readMoreMatch && !readMoreMatch[1]!.includes("http")) {
|
|
75
|
+
suggestions.push(
|
|
76
|
+
"The 'Read More' section has no external links. Add links to official docs or tutorials.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result =
|
|
81
|
+
suggestions.length > 0
|
|
82
|
+
? suggestions
|
|
83
|
+
: ["Doc looks good! No improvement suggestions."];
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "text",
|
|
89
|
+
text: JSON.stringify({ suggestions: result }, null, 2),
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { getCategories } from "../lib/docs.js";
|
|
3
|
+
|
|
4
|
+
export function registerListCategories(
|
|
5
|
+
server: McpServer,
|
|
6
|
+
docsDir: string,
|
|
7
|
+
): void {
|
|
8
|
+
server.tool(
|
|
9
|
+
"list-categories",
|
|
10
|
+
"List all learning doc categories with document counts",
|
|
11
|
+
{},
|
|
12
|
+
async () => {
|
|
13
|
+
const categories = getCategories(docsDir);
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: JSON.stringify(categories, null, 2) }],
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getAllDocs, getDocsByCategory, getDocsByTag } from "../lib/docs.js";
|
|
4
|
+
|
|
5
|
+
export function registerListDocs(server: McpServer, docsDir: string): void {
|
|
6
|
+
server.tool(
|
|
7
|
+
"list-docs",
|
|
8
|
+
"List learning docs. Optionally filter by category or tag. Returns doc metadata (title, category, tags, dates, excerpt).",
|
|
9
|
+
{
|
|
10
|
+
category: z.string().optional().describe("Filter by category name"),
|
|
11
|
+
tag: z.string().optional().describe("Filter by tag"),
|
|
12
|
+
limit: z.number().optional().describe("Max number of docs to return"),
|
|
13
|
+
},
|
|
14
|
+
async ({ category, tag, limit }) => {
|
|
15
|
+
let docs = category
|
|
16
|
+
? getDocsByCategory(docsDir, category)
|
|
17
|
+
: tag
|
|
18
|
+
? getDocsByTag(docsDir, tag)
|
|
19
|
+
: getAllDocs(docsDir);
|
|
20
|
+
|
|
21
|
+
if (limit && limit > 0) {
|
|
22
|
+
docs = docs.slice(0, limit);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: JSON.stringify(docs, null, 2) }],
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readDoc } from "../lib/docs.js";
|
|
4
|
+
|
|
5
|
+
export function registerReadDoc(server: McpServer, docsDir: string): void {
|
|
6
|
+
server.tool(
|
|
7
|
+
"read-doc",
|
|
8
|
+
"Read the full content of a single learning doc by category and slug (filename without .md).",
|
|
9
|
+
{
|
|
10
|
+
category: z.string().describe("Category folder name"),
|
|
11
|
+
slug: z.string().describe("Doc filename without .md extension"),
|
|
12
|
+
},
|
|
13
|
+
async ({ category, slug }) => {
|
|
14
|
+
const doc = readDoc(docsDir, category, slug);
|
|
15
|
+
if (!doc) {
|
|
16
|
+
return {
|
|
17
|
+
content: [
|
|
18
|
+
{ type: "text", text: `Doc not found: ${category}/${slug}.md` },
|
|
19
|
+
],
|
|
20
|
+
isError: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: JSON.stringify(doc, null, 2) }],
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { searchDocs } from "../lib/docs.js";
|
|
4
|
+
|
|
5
|
+
export function registerSearchDocs(server: McpServer, docsDir: string): void {
|
|
6
|
+
server.tool(
|
|
7
|
+
"search-docs",
|
|
8
|
+
"Full-text search across all learning docs. Searches titles, tags, and content. Optionally filter by category.",
|
|
9
|
+
{
|
|
10
|
+
query: z.string().describe("Search query text"),
|
|
11
|
+
category: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Limit search to this category"),
|
|
15
|
+
},
|
|
16
|
+
async ({ query, category }) => {
|
|
17
|
+
const results = searchDocs(docsDir, query, category);
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
}
|