From 5313ad8871a4676800f1d1d1e4da02606e513485 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Wed, 9 Sep 2026 10:14:50 -0700 Subject: [PATCH 1/2] fix: resolve docs from hoisted Next.js installations --- .changeset/resolve-hoisted-next-docs.md | 5 +++ README.md | 2 +- src/tools/nextjs-docs.ts | 34 +++++++++++++------ test/unit/nextjs-docs-gateway.test.ts | 43 +++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 .changeset/resolve-hoisted-next-docs.md diff --git a/.changeset/resolve-hoisted-next-docs.md b/.changeset/resolve-hoisted-next-docs.md new file mode 100644 index 0000000..8d01247 --- /dev/null +++ b/.changeset/resolve-hoisted-next-docs.md @@ -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. diff --git a/README.md b/README.md index ce6921d..f0fc7a5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/tools/nextjs-docs.ts b/src/tools/nextjs-docs.ts index 1067a60..943d719 100644 --- a/src/tools/nextjs-docs.ts +++ b/src/tools/nextjs-docs.ts @@ -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. @@ -47,6 +48,24 @@ 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): { @@ -54,13 +73,8 @@ function resolveNextVersion(projectPath: string): { 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" } } @@ -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" @@ -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.", ], diff --git a/test/unit/nextjs-docs-gateway.test.ts b/test/unit/nextjs-docs-gateway.test.ts index 0a36ecb..b243ae6 100644 --- a/test/unit/nextjs-docs-gateway.test.ts +++ b/test/unit/nextjs-docs-gateway.test.ts @@ -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 () => { @@ -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") @@ -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 }) + } +}) From 920778c72497be57a520fc80c17e1aad5279b3ad Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Wed, 9 Sep 2026 10:16:04 -0700 Subject: [PATCH 2/2] fix: cancel and bound upstream runtime requests --- .../cancel-upstream-runtime-requests.md | 5 + README.md | 2 +- src/_internal/nextjs-runtime-manager.ts | 91 +++++++++---- src/index.ts | 7 +- src/tools/nextjs_call.ts | 9 +- src/tools/nextjs_index.ts | 20 +-- test/unit/runtime-request-lifecycle.test.ts | 127 ++++++++++++++++++ 7 files changed, 220 insertions(+), 41 deletions(-) create mode 100644 .changeset/cancel-upstream-runtime-requests.md create mode 100644 test/unit/runtime-request-lifecycle.test.ts diff --git a/.changeset/cancel-upstream-runtime-requests.md b/.changeset/cancel-upstream-runtime-requests.md new file mode 100644 index 0000000..918e4b2 --- /dev/null +++ b/.changeset/cancel-upstream-runtime-requests.md @@ -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. diff --git a/README.md b/README.md index f0fc7a5..ea54522 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/_internal/nextjs-runtime-manager.ts b/src/_internal/nextjs-runtime-manager.ts index d35a122..e305f6d 100644 --- a/src/_internal/nextjs-runtime-manager.ts +++ b/src/_internal/nextjs-runtime-manager.ts @@ -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 @@ -62,13 +74,13 @@ function getFetchOptions(protocol: "http" | "https") { async function probeMCPEndpoint( port: number, protocol: "http" | "https", - timeoutMs: number = 500 -): Promise { + timeoutMs: number = 500, + signal?: AbortSignal +): Promise | 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, @@ -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 } } @@ -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 } @@ -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) { @@ -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" } @@ -300,9 +324,12 @@ async function findNextJsServers(): Promise { async function makeNextJsMCPRequest( port: number, method: string, - params: Record = {} + params: Record = {}, + options: RuntimeRequestOptions = {} ): Promise { - 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) @@ -322,6 +349,7 @@ async function makeNextJsMCPRequest( Accept: "application/json, text/event-stream", }, body: JSON.stringify(jsonRpcRequest), + signal, }) if (!response.ok) { @@ -372,11 +400,15 @@ async function makeNextJsMCPRequest( } } -export async function listNextJsTools(port: number): Promise { +export async function listNextJsTools( + port: number, + options: RuntimeRequestOptions = {} +): Promise { 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 [] } @@ -385,13 +417,16 @@ export async function listNextJsTools(port: number): Promise { export async function callNextJsTool( port: number, toolName: string, - args: Record + args: Record, + options: RuntimeRequestOptions = {} ): Promise { 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) { @@ -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 { +async function discoverViaPortProbing(options: RuntimeRequestOptions): Promise { 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, @@ -437,12 +472,15 @@ async function discoverViaPortProbing(): Promise { return servers } -export async function getAllAvailableServers(): Promise { +export async function getAllAvailableServers( + options: RuntimeRequestOptions = {} +): Promise { + options.signal?.throwIfAborted() const seenPorts = new Set() 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) @@ -452,6 +490,7 @@ export async function getAllAvailableServers(): Promise { // 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)) @@ -459,7 +498,7 @@ export async function getAllAvailableServers(): Promise { // 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 }) ) diff --git a/src/index.ts b/src/index.ts index c176bb4..914794c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) @@ -81,8 +81,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const parsedArgs = parseToolArgs(tool.inputSchema, args || {}) const result = await (tool.handler as ( - args: Record - ) => Promise)(parsedArgs) + args: Record, + options: { signal: AbortSignal } + ) => Promise)(parsedArgs, { signal: extra.signal }) if (typeof result !== "string") return result diff --git a/src/tools/nextjs_call.ts b/src/tools/nextjs_call.ts index f716a68..b1df654 100644 --- a/src/tools/nextjs_call.ts +++ b/src/tools/nextjs_call.ts @@ -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 @@ -64,7 +64,10 @@ function toolResult(payload: Record): CallToolResult { } } -export async function handler(args: NextjsCallArgs): Promise { +export async function handler( + args: NextjsCallArgs, + options: RuntimeRequestOptions = {} +): Promise { try { if (!args.port) { return toolResult({ @@ -85,7 +88,7 @@ export async function handler(args: NextjsCallArgs): Promise { // 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: !( diff --git a/src/tools/nextjs_index.ts b/src/tools/nextjs_index.ts index ed221b4..6012e25 100644 --- a/src/tools/nextjs_index.ts +++ b/src/tools/nextjs_index.ts @@ -4,6 +4,7 @@ import { listNextJsTools, detectProtocol, MCP_HOST, + type RuntimeRequestOptions, } from "../_internal/nextjs-runtime-manager.js" export const inputSchema = { @@ -70,7 +71,7 @@ type NextjsIndexArgs = { port?: string | number } -async function probeAndListTools(port: number): Promise<{ +async function probeAndListTools(port: number, options: RuntimeRequestOptions): Promise<{ success: boolean server?: { port: number @@ -81,8 +82,8 @@ async function probeAndListTools(port: number): Promise<{ error?: string }> { try { - const protocol = await detectProtocol(port) - const tools = await listNextJsTools(port) + const protocol = await detectProtocol(port, options) + const tools = await listNextJsTools(port, options) if (tools.length === 0) { return { @@ -113,12 +114,15 @@ async function probeAndListTools(port: number): Promise<{ } } -export async function handler(args: NextjsIndexArgs = {}): Promise { +export async function handler( + args: NextjsIndexArgs = {}, + options: RuntimeRequestOptions = {} +): Promise { try { // If a specific port is provided, probe it directly if (args.port !== undefined) { const portNumber = typeof args.port === "string" ? parseInt(args.port, 10) : args.port - const result = await probeAndListTools(portNumber) + const result = await probeAndListTools(portNumber, options) if (result.success && result.server) { return JSON.stringify({ @@ -138,13 +142,13 @@ export async function handler(args: NextjsIndexArgs = {}): Promise { } // Auto-discover all servers - const servers = await getAllAvailableServers() + const servers = await getAllAvailableServers(options) // Get tools for each server const candidatesWithTools = await Promise.all( servers.map(async (s) => { - const protocol = await detectProtocol(s.port) - const tools = await listNextJsTools(s.port) + const protocol = await detectProtocol(s.port, options) + const tools = await listNextJsTools(s.port, options) if (tools.length === 0) return null return { port: s.port, diff --git a/test/unit/runtime-request-lifecycle.test.ts b/test/unit/runtime-request-lifecycle.test.ts new file mode 100644 index 0000000..1431882 --- /dev/null +++ b/test/unit/runtime-request-lifecycle.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest" +import { createServer, type ServerResponse } from "node:http" +import { callNextJsTool, probePort } from "../../src/_internal/nextjs-runtime-manager.js" + +async function runtime(mode: "headers" | "body" | "probe" | "success") { + const open = new Set() + let reached!: () => void + const requested = new Promise((resolve) => { + reached = resolve + }) + const server = createServer(async (req, res) => { + let body = "" + for await (const chunk of req) body += chunk + const request = JSON.parse(body) + if (mode !== "probe" && request.method === "tools/list") { + res.writeHead(200, { "Content-Type": "text/event-stream" }) + res.end( + `data: ${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { tools: [] } })}\n\n` + ) + return + } + if (mode === "success") { + res.writeHead(200, { "Content-Type": "text/event-stream" }) + res.end( + `data: ${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { content: [] } })}\n\n` + ) + return + } + open.add(res) + res.once("close", () => open.delete(res)) + if (mode !== "headers") { + res.writeHead(200, { "Content-Type": "text/event-stream" }) + res.write(": keep-alive\n\n") + } + reached() + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing listening port") + return { + port: address.port, + open, + requested, + close: async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + }, + } +} + +function settles(promise: Promise): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("request did not settle")), 1500) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + } + ) + }) +} + +describe("runtime request lifecycle", () => { + it.each(["headers", "body"] as const)("cancels a request stalled at %s", async (mode) => { + const fixture = await runtime(mode) + const controller = new AbortController() + try { + const pending = callNextJsTool(fixture.port, "get_errors", {}, { signal: controller.signal }) + await fixture.requested + controller.abort(new Error("caller cancelled")) + await expect(settles(pending)).rejects.toThrow(/cancel|abort/i) + await expect.poll(() => fixture.open.size, { timeout: 1500 }).toBe(0) + } finally { + await fixture.close() + } + }) + + it("applies a deadline while reading the response body", async () => { + const fixture = await runtime("body") + try { + await expect( + settles(callNextJsTool(fixture.port, "get_errors", {}, { timeoutMs: 100 })) + ).rejects.toThrow(/timeout|timed out/i) + await expect.poll(() => fixture.open.size, { timeout: 1500 }).toBe(0) + } finally { + await fixture.close() + } + }) + + it("cancels a probe response body after reading its status", async () => { + const fixture = await runtime("probe") + try { + expect(await probePort(fixture.port)).toBe("http") + await expect.poll(() => fixture.open.size, { timeout: 1500 }).toBe(0) + } finally { + await fixture.close() + } + }) + + it("does not send a request when the caller already cancelled", async () => { + const fixture = await runtime("success") + const controller = new AbortController() + controller.abort(new Error("caller cancelled")) + try { + await expect( + callNextJsTool(fixture.port, "get_errors", {}, { signal: controller.signal }) + ).rejects.toThrow(/cancel|abort/i) + } finally { + await fixture.close() + } + }) + + it("still completes a healthy request", async () => { + const fixture = await runtime("success") + try { + expect(await callNextJsTool(fixture.port, "get_errors", {}, { timeoutMs: 1000 })).toEqual({ + content: [], + }) + } finally { + await fixture.close() + } + }) +})