Skip to content

Commit 2d57155

Browse files
committed
Creating searchable docs mcp server
1 parent a14a18d commit 2d57155

File tree

8 files changed

+126673
-3823
lines changed

8 files changed

+126673
-3823
lines changed

app/api/[transport]/route.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {createMcpHandler} from "mcp-handler";
2+
import {z} from "zod";
3+
4+
import {formatMatchAsBlock, searchIndex} from "../search/searchIndex";
5+
import {readDocContent} from "../shared/docs-utils";
6+
7+
const handler = createMcpHandler(
8+
(server) => {
9+
server.tool(
10+
"search_docs",
11+
"Search the precomputed markdown index and return matching documentation entry points.",
12+
{
13+
query: z.string().min(1),
14+
limit: z.number().int().min(1).max(25).default(5),
15+
},
16+
async ({query, limit}) => {
17+
const matches = await searchIndex(query, limit);
18+
const contentText = matches.length
19+
? matches.map(formatMatchAsBlock).join("\n\n")
20+
: "No matches found.";
21+
22+
return {
23+
content: [{type: "text", text: contentText}],
24+
};
25+
}
26+
);
27+
28+
server.tool(
29+
"get_doc",
30+
"Fetch raw markdown from the documentation exports. Reads local files when available, otherwise fetches from DOCS_PUBLIC_BASE.",
31+
{
32+
path: z.string().min(1),
33+
},
34+
async ({path}) => {
35+
const content = await readDocContent(path);
36+
return {
37+
content: [{type: "text", text: content}],
38+
};
39+
}
40+
);
41+
},
42+
{
43+
// Optional server options
44+
},
45+
{
46+
basePath: "/api",
47+
maxDuration: 60,
48+
verboseLogs: false,
49+
}
50+
);
51+
52+
function normalizeRequest(request: Request): Request {
53+
const url = new URL(request.url);
54+
if (url.pathname.endsWith("/") && url.pathname.length > 1) {
55+
url.pathname = url.pathname.slice(0, -1);
56+
}
57+
58+
return new Request(url.toString(), {
59+
method: request.method,
60+
headers: request.headers,
61+
body: request.body,
62+
// @ts-ignore - duplex is needed for streaming
63+
duplex: "half",
64+
});
65+
}
66+
67+
function wrappedHandler(request: Request) {
68+
const normalizedRequest = normalizeRequest(request);
69+
return handler(normalizedRequest);
70+
}
71+
72+
export {wrappedHandler as GET, wrappedHandler as POST, wrappedHandler as DELETE};

app/api/search/route.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {NextRequest, NextResponse} from "next/server";
2+
3+
import {mapMatchToResponse, searchIndex} from "./searchIndex";
4+
5+
export const runtime = "nodejs";
6+
7+
export async function GET(request: NextRequest) {
8+
const {searchParams} = new URL(request.url);
9+
const query = searchParams.get("q") ?? "";
10+
const limitParam = searchParams.get("limit");
11+
const limit = limitParam ? Math.min(25, Math.max(1, Number(limitParam))) : 10;
12+
13+
try {
14+
const matches = await searchIndex(query, limit);
15+
const results = matches.map(mapMatchToResponse);
16+
17+
return NextResponse.json({
18+
query,
19+
limit,
20+
count: results.length,
21+
results,
22+
});
23+
} catch (error) {
24+
return NextResponse.json(
25+
{
26+
query,
27+
limit,
28+
error: error instanceof Error ? error.message : "Unknown error",
29+
},
30+
{status: 500}
31+
);
32+
}
33+
}

app/api/search/searchIndex.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import {promises as fs} from "node:fs";
2+
import path from "node:path";
3+
4+
import {buildDocUrl} from "../shared/docs-utils";
5+
6+
const SEARCH_INDEX_PATH = path.join(process.cwd(), "public", "search-index.json");
7+
8+
type RawSearchIndexEntry = {
9+
path: string;
10+
title: string;
11+
hierarchy: string[];
12+
summary: string;
13+
content: string;
14+
};
15+
16+
type SearchIndexFile = {
17+
generatedAt: string;
18+
total: number;
19+
entries: RawSearchIndexEntry[];
20+
};
21+
22+
export type SearchMatch = {
23+
path: string;
24+
title: string;
25+
hierarchy: string[];
26+
summary: string;
27+
snippet: string | null;
28+
score: number;
29+
matchedTokens: number;
30+
};
31+
32+
type CachedEntry = RawSearchIndexEntry & {
33+
pathLower: string;
34+
titleLower: string;
35+
hierarchyLower: string[];
36+
contentLower: string;
37+
};
38+
39+
let searchIndexPromise: Promise<CachedEntry[]> | null = null;
40+
41+
async function loadSearchIndexInternal(): Promise<CachedEntry[]> {
42+
const raw = await fs.readFile(SEARCH_INDEX_PATH, "utf8");
43+
const parsed = JSON.parse(raw) as SearchIndexFile;
44+
return parsed.entries.map(entry => ({
45+
...entry,
46+
pathLower: entry.path.toLowerCase(),
47+
titleLower: entry.title.toLowerCase(),
48+
hierarchyLower: entry.hierarchy.map(segment => segment.toLowerCase()),
49+
contentLower: entry.content.toLowerCase(),
50+
}));
51+
}
52+
53+
export async function ensureSearchIndex(): Promise<CachedEntry[]> {
54+
if (!searchIndexPromise) {
55+
searchIndexPromise = loadSearchIndexInternal().catch(error => {
56+
searchIndexPromise = null;
57+
throw error;
58+
});
59+
}
60+
61+
return searchIndexPromise;
62+
}
63+
64+
function scoreEntry(entry: CachedEntry, tokens: string[]) {
65+
let score = 0;
66+
let matchedTokens = 0;
67+
68+
for (const token of tokens) {
69+
let tokenMatched = false;
70+
71+
if (entry.titleLower.includes(token)) {
72+
score += 6;
73+
tokenMatched = true;
74+
}
75+
76+
if (entry.pathLower.includes(token)) {
77+
score += 4;
78+
tokenMatched = true;
79+
}
80+
81+
if (entry.hierarchyLower.some(segment => segment.includes(token))) {
82+
score += 3;
83+
tokenMatched = true;
84+
}
85+
86+
if (entry.contentLower.includes(token)) {
87+
score += 1;
88+
tokenMatched = true;
89+
}
90+
91+
if (tokenMatched) {
92+
matchedTokens += 1;
93+
}
94+
}
95+
96+
if (matchedTokens === 0) {
97+
return null;
98+
}
99+
100+
score += getInstallBias(entry);
101+
102+
return {score, matchedTokens};
103+
}
104+
105+
function buildSnippet(entry: CachedEntry, tokens: string[]): string | null {
106+
const lines = entry.content.split(/\r?\n/);
107+
for (const line of lines) {
108+
const lineLower = line.toLowerCase();
109+
if (tokens.some(token => lineLower.includes(token))) {
110+
const trimmed = line.trim();
111+
if (trimmed.length === 0) {
112+
continue;
113+
}
114+
return trimmed.length > 200 ? `${trimmed.slice(0, 199)}…` : trimmed;
115+
}
116+
}
117+
return null;
118+
}
119+
120+
export async function searchIndex(query: string, limit: number): Promise<SearchMatch[]> {
121+
const tokens = query
122+
.toLowerCase()
123+
.split(/\s+/)
124+
.map(token => token.trim())
125+
.filter(Boolean);
126+
127+
if (tokens.length === 0) {
128+
return [];
129+
}
130+
131+
const entries = await ensureSearchIndex();
132+
const matches: SearchMatch[] = [];
133+
134+
for (const entry of entries) {
135+
const scoreResult = scoreEntry(entry, tokens);
136+
if (!scoreResult) {
137+
continue;
138+
}
139+
140+
matches.push({
141+
path: entry.path,
142+
title: entry.title,
143+
hierarchy: entry.hierarchy,
144+
summary: entry.summary,
145+
snippet: buildSnippet(entry, tokens),
146+
score: scoreResult.score,
147+
matchedTokens: scoreResult.matchedTokens,
148+
});
149+
}
150+
151+
matches.sort((a, b) => {
152+
if (b.score !== a.score) {
153+
return b.score - a.score;
154+
}
155+
if (b.matchedTokens !== a.matchedTokens) {
156+
return b.matchedTokens - a.matchedTokens;
157+
}
158+
return a.path.localeCompare(b.path);
159+
});
160+
161+
return matches.slice(0, limit);
162+
}
163+
164+
function getInstallBias(entry: CachedEntry): number {
165+
const segments = entry.pathLower.split("/");
166+
const fileName = segments[segments.length - 1] ?? "";
167+
const baseName = fileName.replace(/\.md$/, "");
168+
169+
let bias = 0;
170+
171+
// Top-level platform doc like "platforms/react.md"
172+
if (segments[0] === "platforms" && segments.length === 2) {
173+
bias += 40;
174+
}
175+
176+
// JavaScript guide root doc like "platforms/javascript/guides/react.md"
177+
if (
178+
segments[0] === "platforms" &&
179+
segments[1] === "javascript" &&
180+
segments[2] === "guides" &&
181+
segments.length === 4
182+
) {
183+
bias += 50;
184+
}
185+
186+
// Files under an install directory get a boost
187+
if (segments.includes("install")) {
188+
bias += 20;
189+
}
190+
191+
// Common install filenames get additional weight
192+
if (["install", "installation", "setup", "getting-started"].includes(baseName)) {
193+
bias += 25;
194+
}
195+
196+
return bias;
197+
}
198+
199+
export function formatMatchAsBlock(match: SearchMatch): string {
200+
const header = `# ${match.hierarchy.join(" > ")}`;
201+
const link = `[${match.title}](${match.path})`;
202+
const lines = [header, link];
203+
204+
if (match.snippet) {
205+
lines.push(match.snippet);
206+
}
207+
208+
return lines.join("\n");
209+
}
210+
211+
export function mapMatchToResponse(match: SearchMatch) {
212+
return {
213+
path: match.path,
214+
title: match.title,
215+
hierarchy: match.hierarchy,
216+
summary: match.summary,
217+
snippet: match.snippet,
218+
url: buildDocUrl(match.path),
219+
score: match.score,
220+
matchedTokens: match.matchedTokens,
221+
};
222+
}

app/api/shared/docs-utils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {promises as fs} from "node:fs";
2+
import path from "node:path";
3+
4+
export const MD_EXPORTS_ROOT = path.join(process.cwd(), "public", "md-exports");
5+
export const DOCS_PUBLIC_BASE = process.env.DOCS_PUBLIC_BASE ?? "https://docs.sentry.io";
6+
7+
export function normalizeDocPath(inputPath: string): string {
8+
const trimmed = inputPath.trim();
9+
const withoutLeadingSlash = trimmed.replace(/^\/+/, "");
10+
const normalized = path.normalize(withoutLeadingSlash);
11+
12+
if (normalized.startsWith("..")) {
13+
throw new Error("Invalid doc path: outside allowed directory");
14+
}
15+
16+
return normalized;
17+
}
18+
19+
export function buildDocUrl(docPath: string): string {
20+
const normalized = normalizeDocPath(docPath);
21+
const base = DOCS_PUBLIC_BASE.endsWith("/") ? DOCS_PUBLIC_BASE : `${DOCS_PUBLIC_BASE}/`;
22+
const url = new URL(normalized, base);
23+
return url.toString();
24+
}
25+
26+
export async function readDocContent(docPath: string): Promise<string> {
27+
const normalized = normalizeDocPath(docPath);
28+
const localPath = path.join(MD_EXPORTS_ROOT, normalized);
29+
30+
try {
31+
const file = await fs.readFile(localPath, "utf8");
32+
return file;
33+
} catch (localError) {
34+
const url = buildDocUrl(normalized);
35+
36+
const response = await fetch(url);
37+
if (!response.ok) {
38+
throw new Error(`Failed to fetch doc from ${url}: ${response.status} ${response.statusText}`);
39+
}
40+
41+
return await response.text();
42+
}
43+
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@google-cloud/storage": "^7.7.0",
4343
"@mdx-js/loader": "^3.0.0",
4444
"@mdx-js/react": "^3.0.0",
45+
"@modelcontextprotocol/sdk": "^1.18.1",
4546
"@pondorasti/remark-img-links": "^1.0.8",
4647
"@popperjs/core": "^2.11.8",
4748
"@prettier/plugin-xml": "^3.3.1",
@@ -70,6 +71,7 @@
7071
"js-cookie": "^3.0.5",
7172
"js-yaml": "^4.1.0",
7273
"match-sorter": "^6.3.4",
74+
"mcp-handler": "^1.0.2",
7375
"mdx-bundler": "^10.0.1",
7476
"mermaid": "^11.11.0",
7577
"micromark": "^4.0.0",
@@ -110,7 +112,8 @@
110112
"tailwindcss-scoped-preflight": "^3.0.4",
111113
"textarea-markdown-editor": "^1.0.4",
112114
"unified": "^11.0.5",
113-
"unist-util-remove": "^4.0.0"
115+
"unist-util-remove": "^4.0.0",
116+
"zod": "^3.25.76"
114117
},
115118
"devDependencies": {
116119
"@babel/preset-typescript": "^7.15.0",

0 commit comments

Comments
 (0)