Skip to content

Commit 265ad85

Browse files
committed
feat(mcp-server): add ECMA-376 spec MCP server with vector search
1 parent aeafaac commit 265ad85

10 files changed

Lines changed: 607 additions & 54 deletions

File tree

apps/mcp-server/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.dev.vars

apps/mcp-server/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
"typecheck": "tsc --noEmit"
1212
},
1313
"dependencies": {
14-
"@ooxml-dev/shared": "workspace:*"
14+
"@ooxml-dev/shared": "workspace:*",
15+
"@modelcontextprotocol/sdk": "^1.25.3",
16+
"@neondatabase/serverless": "^1.0.2"
1517
},
1618
"devDependencies": {
17-
"@cloudflare/workers-types": "^4.20241230.0",
19+
"@cloudflare/workers-types": "^4.20260127.0",
1820
"typescript": "~5.9.3",
19-
"wrangler": "^4.0.0"
21+
"wrangler": "^4.61.0"
2022
}
2123
}

apps/mcp-server/src/db.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Database client for MCP server using Neon serverless driver
3+
*/
4+
5+
import { neon } from "@neondatabase/serverless";
6+
7+
export interface SearchResult {
8+
id: number;
9+
partNumber: number;
10+
sectionId: string | null;
11+
title: string | null;
12+
content: string;
13+
contentType: string;
14+
score: number;
15+
}
16+
17+
export interface SpecContent {
18+
id: number;
19+
partNumber: number;
20+
sectionId: string | null;
21+
title: string | null;
22+
content: string;
23+
contentType: string;
24+
}
25+
26+
export function createDb(connectionString: string) {
27+
const sql = neon(connectionString);
28+
29+
return {
30+
// Semantic search using vector similarity
31+
async search(
32+
queryEmbedding: number[],
33+
options: { limit?: number; partNumber?: number; contentType?: string } = {},
34+
): Promise<SearchResult[]> {
35+
const { limit = 5, partNumber, contentType } = options;
36+
const embeddingStr = `[${queryEmbedding.join(",")}]`;
37+
38+
// Build query with optional filters
39+
let results: Record<string, unknown>[];
40+
if (partNumber !== undefined && contentType) {
41+
results = await sql`
42+
SELECT id, part_number, section_id, title, content, content_type,
43+
1 - (embedding <=> ${embeddingStr}::vector) as score
44+
FROM spec_content
45+
WHERE embedding IS NOT NULL
46+
AND part_number = ${partNumber}
47+
AND content_type = ${contentType}
48+
ORDER BY embedding <=> ${embeddingStr}::vector
49+
LIMIT ${limit}
50+
`;
51+
} else if (partNumber !== undefined) {
52+
results = await sql`
53+
SELECT id, part_number, section_id, title, content, content_type,
54+
1 - (embedding <=> ${embeddingStr}::vector) as score
55+
FROM spec_content
56+
WHERE embedding IS NOT NULL
57+
AND part_number = ${partNumber}
58+
ORDER BY embedding <=> ${embeddingStr}::vector
59+
LIMIT ${limit}
60+
`;
61+
} else if (contentType) {
62+
results = await sql`
63+
SELECT id, part_number, section_id, title, content, content_type,
64+
1 - (embedding <=> ${embeddingStr}::vector) as score
65+
FROM spec_content
66+
WHERE embedding IS NOT NULL
67+
AND content_type = ${contentType}
68+
ORDER BY embedding <=> ${embeddingStr}::vector
69+
LIMIT ${limit}
70+
`;
71+
} else {
72+
results = await sql`
73+
SELECT id, part_number, section_id, title, content, content_type,
74+
1 - (embedding <=> ${embeddingStr}::vector) as score
75+
FROM spec_content
76+
WHERE embedding IS NOT NULL
77+
ORDER BY embedding <=> ${embeddingStr}::vector
78+
LIMIT ${limit}
79+
`;
80+
}
81+
82+
return results.map((r) => ({
83+
id: r.id as number,
84+
partNumber: r.part_number as number,
85+
sectionId: r.section_id as string | null,
86+
title: r.title as string | null,
87+
content: r.content as string,
88+
contentType: r.content_type as string,
89+
score: r.score as number,
90+
}));
91+
},
92+
93+
// Get content by section ID
94+
async getBySection(sectionId: string, partNumber?: number): Promise<SpecContent[]> {
95+
const pattern = `${sectionId}%`;
96+
let results: Record<string, unknown>[];
97+
98+
if (partNumber !== undefined) {
99+
results = await sql`
100+
SELECT id, part_number, section_id, title, content, content_type
101+
FROM spec_content
102+
WHERE section_id LIKE ${pattern}
103+
AND part_number = ${partNumber}
104+
ORDER BY section_id, id
105+
`;
106+
} else {
107+
results = await sql`
108+
SELECT id, part_number, section_id, title, content, content_type
109+
FROM spec_content
110+
WHERE section_id LIKE ${pattern}
111+
ORDER BY section_id, id
112+
`;
113+
}
114+
115+
return results.map((r) => ({
116+
id: r.id as number,
117+
partNumber: r.part_number as number,
118+
sectionId: r.section_id as string | null,
119+
title: r.title as string | null,
120+
content: r.content as string,
121+
contentType: r.content_type as string,
122+
}));
123+
},
124+
125+
// Get sections list (for browsing)
126+
async listSections(
127+
partNumber?: number,
128+
): Promise<Array<{ sectionId: string; title: string; partNumber: number }>> {
129+
let results: Record<string, unknown>[];
130+
131+
if (partNumber !== undefined) {
132+
results = await sql`
133+
SELECT DISTINCT section_id, title, part_number
134+
FROM spec_content
135+
WHERE section_id IS NOT NULL AND title IS NOT NULL
136+
AND part_number = ${partNumber}
137+
ORDER BY part_number, section_id
138+
`;
139+
} else {
140+
results = await sql`
141+
SELECT DISTINCT section_id, title, part_number
142+
FROM spec_content
143+
WHERE section_id IS NOT NULL AND title IS NOT NULL
144+
ORDER BY part_number, section_id
145+
`;
146+
}
147+
148+
return results.map((r) => ({
149+
sectionId: r.section_id as string,
150+
title: r.title as string,
151+
partNumber: r.part_number as number,
152+
}));
153+
},
154+
155+
// Get stats
156+
async getStats(): Promise<{ total: number; byPart: Record<number, number> }> {
157+
const results = await sql`
158+
SELECT part_number, COUNT(*) as count
159+
FROM spec_content
160+
GROUP BY part_number
161+
ORDER BY part_number
162+
`;
163+
164+
const byPart: Record<number, number> = {};
165+
let total = 0;
166+
167+
for (const r of results) {
168+
const part = r.part_number as number;
169+
const count = Number(r.count);
170+
byPart[part] = count;
171+
total += count;
172+
}
173+
174+
return { total, byPart };
175+
},
176+
};
177+
}

apps/mcp-server/src/embeddings.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Embedding client for query embedding
3+
*/
4+
5+
export async function embedQuery(text: string, apiKey: string): Promise<number[]> {
6+
const response = await fetch("https://api.voyageai.com/v1/embeddings", {
7+
method: "POST",
8+
headers: {
9+
"Content-Type": "application/json",
10+
Authorization: `Bearer ${apiKey}`,
11+
},
12+
body: JSON.stringify({
13+
input: [text],
14+
model: "voyage-3",
15+
}),
16+
});
17+
18+
if (!response.ok) {
19+
const error = await response.text();
20+
throw new Error(`Voyage embedding failed: ${error}`);
21+
}
22+
23+
const data = (await response.json()) as {
24+
data: Array<{ embedding: number[]; index: number }>;
25+
};
26+
27+
return data.data[0].embedding;
28+
}

0 commit comments

Comments
 (0)