Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/01-app/02-guides/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -1190,5 +1190,8 @@
"1189": "Route \"%s\" accessed header \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`headers\\` array, or \\`[\"%s\", null]\\` if it should be absent.",
"1190": "Route \"%s\" accessed param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
"1191": "Route \"%s\" called %s but param%s %s %s not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. %s requires all route params to be provided.",
"1192": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object."
"1192": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
"1193": "Compilation failed but no issues were recorded",
"1194": "no route matched for path \"%s\"",
"1195": "compileRoute: either routeSpecifier or path is required"
}
121 changes: 120 additions & 1 deletion packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
processTopLevelIssues,
printNonFatalIssue,
normalizedPageToTurbopackStructureRoute,
type StartChangeSubscription,
} from './turbopack-utils'
import {
propagateServerField,
Expand Down Expand Up @@ -89,6 +90,7 @@ import {
formatIssue,
isFileSystemCacheEnabledForDev,
isWellKnownError,
ModuleBuildError,
processIssues,
renderStyledStringToErrorAnsi,
type EntryIssuesMap,
Expand Down Expand Up @@ -120,6 +122,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'
Expand Down Expand Up @@ -1039,6 +1043,116 @@ 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 extraOptions: object | undefined = undefined
for (const [name] of currentEntrypoints.app) {
if (normalizeAppPath(name) === page) {
extraOptions = {
// 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: name,
bundlePath: `app${name}`,
filename: '',
} as any,
}
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,
...extraOptions,
}

// 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),
}
},
}),
]
: []),
Expand Down Expand Up @@ -1532,6 +1646,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.
Expand Down Expand Up @@ -1682,7 +1797,11 @@ export async function createHotReloaderTurbopack(
logErrors: true,

hooks: {
subscribeToChanges: subscribeToClientChanges,
// Pass a no-o subscribeToChanges to skip wiring HMR subscriptions for
// one-shot compilations (e.g. compile_route MCP tool).
subscribeToChanges: subscribeToChanges
? subscribeToClientChanges
: ((async () => {}) as StartChangeSubscription),
handleWrittenEndpoint: (id, result, forceDeleteCache) => {
currentWrittenEntrypoints.set(id, result)
assetMapper.setPathsForKey(id, result.clientPaths)
Expand Down
12 changes: 11 additions & 1 deletion packages/next/src/server/dev/hot-reloader-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,23 @@ export interface NextJsHotReloaderInterface {
definition,
isApp,
url,
subscribeToChanges,
}: {
page: string
clientOnly: boolean
appPaths?: ReadonlyArray<string> | 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<void>
close(): void
}
5 changes: 5 additions & 0 deletions packages/next/src/server/dev/hot-reloader-webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}),
]
: [])
Expand Down Expand Up @@ -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<void> {
return this.hotReloaderSpan
.traceChild('ensure-page', {
Expand Down
5 changes: 5 additions & 0 deletions packages/next/src/server/dev/turbopack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export type ClientState = {
export type ClientStateMap = WeakMap<ws, ClientState>

// 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
Expand Down Expand Up @@ -168,6 +171,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) {
Expand Down
10 changes: 10 additions & 0 deletions packages/next/src/server/mcp/get-or-create-mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -62,5 +68,9 @@ export const getOrCreateMcpServer = (options: McpServerOptions) => {
registerGetCompilationIssuesTool(mcpServer, options.getTurbopackProject)
}

if (options.compileRoute) {
registerCompileRouteTool(mcpServer, options.compileRoute)
}

return mcpServer
}
102 changes: 102 additions & 0 deletions packages/next/src/server/mcp/tools/compile-route.ts
Original file line number Diff line number Diff line change
@@ -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 }
),
},
],
}
}
}
)
}
Loading
Loading