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/cancel-upstream-runtime-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"next-devtools-mcp": patch
---

Propagate MCP cancellation to upstream requests, bound response reads with a deadline, and release unused probe response bodies.
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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Input:
{ "port": 3000, "toolName": "get_errors" }
```

Output: JSON with the tool's result.
Output: JSON with the tool's result. Upstream requests, including response-body reads, have a 60-second deadline. Cancelling an MCP request aborts its upstream network work; discovery also releases response bodies used only to detect the protocol.

</details>

Expand All @@ -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
91 changes: 65 additions & 26 deletions src/_internal/nextjs-runtime-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { Agent as UndiciAgent } from "undici"

const execAsync = promisify(exec)

export interface RuntimeRequestOptions {
signal?: AbortSignal
timeoutMs?: number
}

const REQUEST_TIMEOUT_MS = 60_000

function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
const deadline = AbortSignal.timeout(timeoutMs)
return signal ? AbortSignal.any([signal, deadline]) : deadline
}

interface NextJsServerInfo {
port: number
pid: number
Expand Down Expand Up @@ -62,13 +74,13 @@ function getFetchOptions(protocol: "http" | "https") {
async function probeMCPEndpoint(
port: number,
protocol: "http" | "https",
timeoutMs: number = 500
): Promise<Response | null> {
timeoutMs: number = 500,
signal?: AbortSignal
): Promise<Pick<Response, "ok" | "status"> | null> {
try {
const url = `${protocol}://${MCP_HOST}:${port}/_next/mcp`
const fetchOptions = getFetchOptions(protocol)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
signal?.throwIfAborted()

const response = await fetch(url, {
...fetchOptions,
Expand All @@ -83,12 +95,15 @@ async function probeMCPEndpoint(
params: {},
id: 1,
}),
signal: controller.signal,
signal: requestSignal(timeoutMs, signal),
})

clearTimeout(timeoutId)
return response
// Only the status is used by protocol detection. Do not leave a streaming
// probe body (and its socket) open after returning.
await response.body?.cancel()
return { ok: response.ok, status: response.status }
} catch {
signal?.throwIfAborted()
return null
}
}
Expand All @@ -98,11 +113,16 @@ async function probeMCPEndpoint(
* Returns the successful protocol if found, null otherwise
* Also caches the detected protocol
*/
async function probePort(port: number, timeoutMs: number = 500): Promise<"http" | "https" | null> {
async function probePort(
port: number,
timeoutMs: number = 500,
signal?: AbortSignal
): Promise<"http" | "https" | null> {
signal?.throwIfAborted()
// Check cache first
if (protocolCache.has(port)) {
const cachedProtocol = protocolCache.get(port)!
const response = await probeMCPEndpoint(port, cachedProtocol, timeoutMs)
const response = await probeMCPEndpoint(port, cachedProtocol, timeoutMs, signal)
if (response?.ok) {
return cachedProtocol
}
Expand All @@ -112,7 +132,7 @@ async function probePort(port: number, timeoutMs: number = 500): Promise<"http"

// Try HTTP first (more common for local dev)
for (const protocol of ["http", "https"] as const) {
const response = await probeMCPEndpoint(port, protocol, timeoutMs)
const response = await probeMCPEndpoint(port, protocol, timeoutMs, signal)
if (response && response.status !== 404) {
protocolCache.set(port, protocol)
if (response.ok) {
Expand All @@ -128,12 +148,16 @@ async function probePort(port: number, timeoutMs: number = 500): Promise<"http"
* Detect protocol for a port (for use when making requests)
* Returns cached protocol or defaults to http
*/
async function detectProtocol(port: number): Promise<"http" | "https"> {
async function detectProtocol(
port: number,
options: RuntimeRequestOptions = {}
): Promise<"http" | "https"> {
options.signal?.throwIfAborted()
if (protocolCache.has(port)) {
return protocolCache.get(port)!
}

const protocol = await probePort(port)
const protocol = await probePort(port, 500, options.signal)
return protocol ?? "http"
}

Expand Down Expand Up @@ -300,9 +324,12 @@ async function findNextJsServers(): Promise<NextJsServerInfo[]> {
async function makeNextJsMCPRequest(
port: number,
method: string,
params: Record<string, unknown> = {}
params: Record<string, unknown> = {},
options: RuntimeRequestOptions = {}
): Promise<NextJsMCPResponse> {
const protocol = await detectProtocol(port)
const signal = requestSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal)
signal.throwIfAborted()
const protocol = await detectProtocol(port, { signal })
const url = `${protocol}://${MCP_HOST}:${port}/_next/mcp`
const fetchOptions = getFetchOptions(protocol)

Expand All @@ -322,6 +349,7 @@ async function makeNextJsMCPRequest(
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(jsonRpcRequest),
signal,
})

if (!response.ok) {
Expand Down Expand Up @@ -372,11 +400,15 @@ async function makeNextJsMCPRequest(
}
}

export async function listNextJsTools(port: number): Promise<NextJsMCPTool[]> {
export async function listNextJsTools(
port: number,
options: RuntimeRequestOptions = {}
): Promise<NextJsMCPTool[]> {
try {
const response = await makeNextJsMCPRequest(port, "tools/list", {})
const response = await makeNextJsMCPRequest(port, "tools/list", {}, options)
return response.result?.tools || []
} catch (error) {
options.signal?.throwIfAborted()
console.error("[Next.js Runtime Manager] Error listing tools:", error)
return []
}
Expand All @@ -385,13 +417,16 @@ export async function listNextJsTools(port: number): Promise<NextJsMCPTool[]> {
export async function callNextJsTool(
port: number,
toolName: string,
args: Record<string, unknown>
args: Record<string, unknown>,
options: RuntimeRequestOptions = {}
): Promise<unknown> {
try {
const response = await makeNextJsMCPRequest(port, "tools/call", {
name: toolName,
arguments: args,
})
const response = await makeNextJsMCPRequest(
port,
"tools/call",
{ name: toolName, arguments: args },
options
)

return response.result
} catch (error) {
Expand All @@ -410,13 +445,13 @@ const COMMON_PORTS = [3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009
* Discover Next.js servers by probing common ports
* This is more reliable than process discovery on some OS
*/
async function discoverViaPortProbing(): Promise<NextJsServerInfo[]> {
async function discoverViaPortProbing(options: RuntimeRequestOptions): Promise<NextJsServerInfo[]> {
const servers: NextJsServerInfo[] = []

// Probe all common ports in parallel for speed
const results = await Promise.all(
COMMON_PORTS.map(async (port) => {
const protocol = await probePort(port)
const protocol = await probePort(port, 500, options.signal)
if (protocol) {
return {
port,
Expand All @@ -437,12 +472,15 @@ async function discoverViaPortProbing(): Promise<NextJsServerInfo[]> {
return servers
}

export async function getAllAvailableServers(): Promise<NextJsServerInfo[]> {
export async function getAllAvailableServers(
options: RuntimeRequestOptions = {}
): Promise<NextJsServerInfo[]> {
options.signal?.throwIfAborted()
const seenPorts = new Set<number>()
const allServers: NextJsServerInfo[] = []

// Step 1: Probe common ports first (most reliable, works on all OS)
const portProbedServers = await discoverViaPortProbing()
const portProbedServers = await discoverViaPortProbing(options)
for (const server of portProbedServers) {
if (!seenPorts.has(server.port)) {
seenPorts.add(server.port)
Expand All @@ -452,14 +490,15 @@ export async function getAllAvailableServers(): Promise<NextJsServerInfo[]> {

// Step 2: Also try process discovery to find servers on non-standard ports
const processServers = await findNextJsServers()
options.signal?.throwIfAborted()

// Filter to servers not already found via port probing
const newServers = processServers.filter(server => !seenPorts.has(server.port))

// Verify MCP for process-discovered servers in parallel
const verifiedServers = await Promise.all(
newServers.map(async (server) => {
const hasMCP = await probePort(server.port, 1000)
const hasMCP = await probePort(server.port, 1000, options.signal)
return hasMCP ? server : null
})
)
Expand Down
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}
})

server.setRequestHandler(CallToolRequestSchema, async (request) => {
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args } = request.params

const tool = tools.find((t) => t.metadata.name === name)
Expand All @@ -81,8 +81,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const parsedArgs = parseToolArgs(tool.inputSchema, args || {})

const result = await (tool.handler as (
args: Record<string, unknown>
) => Promise<string | CallToolResult>)(parsedArgs)
args: Record<string, unknown>,
options: { signal: AbortSignal }
) => Promise<string | CallToolResult>)(parsedArgs, { signal: extra.signal })

if (typeof result !== "string") return result

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
9 changes: 6 additions & 3 deletions src/tools/nextjs_call.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { callNextJsTool } from "../_internal/nextjs-runtime-manager.js"
import { callNextJsTool, type RuntimeRequestOptions } from "../_internal/nextjs-runtime-manager.js"

export const inputSchema = {
port: z
Expand Down Expand Up @@ -64,7 +64,10 @@ function toolResult(payload: Record<string, unknown>): CallToolResult {
}
}

export async function handler(args: NextjsCallArgs): Promise<CallToolResult> {
export async function handler(
args: NextjsCallArgs,
options: RuntimeRequestOptions = {}
): Promise<CallToolResult> {
try {
if (!args.port) {
return toolResult({
Expand All @@ -85,7 +88,7 @@ export async function handler(args: NextjsCallArgs): Promise<CallToolResult> {
// Ensure port is a number
const portNumber = typeof args.port === "string" ? parseInt(args.port, 10) : args.port

const result = await callNextJsTool(portNumber, args.toolName, args.args || {})
const result = await callNextJsTool(portNumber, args.toolName, args.args || {}, options)

return toolResult({
success: !(
Expand Down
Loading
Loading