Skip to content

Commit d863e23

Browse files
Merge pull request #8 from KDachev4/add-mcp-folder
mcp
2 parents 8da20c8 + 19296dd commit d863e23

4 files changed

Lines changed: 367 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
site/
22
docs/
3+
mcp/node_modules/
4+
mcp/package-lock.json

mcp/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "compendium-mcp",
3+
"version": "1.0.0",
4+
"description": "MCP server for the Maths, CS & AI Compendium",
5+
"type": "module",
6+
"scripts": {
7+
"start": "[ -d node_modules ] || npm install --silent && tsx src/index.ts",
8+
"setup": "npm install"
9+
},
10+
"dependencies": {
11+
"@modelcontextprotocol/sdk": "^1.12.0",
12+
"zod": "^3.24.0"
13+
},
14+
"devDependencies": {
15+
"tsx": "^4.19.0",
16+
"@types/node": "^22.0.0",
17+
"typescript": "^5.7.0"
18+
}
19+
}

mcp/src/index.ts

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3+
import { z } from "zod";
4+
import { readdir, readFile } from "node:fs/promises";
5+
import { join, dirname } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const ROOT = process.env.COMPENDIUM_ROOT || join(__dirname, "..", "..");
10+
11+
const CHAPTER_RE = /^chapter (\d{2}): (.+)$/;
12+
const SECTION_RE = /^(\d{2})\. (.+)\.md$/;
13+
14+
interface Chapter {
15+
number: number;
16+
name: string;
17+
path: string;
18+
}
19+
20+
interface Section {
21+
number: number;
22+
name: string;
23+
path: string;
24+
}
25+
26+
interface SectionMeta {
27+
chapter: number;
28+
chapterName: string;
29+
section: number;
30+
sectionName: string;
31+
description: string;
32+
}
33+
34+
async function getChapters(): Promise<Chapter[]> {
35+
const entries = await readdir(ROOT);
36+
return entries
37+
.map((entry) => {
38+
const match = entry.match(CHAPTER_RE);
39+
if (!match) return null;
40+
return { number: parseInt(match[1], 10), name: match[2], path: join(ROOT, entry) };
41+
})
42+
.filter((ch): ch is Chapter => ch !== null)
43+
.sort((a, b) => a.number - b.number);
44+
}
45+
46+
async function getSections(chapterPath: string): Promise<Section[]> {
47+
const entries = await readdir(chapterPath);
48+
return entries
49+
.map((entry) => {
50+
const match = entry.match(SECTION_RE);
51+
if (!match) return null;
52+
return { number: parseInt(match[1], 10), name: match[2], path: join(chapterPath, entry) };
53+
})
54+
.filter((s): s is Section => s !== null)
55+
.sort((a, b) => a.number - b.number);
56+
}
57+
58+
async function parseLlmsTxt(): Promise<SectionMeta[]> {
59+
const content = await readFile(join(ROOT, "llms.txt"), "utf-8");
60+
const results: SectionMeta[] = [];
61+
let currentChapter = 0;
62+
let currentChapterName = "";
63+
64+
for (const line of content.split("\n")) {
65+
const chapterMatch = line.match(/^### Chapter (\d+): (.+)$/);
66+
if (chapterMatch) {
67+
currentChapter = parseInt(chapterMatch[1], 10);
68+
currentChapterName = chapterMatch[2];
69+
continue;
70+
}
71+
72+
const sectionMatch = line.match(/^- \[(.+?)\]\(.+?\): (.+)$/);
73+
if (sectionMatch && currentChapter > 0) {
74+
results.push({
75+
chapter: currentChapter,
76+
chapterName: currentChapterName,
77+
section: results.filter((r) => r.chapter === currentChapter).length + 1,
78+
sectionName: sectionMatch[1],
79+
description: sectionMatch[2],
80+
});
81+
}
82+
}
83+
return results;
84+
}
85+
86+
const STOP_WORDS = new Set([
87+
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
88+
"our", "out", "has", "how", "its", "may", "who", "did", "get", "got", "let", "say", "she",
89+
"too", "use", "what", "why", "when", "where", "which", "with", "would", "could", "should",
90+
"about", "after", "been", "being", "between", "both", "does", "doing", "during", "each",
91+
"from", "have", "into", "just", "know", "like", "make", "more", "most", "much", "need",
92+
"only", "other", "over", "some", "such", "take", "than", "that", "them", "then", "there",
93+
"these", "they", "this", "very", "want", "well", "were", "will", "work", "your",
94+
"understand", "learn", "explain", "tell", "help",
95+
]);
96+
97+
const server = new McpServer({
98+
name: "compendium",
99+
version: "1.0.0",
100+
});
101+
102+
server.registerTool(
103+
"list_topics",
104+
{
105+
description: "List all chapters and sections in the compendium, or filter to a specific chapter",
106+
inputSchema: { chapter: z.number().optional().describe("Filter to a specific chapter number (1-20)") },
107+
},
108+
async ({ chapter }) => {
109+
const chapters = await getChapters();
110+
const filtered = chapter ? chapters.filter((ch) => ch.number === chapter) : chapters;
111+
112+
if (filtered.length === 0) {
113+
return { content: [{ type: "text", text: `Chapter ${chapter} not found. Valid chapters: 1-${chapters.length}.` }] };
114+
}
115+
116+
const lines: string[] = [];
117+
for (const ch of filtered) {
118+
const sections = await getSections(ch.path);
119+
lines.push(`\n## Chapter ${ch.number}: ${ch.name}`);
120+
for (const sec of sections) {
121+
lines.push(` ${sec.number}. ${sec.name}`);
122+
}
123+
}
124+
125+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
126+
},
127+
);
128+
129+
server.registerTool(
130+
"read_section",
131+
{
132+
description: "Read the full content of a specific section from the compendium",
133+
inputSchema: {
134+
chapter: z.number().describe("Chapter number (1-20)"),
135+
section: z.number().describe("Section number (typically 0-7, varies by chapter)"),
136+
},
137+
},
138+
async ({ chapter, section }) => {
139+
const chapters = await getChapters();
140+
const ch = chapters.find((c) => c.number === chapter);
141+
if (!ch) {
142+
return { content: [{ type: "text", text: `Chapter ${chapter} not found. Valid chapters: 1-${chapters.length}.` }] };
143+
}
144+
145+
const sections = await getSections(ch.path);
146+
const sec = sections.find((s) => s.number === section);
147+
if (!sec) {
148+
const valid = sections.map((s) => s.number).join(", ");
149+
return { content: [{ type: "text", text: `Section ${section} not found in Chapter ${chapter}: ${ch.name}. Valid sections: ${valid}.` }] };
150+
}
151+
152+
const content = await readFile(sec.path, "utf-8");
153+
return { content: [{ type: "text", text: `# Chapter ${ch.number}: ${ch.name}${sec.name}\n\n${content}` }] };
154+
},
155+
);
156+
157+
server.registerTool(
158+
"search",
159+
{
160+
description: "Search across all compendium sections for a term or phrase",
161+
inputSchema: { query: z.string().describe("Search term or phrase to find across all sections") },
162+
},
163+
async ({ query }) => {
164+
const chapters = await getChapters();
165+
const results: string[] = [];
166+
const lowerQuery = query.toLowerCase();
167+
168+
for (const ch of chapters) {
169+
const sections = await getSections(ch.path);
170+
for (const sec of sections) {
171+
const content = await readFile(sec.path, "utf-8");
172+
const lines = content.split("\n");
173+
const matches: string[] = [];
174+
175+
for (let i = 0; i < lines.length; i++) {
176+
if (lines[i].toLowerCase().includes(lowerQuery)) {
177+
const start = Math.max(0, i - 2);
178+
const end = Math.min(lines.length - 1, i + 2);
179+
const excerpt = lines.slice(start, end + 1).join("\n");
180+
matches.push(` Line ${i + 1}:\n${excerpt}`);
181+
}
182+
}
183+
184+
if (matches.length > 0) {
185+
results.push(`### Chapter ${ch.number}: ${ch.name}${sec.name}\n${matches.slice(0, 3).join("\n\n")}`);
186+
}
187+
}
188+
189+
if (results.length >= 20) break;
190+
}
191+
192+
if (results.length === 0) {
193+
return { content: [{ type: "text", text: `No results found for "${query}".` }] };
194+
}
195+
196+
return { content: [{ type: "text", text: `Found matches in ${results.length} sections:\n\n${results.join("\n\n")}` }] };
197+
},
198+
);
199+
200+
server.registerTool(
201+
"recommend",
202+
{
203+
description: "Given a learning goal or question, recommend the most relevant compendium sections in suggested reading order",
204+
inputSchema: { query: z.string().describe("A learning goal or question, e.g. 'How do transformers work?' or 'What math do I need for ML?'") },
205+
},
206+
async ({ query }) => {
207+
const meta = await parseLlmsTxt();
208+
const keywords = query
209+
.toLowerCase()
210+
.split(/\W+/)
211+
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
212+
213+
if (keywords.length === 0) {
214+
return { content: [{ type: "text", text: "Could not extract meaningful keywords from your query. Try using specific technical terms." }] };
215+
}
216+
217+
const scored = meta.map((entry) => {
218+
const descLower = entry.description.toLowerCase();
219+
const nameLower = `${entry.chapterName} ${entry.sectionName}`.toLowerCase();
220+
221+
let score = 0;
222+
for (const kw of keywords) {
223+
if (descLower.includes(kw)) score += 2;
224+
if (nameLower.includes(kw)) score += 3;
225+
}
226+
return { ...entry, score };
227+
});
228+
229+
const matches = scored
230+
.filter((s) => s.score > 0)
231+
.sort((a, b) => b.score - a.score || a.chapter - b.chapter)
232+
.slice(0, 15);
233+
234+
if (matches.length === 0) {
235+
return { content: [{ type: "text", text: `No relevant sections found for "${query}". Try broader terms or use search for exact matches.` }] };
236+
}
237+
238+
const byChapter = new Map<number, typeof matches>();
239+
for (const m of matches) {
240+
if (!byChapter.has(m.chapter)) byChapter.set(m.chapter, []);
241+
byChapter.get(m.chapter)!.push(m);
242+
}
243+
244+
const lines: string[] = ["Recommended sections (in suggested reading order):\n"];
245+
for (const [chNum, sections] of [...byChapter.entries()].sort((a, b) => a[0] - b[0])) {
246+
const ch = sections[0];
247+
sections.sort((a, b) => a.section - b.section);
248+
lines.push(`## Chapter ${chNum}: ${ch.chapterName}`);
249+
for (const sec of sections) {
250+
lines.push(` ${sec.section}. ${sec.sectionName}${sec.description}`);
251+
}
252+
lines.push("");
253+
}
254+
255+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
256+
},
257+
);
258+
259+
server.registerTool(
260+
"get_examples",
261+
{
262+
description: "Extract code examples from the compendium, optionally filtered by topic or language. Returns implementation code with surrounding explanation.",
263+
inputSchema: {
264+
query: z.string().optional().describe("Topic to find examples for, e.g. 'attention mechanism' or 'CUDA kernel'"),
265+
language: z.string().optional().describe("Filter by programming language, e.g. 'python', 'cpp', 'bash'"),
266+
chapter: z.number().optional().describe("Filter to a specific chapter number (1-20)"),
267+
},
268+
},
269+
async ({ query, language, chapter }) => {
270+
const chapters = await getChapters();
271+
const filtered = chapter ? chapters.filter((ch) => ch.number === chapter) : chapters;
272+
const lowerQuery = query?.toLowerCase();
273+
const results: string[] = [];
274+
275+
for (const ch of filtered) {
276+
const sections = await getSections(ch.path);
277+
for (const sec of sections) {
278+
const content = await readFile(sec.path, "utf-8");
279+
const lines = content.split("\n");
280+
281+
for (let i = 0; i < lines.length; i++) {
282+
const openMatch = lines[i].match(/^```(\w*)$/);
283+
if (!openMatch) continue;
284+
285+
const lang = openMatch[1] || "text";
286+
if (language && lang !== language) continue;
287+
288+
let end = i + 1;
289+
while (end < lines.length && lines[end] !== "```") end++;
290+
291+
const code = lines.slice(i + 1, end).join("\n");
292+
if (!code.trim()) continue;
293+
294+
const ctxStart = Math.max(0, i - 3);
295+
const context = lines.slice(ctxStart, i).filter((l) => l.trim()).join("\n");
296+
297+
if (lowerQuery) {
298+
const searchable = `${context} ${code}`.toLowerCase();
299+
if (!searchable.includes(lowerQuery)) continue;
300+
}
301+
302+
results.push(
303+
`### Chapter ${ch.number}: ${ch.name}${sec.name}\n` +
304+
(context ? `${context}\n\n` : "") +
305+
`\`\`\`${lang}\n${code}\n\`\`\``,
306+
);
307+
308+
if (results.length >= 10) break;
309+
i = end;
310+
}
311+
if (results.length >= 10) break;
312+
}
313+
if (results.length >= 10) break;
314+
}
315+
316+
if (results.length === 0) {
317+
const filters = [query && `topic "${query}"`, language && `language "${language}"`, chapter && `chapter ${chapter}`].filter(Boolean).join(", ");
318+
return { content: [{ type: "text", text: `No code examples found for ${filters || "the given filters"}.` }] };
319+
}
320+
321+
return { content: [{ type: "text", text: `Found ${results.length} code examples:\n\n${results.join("\n\n---\n\n")}` }] };
322+
},
323+
);
324+
325+
async function main() {
326+
const transport = new StdioServerTransport();
327+
await server.connect(transport);
328+
console.error("Compendium MCP server running on stdio");
329+
}
330+
331+
main().catch((err) => {
332+
console.error("Failed to start server:", err);
333+
process.exit(1);
334+
});

mcp/tsconfig.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "Node16",
5+
"moduleResolution": "Node16",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"skipLibCheck": true,
9+
"outDir": "dist"
10+
},
11+
"include": ["src"]
12+
}

0 commit comments

Comments
 (0)