Skip to content

Commit f17f096

Browse files
committed
search docs via algoliasearch
1 parent a58947a commit f17f096

File tree

1 file changed

+86
-130
lines changed

1 file changed

+86
-130
lines changed

src/tools/nextjs-docs.ts

Lines changed: 86 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,120 @@
11
import { z } from "zod"
22
import { type InferSchema } from "xmcp"
3-
import { loadNumberedMarkdownFilesWithNames } from "../_internal/resource-loader"
43

54
export const schema = {
6-
query: z
7-
.string()
8-
.min(1, "Query parameter is required and must be a non-empty string")
9-
.describe("Search query to find relevant Next.js documentation sections"),
10-
category: z
11-
.enum(["all", "getting-started", "guides", "api-reference", "architecture", "community"])
5+
query: z.string().describe("Search query to find relevant Next.js documentation"),
6+
routerType: z
7+
.enum(["all", "app", "pages"])
128
.optional()
13-
.describe("Filter documentation by category (optional)"),
9+
.default("all")
10+
.describe("Filter by router type: 'app' (App Router) or 'pages' (Pages Router)"),
1411
}
1512

1613
export const metadata = {
1714
name: "nextjs_docs",
18-
description: `Search and retrieve Next.js official documentation.
19-
First searches MCP resources (Next.js 16 knowledge base) for latest information, then falls back to official Next.js documentation if nothing is found.
20-
Provides access to comprehensive Next.js guides, API references, and best practices.`,
15+
description: "Search Next.js official documentation",
2116
}
2217

23-
let cachedDocs: { url: string; title: string; category: string }[] | null = null
18+
const ALGOLIA_APP_ID = "NNTAHQI9C5"
19+
const ALGOLIA_API_KEY = "948b42d1edd177a55c6d6ae8dab24621"
20+
21+
const algoliaHitSchema = z.object({
22+
title: z.string(),
23+
content: z.string(),
24+
path: z.string(),
25+
section: z.string().optional(),
26+
anchor: z.string().optional(),
27+
isApp: z.boolean().optional(),
28+
isPages: z.boolean().optional(),
29+
})
30+
31+
const algoliaResponseSchema = z.object({
32+
results: z.array(
33+
z.object({
34+
hits: z.array(algoliaHitSchema),
35+
})
36+
),
37+
})
38+
39+
type AlgoliaHit = z.infer<typeof algoliaHitSchema>
40+
41+
async function searchAlgolia(query: string, filters?: string): Promise<AlgoliaHit[]> {
42+
const response = await fetch("https://NNTAHQI9C5-dsn.algolia.net/1/indexes/*/queries", {
43+
method: "POST",
44+
headers: {
45+
"X-Algolia-API-Key": ALGOLIA_API_KEY,
46+
"X-Algolia-Application-Id": ALGOLIA_APP_ID,
47+
"Content-Type": "application/json",
48+
},
49+
body: JSON.stringify({
50+
requests: [
51+
{
52+
indexName: "nextjs_docs_stable",
53+
query,
54+
params: filters ? `hitsPerPage=10&filters=${filters}` : "hitsPerPage=10",
55+
},
56+
],
57+
}),
58+
})
59+
60+
if (!response.ok) {
61+
throw new Error(`Algolia search failed: ${response.statusText}`)
62+
}
63+
64+
const json = await response.json()
65+
const data = algoliaResponseSchema.parse(json)
66+
return data.results[0]?.hits ?? []
67+
}
2468

25-
async function getNextJsDocs(): Promise<{ url: string; title: string; category: string }[]> {
26-
if (cachedDocs) {
27-
return cachedDocs
69+
function formatSearchResults(hits: AlgoliaHit[], query: string): string {
70+
if (hits.length === 0) {
71+
return `No documentation found for "${query}".`
2872
}
2973

30-
const response = await fetch("https://nextjs.org/docs/llms.txt")
31-
const text = await response.text()
74+
let result = `Found ${hits.length} result(s) for "${query}":\n\n`
3275

33-
const linkRegex = /- \[(.*?)\]\((https:\/\/nextjs\.org\/docs\/.*?)\)/g
34-
const docs: { url: string; title: string; category: string }[] = []
76+
for (const hit of hits) {
77+
result += `## ${hit.title}\n`
3578

36-
let match
37-
while ((match = linkRegex.exec(text)) !== null) {
38-
const title = match[1]
39-
const url = match[2]
79+
if (hit.section && hit.section !== hit.title) {
80+
result += `**Section:** ${hit.section}\n`
81+
}
82+
83+
result += `**URL:** https://nextjs.org${hit.path}${hit.anchor ? `#${hit.anchor}` : ""}\n`
4084

41-
if (!title || !url) {
42-
continue
85+
const routerBadge = hit.isApp ? "[App Router]" : hit.isPages ? "[Pages Router]" : ""
86+
if (routerBadge) {
87+
result += `**Router:** ${routerBadge}\n`
4388
}
4489

45-
let category = "other"
46-
if (url.includes("/getting-started/")) {
47-
category = "getting-started"
48-
} else if (url.includes("/guides/")) {
49-
category = "guides"
50-
} else if (url.includes("/api-reference/")) {
51-
category = "api-reference"
52-
} else if (url.includes("/architecture/")) {
53-
category = "architecture"
54-
} else if (url.includes("/community/")) {
55-
category = "community"
90+
if (hit.content) {
91+
result += `\n${hit.content}\n`
5692
}
5793

58-
docs.push({ url, title, category })
94+
result += "\n---\n\n"
5995
}
6096

61-
cachedDocs = docs
62-
return docs
97+
return result
6398
}
6499

65100
export default async function nextjsDocs({
66101
query,
67-
category = "all",
102+
routerType = "all",
68103
}: InferSchema<typeof schema>): Promise<string> {
69-
const queryLower = query.toLowerCase()
70-
const mdFiles = loadNumberedMarkdownFilesWithNames()
71-
72-
const matches: Array<{ filename: string; content: string; score: number }> = []
73-
74-
for (const { filename, content } of mdFiles) {
75-
let score = 0
76-
77-
if (filename.toLowerCase().includes(queryLower)) {
78-
score += 10
79-
}
80-
81-
if (content.substring(0, 500).toLowerCase().includes(queryLower)) {
82-
score += 5
83-
}
84-
85-
const keywords = [
86-
"cache",
87-
"prefetch",
88-
"public",
89-
"private",
90-
"revalidate",
91-
"invalidation",
92-
"async",
93-
"params",
94-
"searchParams",
95-
"cookies",
96-
"headers",
97-
"connection",
98-
"build",
99-
"prerender",
100-
"metadata",
101-
"error",
102-
"test",
103-
"cacheLife",
104-
"cacheTag",
105-
"updateTag",
106-
]
107-
108-
for (const keyword of keywords) {
109-
if (queryLower.includes(keyword) && content.toLowerCase().includes(keyword)) {
110-
score += 3
111-
}
112-
}
113-
114-
if (score > 0) {
115-
matches.push({ filename, content, score })
116-
}
117-
}
118-
119-
const topMatches = matches.sort((a, b) => b.score - a.score).slice(0, 3)
120-
121-
if (topMatches.length > 0) {
122-
let result = `Found ${topMatches.length} relevant section(s) in Next.js 16 knowledge base:\n\n`
104+
try {
105+
let filters: string | undefined
123106

124-
for (const match of topMatches) {
125-
const title = match.filename.replace(/^\d+-/, "").replace(".md", "").replace(/-/g, " ")
126-
result += `## ${title}\n\n`
127-
128-
const truncatedContent =
129-
match.content.length > 3000
130-
? match.content.substring(0, 3000) + "\n\n...(truncated)"
131-
: match.content
132-
result += `${truncatedContent}\n\n`
133-
result += `---\n\n`
107+
if (routerType === "app") {
108+
filters = "isApp:true"
109+
} else if (routerType === "pages") {
110+
filters = "isPages:true"
134111
}
135112

136-
return result
137-
}
138-
139-
const docs = await getNextJsDocs()
113+
const hits = await searchAlgolia(query, filters)
140114

141-
let filtered = docs
142-
if (category !== "all") {
143-
filtered = docs.filter((doc) => doc.category === category)
115+
return formatSearchResults(hits, query)
116+
} catch (error) {
117+
const errorMessage = error instanceof Error ? error.message : "Unknown error"
118+
return `Error searching Next.js documentation: ${errorMessage}`
144119
}
145-
146-
const results = filtered
147-
.filter(
148-
(doc) =>
149-
doc.title?.toLowerCase().includes(queryLower) || doc.url?.toLowerCase().includes(queryLower)
150-
)
151-
.slice(0, 10)
152-
153-
if (results.length === 0) {
154-
return `No documentation found for "${query}"${
155-
category !== "all" ? ` in category "${category}"` : ""
156-
} in both MCP resources and official Next.js documentation.`
157-
}
158-
159-
return `No matches in MCP resources. Found ${
160-
results.length
161-
} documentation page(s) from official Next.js docs:\n\n${results
162-
.map((doc) => `- [${doc.title}](${doc.url})`)
163-
.join("\n")}`
164120
}

0 commit comments

Comments
 (0)