Skip to content

Commit 4937d34

Browse files
authored
Use official docs search API to implement nextjs_docs MCP tool (#68)
1 parent 37bcf17 commit 4937d34

File tree

1 file changed

+100
-132
lines changed

1 file changed

+100
-132
lines changed

src/tools/nextjs-docs.ts

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

54
export const schema = {
5+
action: z
6+
.enum(["search", "get"])
7+
.describe(
8+
"Action to perform: 'search' to find docs by keyword, 'get' to fetch full markdown content"
9+
),
610
query: z
711
.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"])
1212
.optional()
13-
.describe("Filter documentation by category (optional)"),
13+
.describe(
14+
"Required for 'search' action. Keyword search query (e.g., 'metadata', 'generateStaticParams', 'middleware'). Use specific terms, not natural language questions."
15+
),
16+
path: z
17+
.string()
18+
.optional()
19+
.describe(
20+
"Required for 'get' action. Doc path from search results (e.g., '/docs/app/api-reference/functions/refresh')"
21+
),
22+
anchor: z
23+
.string()
24+
.optional()
25+
.describe(
26+
"Optional for 'get' action. Anchor/section from search results (e.g., 'usage'). Included in response metadata to indicate relevant section."
27+
),
28+
routerType: z
29+
.enum(["all", "app", "pages"])
30+
.default("all")
31+
.describe(
32+
"For 'search' action only. Filter by Next.js router type: 'app' (App Router only), 'pages' (Pages Router only), or 'all' (both)"
33+
),
1434
}
1535

1636
export const metadata = {
1737
name: "nextjs_docs",
1838
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.`,
21-
}
22-
23-
let cachedDocs: { url: string; title: string; category: string }[] | null = null
24-
25-
async function getNextJsDocs(): Promise<{ url: string; title: string; category: string }[]> {
26-
if (cachedDocs) {
27-
return cachedDocs
28-
}
29-
30-
const response = await fetch("https://nextjs.org/docs/llms.txt")
31-
const text = await response.text()
32-
33-
const linkRegex = /- \[(.*?)\]\((https:\/\/nextjs\.org\/docs\/.*?)\)/g
34-
const docs: { url: string; title: string; category: string }[] = []
35-
36-
let match
37-
while ((match = linkRegex.exec(text)) !== null) {
38-
const title = match[1]
39-
const url = match[2]
40-
41-
if (!title || !url) {
42-
continue
43-
}
44-
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"
56-
}
57-
58-
docs.push({ url, title, category })
59-
}
60-
61-
cachedDocs = docs
62-
return docs
39+
Two-step process: 1) Use action='search' with a keyword query to find relevant docs and get their paths. 2) Use action='get' with a specific path to fetch the full markdown content.
40+
Use specific API names, concepts, or feature names as search terms.`,
6341
}
6442

6543
export default async function nextjsDocs({
44+
action,
6645
query,
67-
category = "all",
46+
path,
47+
anchor,
48+
routerType = "all",
6849
}: 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
50+
if (action === "search") {
51+
if (!query) {
52+
throw new Error("query parameter is required for search action")
53+
}
7654

77-
if (filename.toLowerCase().includes(queryLower)) {
78-
score += 10
55+
// Construct filters based on router type
56+
let filters = "isPages:true OR isApp:true"
57+
if (routerType === "app") {
58+
filters = "isApp:true"
59+
} else if (routerType === "pages") {
60+
filters = "isPages:true"
7961
}
8062

81-
if (content.substring(0, 500).toLowerCase().includes(queryLower)) {
82-
score += 5
63+
// Call Next.js search API
64+
const response = await fetch("https://nextjs.org/api/search", {
65+
method: "POST",
66+
headers: {
67+
"content-type": "application/json",
68+
},
69+
body: JSON.stringify({
70+
indexName: "nextjs_docs_stable",
71+
query,
72+
filters,
73+
}),
74+
})
75+
76+
if (!response.ok) {
77+
throw new Error(`Next.js docs API error: ${response.status} ${response.statusText}`)
8378
}
8479

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-
]
80+
const { hits = [] } = await response.json()
10781

108-
for (const keyword of keywords) {
109-
if (queryLower.includes(keyword) && content.toLowerCase().includes(keyword)) {
110-
score += 3
111-
}
82+
if (hits.length === 0) {
83+
return JSON.stringify({
84+
query,
85+
routerType,
86+
results: [],
87+
message: "No documentation found.",
88+
})
11289
}
11390

114-
if (score > 0) {
115-
matches.push({ filename, content, score })
91+
// Extract only essential fields to reduce payload
92+
const results = hits.map((hit: any) => ({
93+
title: hit.title,
94+
path: hit.path,
95+
content: hit.content,
96+
section: hit.section,
97+
anchor: hit.anchor,
98+
routerType: hit.isApp ? "app" : hit.isPages ? "pages" : "unknown",
99+
}))
100+
101+
return JSON.stringify({
102+
query,
103+
routerType,
104+
results,
105+
})
106+
} else if (action === "get") {
107+
if (!path) {
108+
throw new Error("path parameter is required for get action")
116109
}
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`
123110

124-
for (const match of topMatches) {
125-
const title = match.filename.replace(/^\d+-/, "").replace(".md", "").replace(/-/g, " ")
126-
result += `## ${title}\n\n`
111+
const url = `https://nextjs.org${path}`
112+
const response = await fetch(url, {
113+
headers: {
114+
Accept: "text/markdown",
115+
},
116+
})
127117

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`
118+
if (!response.ok) {
119+
throw new Error(`Failed to fetch documentation: ${response.status} ${response.statusText}`)
134120
}
135121

136-
return result
122+
const markdown = await response.text()
123+
return JSON.stringify({
124+
path,
125+
anchor: anchor || null,
126+
url: anchor ? `https://nextjs.org${path}#${anchor}` : `https://nextjs.org${path}`,
127+
content: markdown,
128+
})
129+
} else {
130+
throw new Error(`Invalid action: ${action}`)
137131
}
138-
139-
const docs = await getNextJsDocs()
140-
141-
let filtered = docs
142-
if (category !== "all") {
143-
filtered = docs.filter((doc) => doc.category === category)
144-
}
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")}`
164132
}

0 commit comments

Comments
 (0)