Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/resolve-hoisted-next-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"next-devtools-mcp": patch
---

Resolve the installed Next.js package from the project so hoisted dependencies report the correct version and absolute documentation path.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Output: JSON with the tool's result.

Does **not** fetch docs. Recent Next.js releases bundle their docs (markdown, matching your installed version) at `node_modules/next/dist/docs/`. This tool checks for those files before returning reading instructions. If an installed release has no bundled docs (including early 16.x releases), it offers https://nextjs.org/docs as a fallback and asks the agent to verify APIs against the installed version. Missing dependencies receive installation guidance instead. On Next.js below 16, it recommends `npx @next/codemod@latest upgrade latest`.

Input: `topic` (optional), `project_path` (optional, defaults to cwd).
Input: `topic` (optional), `project_path` (optional, defaults to cwd). The tool resolves `next/package.json` from that project, including hoisted workspace dependencies, and returns an absolute `docsPath` for the installed package.

</details>

Expand Down
34 changes: 24 additions & 10 deletions src/tools/nextjs-docs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod"
import fs from "node:fs"
import path from "node:path"
import { createRequire } from "node:module"

// Older 16.x releases do not bundle docs. Check package contents before
// directing agents to local files; the major version alone is insufficient.
Expand Down Expand Up @@ -47,20 +48,33 @@ function parseMajor(versionish: string | null | undefined): number | null {
return parseInt(match[1], 10)
}

// Follow the project's module resolution, including hoisted workspace installs.
function resolveNextPackagePath(projectPath: string): string | null {
try {
return createRequire(path.resolve(projectPath, "package.json")).resolve("next/package.json")
} catch {
return null
}
}

function getDocsDirectory(projectPath: string): string {
const packagePath = resolveNextPackagePath(projectPath)
return path.join(
packagePath ? path.dirname(packagePath) : path.resolve(projectPath, "node_modules", "next"),
"dist",
"docs"
)
}

// Resolve the Next.js version for a project, preferring the actually-installed
// version (most accurate) over the declared dependency range.
function resolveNextVersion(projectPath: string): {
version: string | null
source: "installed" | "declared" | null
} {
try {
const installedPkg = path.join(
projectPath,
"node_modules",
"next",
"package.json"
)
if (fs.existsSync(installedPkg)) {
const installedPkg = resolveNextPackagePath(projectPath)
if (installedPkg) {
const { version } = JSON.parse(fs.readFileSync(installedPkg, "utf8"))
if (typeof version === "string") return { version, source: "installed" }
}
Expand Down Expand Up @@ -94,7 +108,7 @@ export async function handler({ topic, project_path }: NextjsDocsArgs): Promise<
: /latest|canary|rc|beta/i.test(version ?? "")

if (isModern) {
const docsDir = path.join(projectPath, "node_modules", "next", "dist", "docs")
const docsDir = getDocsDirectory(projectPath)
const docsExist = fs.existsSync(docsDir)
if (!docsExist) {
const installed = source === "installed"
Expand All @@ -119,13 +133,13 @@ export async function handler({ topic, project_path }: NextjsDocsArgs): Promise<
status: "use_bundled_docs",
nextVersion: version,
versionSource: source,
docsPath: "node_modules/next/dist/docs/",
docsPath: docsDir,
docsAvailable: docsExist,
instructions: [
"Next.js ships its full documentation with the installed package, matching your exact version.",
`Read the relevant guide directly from \`${docsDir}\` (markdown files mirroring the nextjs.org/docs structure).`,
topic
? `For "${topic}", search those files, e.g.: grep -ril "${topic.replace(/"/g, "")}" node_modules/next/dist/docs`
? `For "${topic}", search the markdown files under \`${docsDir}\` for that API or topic.`
: "Browse the directory or grep it for the API/topic you need.",
"Do not rely on training-data knowledge of Next.js APIs — this version may differ. Prefer the bundled docs.",
],
Expand Down
43 changes: 41 additions & 2 deletions test/unit/nextjs-docs-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("nextjs_docs gateway", () => {
expect(result.nextVersion).toBe("16.3.0")
expect(result.versionSource).toBe("installed")
expect(result.docsAvailable).toBe(true)
expect(result.docsPath).toBe("node_modules/next/dist/docs/")
expect(result.docsPath).toBe(fs.realpathSync(path.join(tmpDir, "node_modules/next/dist/docs")))
})

it("treats a canary install as modern", async () => {
Expand Down Expand Up @@ -100,7 +100,7 @@ describe("nextjs_docs gateway", () => {
expect(JSON.stringify(result.instructions)).toContain("16.0.7")
})

it("includes a grep hint when a topic is provided", async () => {
it("includes a search hint when a topic is provided", async () => {
tmpDir = makeProject({ installed: "16.2.0", withDocs: true })
const result = JSON.parse(await handler({ project_path: tmpDir, topic: "use cache" }))
expect(JSON.stringify(result.instructions)).toContain("use cache")
Expand All @@ -119,3 +119,42 @@ it("asks for installation only when a modern dependency is not installed", async
fs.rmSync(dir, { recursive: true, force: true })
}
})

it("resolves installed versions and docs from a hoisted dependency", async () => {
const dir = makeProject({ installed: "16.3.0", withDocs: true })
const child = path.join(dir, "packages", "web")
fs.mkdirSync(child, { recursive: true })
fs.writeFileSync(
path.join(child, "package.json"),
JSON.stringify({ dependencies: { next: "^16.0.0" } })
)
try {
const result = JSON.parse(await handler({ project_path: child, topic: "use cache" }))
expect(result.versionSource).toBe("installed")
expect(result.nextVersion).toBe("16.3.0")
expect(result.docsAvailable).toBe(true)
expect(result.docsPath).toBe(fs.realpathSync(path.join(dir, "node_modules/next/dist/docs")))
expect(result.instructions[1]).toContain(result.docsPath)
expect(result.instructions[2]).toContain(result.docsPath)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})

it("prefers a child-local installation over an ancestor installation", async () => {
const dir = makeProject({ installed: "16.3.0", withDocs: true })
const child = path.join(dir, "packages", "web")
const pkg = path.join(child, "node_modules", "next")
fs.mkdirSync(path.join(pkg, "dist", "docs"), { recursive: true })
fs.writeFileSync(
path.join(pkg, "package.json"),
JSON.stringify({ name: "next", version: "16.2.0" })
)
try {
const result = JSON.parse(await handler({ project_path: child }))
expect(result.nextVersion).toBe("16.2.0")
expect(result.docsPath).toBe(fs.realpathSync(path.join(pkg, "dist", "docs")))
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
Loading