diff --git a/docs/01-app/02-guides/mcp.mdx b/docs/01-app/02-guides/mcp.mdx index 0e04855315159c..d94f6ae327c89d 100644 --- a/docs/01-app/02-guides/mcp.mdx +++ b/docs/01-app/02-guides/mcp.mdx @@ -86,6 +86,8 @@ Through `next-devtools-mcp`, agents can use the following tools: - **`get_project_metadata`**: Retrieve project structure, configuration, and dev server URL - **`get_routes`**: Get all routes that will become entry points by scanning the filesystem. Returns routes grouped by router type (appRouter, pagesRouter). Dynamic segments appear as `[param]` or `[...slug]` patterns - **`get_server_action_by_id`**: Look up Server Actions by their ID to find the source file and function name +- **`get_compilation_issues`**: Retrieve compilation warnings and errors for the whole project from the bundler. Turbopack only. +- **`compile_route`**: Trigger on-demand compilation of a specific route without making an HTTP request to it. Accepts either a `routeSpecifier` (e.g. `/blog/[slug]`, as returned by `get_routes`) or a `path` (e.g. `/blog/hello-world`) which is resolved to the matching route using the dev router's live route table. Returns any compilation issues for the route. Turbopack only. ## Using with agents diff --git a/packages/next/errors.json b/packages/next/errors.json index bdde7f2ae0bf51..b1199a74d7e3d7 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1169,5 +1169,7 @@ "1168": "Route \"%s\": Next.js encountered runtime data such as \\`cookies()\\`, \\`headers()\\`, \\`params\\`, or \\`searchParams\\` inside \\`generateMetadata\\`, or you have file-based metadata such as icons that depend on dynamic params segments. Except for this instance, the page would have been entirely prerenderable which may have been the intended behavior. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata", "1169": "Route \"%s\": Next.js encountered uncached or runtime data during the initial render.\\n\\n\\`fetch(...)\\`, \\`cookies()\\`, \\`headers()\\`, \\`params\\`, \\`searchParams\\`, or \\`connection()\\` accessed outside of \\`\\` blocks navigation, leading to a slower user experience.\\n\\nWays to fix this:\\n - Cache the data access with \\`\"use cache\"\\`\\n - Move the data access into a child component within a boundary\\n - Use \\`generateStaticParams\\` to make route params static\\n - Set \\`export const instant = false\\` to allow a blocking route\\n\\nLearn more: https://nextjs.org/docs/messages/blocking-route", "1170": "Route \"%s\": Next.js encountered uncached data such as \\`fetch(...)\\` or \\`connection()\\` inside \\`generateMetadata\\`. Except for this instance, the page would have been entirely prerenderable which may have been the intended behavior. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata", - "1171": "Response body exceeded maximum size of %s bytes" + "1171": "Response body exceeded maximum size of %s bytes", + "1172": "no route matched for path \"%s\"", + "1173": "compileRoute: either routeSpecifier or path is required" } diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 3b11ba9b0b6d5c..3fffe408b97602 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -92,6 +92,7 @@ import { formatIssue, isFileSystemCacheEnabledForDev, isWellKnownError, + ModuleBuildError, processIssues, renderStyledStringToErrorAnsi, type EntryIssuesMap, @@ -123,6 +124,8 @@ import { matchNextPageBundleRequest, } from './hot-reloader-shared-utils' import { getMcpMiddleware } from '../mcp/get-mcp-middleware' +import { formatCompilationIssues } from '../mcp/tools/utils/format-compilation-issues' +import { resolvePathToRoute } from '../mcp/tools/utils/resolve-path-to-route' import { handleErrorStateResponse } from '../mcp/tools/get-errors' import { handlePageMetadataResponse } from '../mcp/tools/get-page-metadata' import { setStackFrameResolver } from '../mcp/tools/utils/format-errors' @@ -1046,6 +1049,118 @@ export async function createHotReloaderTurbopack( clientsWithoutHtmlRequestId.size + clientsByHtmlRequestId.size, getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN, getTurbopackProject: () => project, + compileRoute: async ({ routeSpecifier, path }) => { + // Resolve the caller's input to a concrete route specifier. The + // path-mode branch reuses the dev router's own live route table + // (opts.fsChecker) — the same one resolve-routes.ts consults on + // every incoming HTTP request — so first-match ordering and live + // route updates are inherited for free. + let page: string + if (routeSpecifier != null) { + page = routeSpecifier + } else if (path != null) { + const resolved = resolvePathToRoute(path, { + appFiles: opts.fsChecker.appFiles, + pageFiles: opts.fsChecker.pageFiles, + dynamicRoutes: opts.fsChecker.getDynamicRoutes(), + }) + if ('notFound' in resolved) { + const err: NodeJS.ErrnoException = new Error( + `no route matched for path "${resolved.pathname}"` + ) + err.code = 'ENOENT' + throw err + } + page = resolved.routeSpecifier + } else { + // Tool handler rejects the empty case; defend the boundary. + throw new Error( + 'compileRoute: either routeSpecifier or path is required' + ) + } + + // ensurePage uses findPagePathData when no definition is provided, + // which calls normalizePagePath("/") → "/index" then findPageFile + // looking for "index.tsx" — neither of which matches "page.tsx" in + // the app dir. Pass a synthetic definition instead. + // + // currentEntrypoints.app is keyed by originalName which includes the + // trailing /page or /route segment (e.g. "/page" for the root route, + // "/blog/[slug]/page" for a dynamic page). Use normalizeAppPath to + // strip that suffix and find the entry matching the user-facing route. + let appOriginalName: string | undefined + for (const [name] of currentEntrypoints.app) { + if (normalizeAppPath(name) === page) { + appOriginalName = name + break + } + } + const ensureOpts = { + page, + // Compile both server and client bundles, matching what happens + // on a real page navigation. Client-only compilation isn't a + // meaningful MCP use case so we don't expose it as a knob. + clientOnly: false, + // Skip wiring HMR subscriptions: there is no client to receive + // updates for routes compiled this way, and these subscriptions + // are never unsubscribed (see TODOs in handleRouteType). + subscribeToChanges: false, + ...(appOriginalName + ? { + // Synthesize a definition so ensurePage bypasses findPagePathData. + // Only page and bundlePath are used from the definition: + // - page: the originalName used as the route key for currentEntrypoints lookup + // - bundlePath: must start with "app/" to set isInsideAppDir=true + definition: { + page: appOriginalName, + bundlePath: `app${appOriginalName}`, + filename: '', + } as any, + } + : {}), + } + + // Snapshot the current issue maps before compilation so we can + // identify which entry keys were added or updated by this call. + // processIssues always creates a new Map() reference, so identity + // comparison detects changes even for re-compilations. + const snapshotBefore = new Map(currentEntryIssues) + + // For app-page routes, processIssues is called with throwIssue=true, + // meaning it throws ModuleBuildError when there are compile errors—but + // it still writes the issues into currentEntryIssues before throwing. + // Catch ModuleBuildError so we can read those issues and return them + // as structured output rather than propagating the throw. + let moduleBuildError: ModuleBuildError | undefined + try { + await hotReloader.ensurePage(ensureOpts) + } catch (err) { + if (err instanceof ModuleBuildError) { + moduleBuildError = err + } else { + throw err + } + } + + const rawIssues = [] + for (const [key, issueMap] of currentEntryIssues) { + if (snapshotBefore.get(key) !== issueMap) { + rawIssues.push(...issueMap.values()) + } + } + + // If ensurePage threw ModuleBuildError but we found no new issues in + // the map (shouldn't happen, but be safe), re-surface the original + // error so its message and stack are preserved. + if (moduleBuildError && rawIssues.length === 0) { + throw moduleBuildError + } + + return { + routeSpecifier: page, + issues: formatCompilationIssues(rawIssues), + } + }, }), ] : []), @@ -1539,6 +1654,7 @@ export async function createHotReloaderTurbopack( definition, isApp, url: requestUrl, + subscribeToChanges = true, }) { // When there is no route definition this is an internal file not a route the user added. // Middleware and instrumentation are handled in turbpack-utils.ts handleEntrypoints instead. @@ -1689,7 +1805,11 @@ export async function createHotReloaderTurbopack( logErrors: true, hooks: { - subscribeToChanges: subscribeToClientChanges, + // Omit subscribeToChanges to skip wiring HMR subscriptions for + // one-shot compilations (e.g. compile_route MCP tool). + ...(subscribeToChanges + ? { subscribeToChanges: subscribeToClientChanges } + : null), handleWrittenEndpoint: (id, result, forceDeleteCache) => { currentWrittenEntrypoints.set(id, result) assetMapper.setPathsForKey(id, result.clientPaths) diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index c620de4a707bb8..df507062fcc934 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -267,13 +267,23 @@ export interface NextJsHotReloaderInterface { definition, isApp, url, + subscribeToChanges, }: { page: string clientOnly: boolean appPaths?: ReadonlyArray | null isApp?: boolean - definition: RouteDefinition | undefined + definition?: RouteDefinition url?: string + /** + * Whether to wire HMR change subscriptions for the compiled entry. + * Defaults to true (the dev server uses these to push updates to + * connected browsers). Pass false for one-shot compilations (e.g. + * the `compile_route` MCP tool) where there is no client to receive + * HMR updates — without this, repeated calls leak subscriptions that + * keep firing on every file change for the life of the dev server. + */ + subscribeToChanges?: boolean }): Promise close(): void } diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 4b646dcfdc8eb4..063ebe195a89dd 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -1690,6 +1690,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { getActiveConnectionCount: () => this.webpackHotMiddleware?.getClientCount() ?? 0, getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN, + // compile_route is Turbopack-only; intentionally omitted here. }), ] : []) @@ -1844,6 +1845,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { isApp?: boolean definition?: RouteDefinition url?: string + // subscribeToChanges is accepted for interface compatibility but is a + // no-op for webpack: webpack's on-demand entry handler does not wire HMR + // subscriptions per entry the way Turbopack does. + subscribeToChanges?: boolean }): Promise { return this.hotReloaderSpan .traceChild('ensure-page', { diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index 0b7962b2326450..4a113bee8e2936 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -134,9 +134,12 @@ export type ClientState = { export type ClientStateMap = WeakMap // hooks only used by the dev server. +// subscribeToChanges is optional: omit it to skip wiring HMR subscriptions +// for one-shot compilations (e.g. the compile_route MCP tool) where there +// is no client to receive updates and no unsubscribe path. type HandleRouteTypeHooks = { handleWrittenEndpoint: HandleWrittenEndpoint - subscribeToChanges: StartChangeSubscription + subscribeToChanges?: StartChangeSubscription } export async function handleRouteType({ @@ -167,6 +170,8 @@ export async function handleRouteType({ readyIds?: ReadyIds // dev + // hooks.subscribeToChanges may be omitted to skip HMR subscriptions for + // one-shot compilations (e.g. the compile_route MCP tool). hooks?: HandleRouteTypeHooks // dev }) { switch (route.type) { @@ -251,7 +256,7 @@ export async function handleRouteType({ if (dev) { // TODO subscriptions should only be caused by the WebSocket connections // otherwise we don't known when to unsubscribe and this leaking - hooks?.subscribeToChanges( + hooks?.subscribeToChanges?.( serverKey, false, route.dataEndpoint, @@ -270,7 +275,7 @@ export async function handleRouteType({ } } ) - hooks?.subscribeToChanges( + hooks?.subscribeToChanges?.( clientKey, false, route.htmlEndpoint, @@ -287,7 +292,7 @@ export async function handleRouteType({ } ) if (entrypoints.global.document) { - hooks?.subscribeToChanges( + hooks?.subscribeToChanges?.( getEntryKey('pages', 'server', '_document'), false, entrypoints.global.document, @@ -344,7 +349,7 @@ export async function handleRouteType({ if (dev) { // TODO subscriptions should only be caused by the WebSocket connections // otherwise we don't known when to unsubscribe and this leaking - hooks?.subscribeToChanges( + hooks?.subscribeToChanges?.( key, true, route.rscEndpoint, @@ -864,7 +869,7 @@ export async function handlePagesErrorRoute({ const writtenEndpoint = await entrypoints.global.app.writeToDisk() hooks.handleWrittenEndpoint(key, writtenEndpoint, false) - hooks.subscribeToChanges( + hooks.subscribeToChanges?.( key, false, entrypoints.global.app, @@ -893,7 +898,7 @@ export async function handlePagesErrorRoute({ const writtenEndpoint = await entrypoints.global.document.writeToDisk() hooks.handleWrittenEndpoint(key, writtenEndpoint, false) - hooks.subscribeToChanges( + hooks.subscribeToChanges?.( key, false, entrypoints.global.document, @@ -919,7 +924,7 @@ export async function handlePagesErrorRoute({ const writtenEndpoint = await entrypoints.global.error.writeToDisk() hooks.handleWrittenEndpoint(key, writtenEndpoint, false) - hooks.subscribeToChanges( + hooks.subscribeToChanges?.( key, false, entrypoints.global.error, diff --git a/packages/next/src/server/mcp/get-or-create-mcp-server.ts b/packages/next/src/server/mcp/get-or-create-mcp-server.ts index c9d523a4c6b78c..5bd9108f8cda11 100644 --- a/packages/next/src/server/mcp/get-or-create-mcp-server.ts +++ b/packages/next/src/server/mcp/get-or-create-mcp-server.ts @@ -6,9 +6,11 @@ import { registerGetLogsTool } from './tools/get-logs' import { registerGetActionByIdTool } from './tools/get-server-action-by-id' import { registerGetRoutesTool } from './tools/get-routes' import { registerGetCompilationIssuesTool } from './tools/get-compilation-issues' +import { registerCompileRouteTool } from './tools/compile-route' import type { HmrMessageSentToBrowser } from '../dev/hot-reloader-types' import type { NextConfigComplete } from '../config-shared' import type { Project } from '../../build/swc/types' +import type { FormattedIssue } from './tools/utils/format-compilation-issues' export interface McpServerOptions { projectPath: string @@ -20,6 +22,10 @@ export interface McpServerOptions { getActiveConnectionCount: () => number getDevServerUrl: () => string | undefined getTurbopackProject?: () => Project | undefined + compileRoute?: (opts: { + routeSpecifier?: string + path?: string + }) => Promise<{ routeSpecifier: string; issues: FormattedIssue[] }> } let mcpServer: McpServer | undefined @@ -62,5 +68,9 @@ export const getOrCreateMcpServer = (options: McpServerOptions) => { registerGetCompilationIssuesTool(mcpServer, options.getTurbopackProject) } + if (options.compileRoute) { + registerCompileRouteTool(mcpServer, options.compileRoute) + } + return mcpServer } diff --git a/packages/next/src/server/mcp/tools/compile-route.ts b/packages/next/src/server/mcp/tools/compile-route.ts new file mode 100644 index 00000000000000..2f90eb4ce47d67 --- /dev/null +++ b/packages/next/src/server/mcp/tools/compile-route.ts @@ -0,0 +1,102 @@ +/** + * MCP tool for compiling a specific route via the on-demand entry handler. + * + * Triggers on-demand compilation so the route's assets are built without making an + * HTTP request to the route. This is the same call path the dev server uses + * when a route is first navigated to, making it useful for warming the module + * graph, measuring compile time, or pre-compiling routes for memory + * benchmarking without requiring live backends. + */ +import type { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp' +import { mcpTelemetryTracker } from '../mcp-telemetry-tracker' +import type { FormattedIssue } from './utils/format-compilation-issues' +import z from 'next/dist/compiled/zod' + +export function registerCompileRouteTool( + server: McpServer, + compileRoute: (opts: { + routeSpecifier?: string + path?: string + }) => Promise<{ routeSpecifier: string; issues: FormattedIssue[] }> +) { + server.registerTool( + 'compile_route', + { + description: + 'Compile a specific route (page or API route) without making an HTTP request. ' + + 'Triggers the same on-demand compilation the dev server uses when a route is first visited. ' + + 'Useful for warming up the module graph, measuring compile time, or pre-compiling routes for memory benchmarking. ' + + 'Returns { routeSpecifier, issues } on success where routeSpecifier is the resolved route and issues contains any compilation warnings or errors. ' + + 'Returns an error if no matching route exists.', + inputSchema: { + routeSpecifier: z + .string() + .describe( + 'A route specifier as returned by the get_routes tool (e.g. "/", "/blog/[slug]", "/api/users/[id]"). ' + + 'Mutually exclusive with `path`; provide exactly one.' + ) + .optional(), + path: z + .string() + .describe( + 'A URL path on this site (e.g. "/blog/hello-world", "/docs/a/b/c"). ' + + 'Query strings are allowed and ignored. Do not include scheme/host/port. ' + + "The path is resolved to its matching route specifier using the dev router's live route table. " + + 'Mutually exclusive with `routeSpecifier`; provide exactly one.' + ) + .optional(), + }, + }, + async ({ routeSpecifier, path }) => { + mcpTelemetryTracker.recordToolCall('mcp/compile_route') + + if ((routeSpecifier == null) === (path == null)) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Provide exactly one of `routeSpecifier` or `path`.', + }), + }, + ], + } + } + + try { + const { routeSpecifier: resolvedRouteSpecifier, issues } = + await compileRoute({ routeSpecifier, path }) + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + routeSpecifier: resolvedRouteSpecifier, + issues, + }), + }, + ], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const notFound = + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify( + notFound + ? { notFound: true, input: path ?? routeSpecifier } + : { input: path ?? routeSpecifier, error: message } + ), + }, + ], + } + } + } + ) +} diff --git a/packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts b/packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts new file mode 100644 index 00000000000000..aa75550e549ace --- /dev/null +++ b/packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts @@ -0,0 +1,46 @@ +/** + * Resolves a URL path (e.g. "/blog/hello-world") to its matching Next.js route + * specifier (e.g. "/blog/[slug]") using the dev router's own live route table. + * + * The `matchers` argument is a thin view of `fsChecker` from the router-server + * process — the same data structure `resolve-routes.ts` iterates on every + * incoming HTTP request — so first-match ordering and live route updates are + * inherited for free. + */ +export interface RouteMatcherView { + appFiles: ReadonlySet + pageFiles: ReadonlySet + dynamicRoutes: ReadonlyArray<{ + page: string + match: (pathname: string) => false | object + }> +} + +export function resolvePathToRoute( + path: string, + matchers: RouteMatcherView +): { routeSpecifier: string } | { notFound: true; pathname: string } { + let pathname = path + const q = pathname.indexOf('?') + if (q >= 0) pathname = pathname.slice(0, q) + const h = pathname.indexOf('#') + if (h >= 0) pathname = pathname.slice(0, h) + if (!pathname.startsWith('/')) pathname = '/' + pathname + if (pathname !== '/' && pathname.endsWith('/')) { + pathname = pathname.slice(0, -1) + } + + if (matchers.appFiles.has(pathname) || matchers.pageFiles.has(pathname)) { + return { routeSpecifier: pathname } + } + + for (const route of matchers.dynamicRoutes) { + // Skip SSG/SSP data-route variants prepended by setup-dev-bundler. + if (route.page.startsWith('/_next/data/')) continue + if (route.match(pathname)) { + return { routeSpecifier: route.page } + } + } + + return { notFound: true, pathname } +} diff --git a/packages/next/src/telemetry/events/build.ts b/packages/next/src/telemetry/events/build.ts index ac5233a634a58c..4a7b227f17764d 100644 --- a/packages/next/src/telemetry/events/build.ts +++ b/packages/next/src/telemetry/events/build.ts @@ -260,6 +260,7 @@ export type McpToolName = | 'mcp/get_routes' | 'mcp/get_server_action_by_id' | 'mcp/get_compilation_issues' + | 'mcp/compile_route' export type EventMcpToolUsage = { toolName: McpToolName diff --git a/test/development/mcp-server/mcp-server-compile-route.test.ts b/test/development/mcp-server/mcp-server-compile-route.test.ts new file mode 100644 index 00000000000000..6e7fe1c45066ce --- /dev/null +++ b/test/development/mcp-server/mcp-server-compile-route.test.ts @@ -0,0 +1,232 @@ +import path from 'path' +import { nextTestSetup } from 'e2e-utils' + +async function callMcpTool( + url: string, + toolName: string, + args: Record = {} +): Promise { + const response = await fetch(`${url}/_next/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: toolName + '-' + Date.now(), + method: 'tools/call', + params: { name: toolName, arguments: args }, + }), + }) + const text = await response.text() + const match = text.match(/data: ({.*})/s) + expect(match).toBeTruthy() + const envelope = JSON.parse(match![1]) + return JSON.parse(envelope.result?.content?.[0]?.text) +} + +// compile_route is Turbopack-only; it is not registered on webpack dev servers. +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'mcp-server compile_route tool', + () => { + const { next, skipped } = nextTestSetup({ + files: path.join(__dirname, 'fixtures', 'dynamic-routes-app'), + }) + + if (skipped) { + return + } + + describe('routeSpecifier input', () => { + it('should compile a valid app router root route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/', + }) + expect(result).toMatchObject({ routeSpecifier: '/', issues: [] }) + }) + + it('should compile a valid dynamic app router route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/blog/[slug]', + }) + expect(result).toMatchObject({ + routeSpecifier: '/blog/[slug]', + issues: [], + }) + }) + + it('should compile a valid pages router route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/about', + }) + expect(result).toMatchObject({ routeSpecifier: '/about', issues: [] }) + }) + + it('should compile a valid app router API route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/api/users/[id]', + }) + expect(result).toMatchObject({ + routeSpecifier: '/api/users/[id]', + issues: [], + }) + }) + + it('should compile a valid pages router API route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/api/legacy', + }) + expect(result).toMatchObject({ + routeSpecifier: '/api/legacy', + issues: [], + }) + }) + + it('should return notFound for a non-existent specifier', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/does-not-exist', + })) as any + expect(result).toMatchObject({ + notFound: true, + input: '/does-not-exist', + }) + }) + }) + + describe('path input', () => { + it('should resolve a static app route path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/', + }) + expect(result).toMatchObject({ routeSpecifier: '/', issues: [] }) + }) + + it('should resolve a dynamic app route path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/blog/hello-world', + }) + expect(result).toMatchObject({ + routeSpecifier: '/blog/[slug]', + issues: [], + }) + }) + + it('should resolve a catchall app route path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/docs/a/b/c', + }) + expect(result).toMatchObject({ + routeSpecifier: '/docs/[...slug]', + issues: [], + }) + }) + + it('should strip query string before matching', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/products/42?ref=x', + }) + expect(result).toMatchObject({ + routeSpecifier: '/products/[id]', + issues: [], + }) + }) + + it('should resolve a pages router dynamic path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/posts/7', + }) + expect(result).toMatchObject({ + routeSpecifier: '/posts/[id]', + issues: [], + }) + }) + + it('should resolve an app router API path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/api/users/42', + }) + expect(result).toMatchObject({ + routeSpecifier: '/api/users/[id]', + issues: [], + }) + }) + + it('should resolve a static pages router path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/about', + }) + expect(result).toMatchObject({ routeSpecifier: '/about', issues: [] }) + }) + + it('should strip a trailing slash before matching', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/about/', + }) + expect(result).toMatchObject({ routeSpecifier: '/about', issues: [] }) + }) + + it('should return notFound when no route matches', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + path: '/nope/x', + })) as any + expect(result).toMatchObject({ notFound: true, input: '/nope/x' }) + }) + }) + + describe('input validation', () => { + it('should error when both routeSpecifier and path are provided', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/', + path: '/', + })) as any + expect(result).toMatchObject({ + error: expect.stringContaining('exactly one'), + }) + }) + + it('should error when neither routeSpecifier nor path is provided', async () => { + const result = (await callMcpTool(next.url, 'compile_route', {})) as any + expect(result).toMatchObject({ + error: expect.stringContaining('exactly one'), + }) + }) + }) + } +) + +// Compilation errors don't throw from ensurePage — they are collected from +// Turbopack's per-entry issue map and returned directly in the compile_route +// response, so no second round-trip to get_compilation_issues is needed. +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'mcp-server compile_route with compilation errors', + () => { + const { next, skipped } = nextTestSetup({ + files: path.join(__dirname, 'fixtures', 'compilation-errors-app'), + }) + + if (skipped) { + return + } + + it('should return compilation issues inline in the response', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/missing-module', + })) as { + routeSpecifier: string + issues: Array<{ severity: string; filePath: string; title: string }> + } + + expect(result.routeSpecifier).toBe('/missing-module') + expect(result.issues.length).toBeGreaterThan(0) + + const moduleNotFound = result.issues.find( + (issue) => + (issue.severity === 'error' || issue.severity === 'fatal') && + (issue.filePath.includes('missing-module') || + issue.title.includes('non-existent-module')) + ) + expect(moduleNotFound).toBeDefined() + }) + } +)