|  | 
| 1 | 1 | import { z } from "zod" | 
| 2 | 2 | import { type InferSchema } from "xmcp" | 
| 3 |  | -import { loadNumberedMarkdownFilesWithNames } from "../_internal/resource-loader" | 
| 4 | 3 | 
 | 
| 5 | 4 | 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"]) | 
| 12 | 8 |     .optional() | 
| 13 |  | -    .describe("Filter documentation by category (optional)"), | 
|  | 9 | +    .default("all") | 
|  | 10 | +    .describe("Filter by router type: 'app' (App Router) or 'pages' (Pages Router)"), | 
| 14 | 11 | } | 
| 15 | 12 | 
 | 
| 16 | 13 | export const metadata = { | 
| 17 | 14 |   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", | 
| 21 | 16 | } | 
| 22 | 17 | 
 | 
| 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 | +} | 
| 24 | 68 | 
 | 
| 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}".` | 
| 28 | 72 |   } | 
| 29 | 73 | 
 | 
| 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` | 
| 32 | 75 | 
 | 
| 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` | 
| 35 | 78 | 
 | 
| 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` | 
| 40 | 84 | 
 | 
| 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` | 
| 43 | 88 |     } | 
| 44 | 89 | 
 | 
| 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` | 
| 56 | 92 |     } | 
| 57 | 93 | 
 | 
| 58 |  | -    docs.push({ url, title, category }) | 
|  | 94 | +    result += "\n---\n\n" | 
| 59 | 95 |   } | 
| 60 | 96 | 
 | 
| 61 |  | -  cachedDocs = docs | 
| 62 |  | -  return docs | 
|  | 97 | +  return result | 
| 63 | 98 | } | 
| 64 | 99 | 
 | 
| 65 | 100 | export default async function nextjsDocs({ | 
| 66 | 101 |   query, | 
| 67 |  | -  category = "all", | 
|  | 102 | +  routerType = "all", | 
| 68 | 103 | }: 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 | 
| 123 | 106 | 
 | 
| 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" | 
| 134 | 111 |     } | 
| 135 | 112 | 
 | 
| 136 |  | -    return result | 
| 137 |  | -  } | 
| 138 |  | - | 
| 139 |  | -  const docs = await getNextJsDocs() | 
|  | 113 | +    const hits = await searchAlgolia(query, filters) | 
| 140 | 114 | 
 | 
| 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}` | 
| 144 | 119 |   } | 
| 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")}` | 
| 164 | 120 | } | 
0 commit comments