Skip to content

Commit 3ffdb8a

Browse files
committed
compile route mcsp handler
1 parent 7a4059f commit 3ffdb8a

10 files changed

Lines changed: 315 additions & 11 deletions

File tree

docs/01-app/02-guides/mcp.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Through `next-devtools-mcp`, agents can use the following tools:
8686
- **`get_project_metadata`**: Retrieve project structure, configuration, and dev server URL
8787
- **`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
8888
- **`get_server_action_by_id`**: Look up Server Actions by their ID to find the source file and function name
89+
- **`get_compilation_issues`**: Retrieve compilation warnings and errors for the whole project from the bundler. Turbopack only.
90+
- **`compile_route`**: Trigger on-demand compilation of a specific route without making an HTTP request to it. Useful for warming the module graph, measuring compile time, or pre-compiling routes without needing live backends. Returns any compilation issues for the route. Turbopack only.
8991

9092
## Using with agents
9193

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1178,5 +1178,6 @@
11781178
"1177": "The middleware \"%s\" accepts an async API directly with the form:\n \n export function middleware(request, event) {\n return NextResponse.redirect('/new-location')\n }\n \n Read more: https://nextjs.org/docs/messages/middleware-new-signature\n ",
11791179
"1178": "The request.page has been deprecated in favour of \\`URLPattern\\`.\n Read more: https://nextjs.org/docs/messages/middleware-request-page\n ",
11801180
"1179": "Invariant: %s This is a bug in Next.js.",
1181-
"1180": "Cookies can only be modified in a Server Action or Route Handler. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options"
1181+
"1180": "Cookies can only be modified in a Server Action or Route Handler. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options",
1182+
"1181": "Compilation failed but no issues were recorded"
11821183
}

packages/next/src/server/dev/hot-reloader-turbopack.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
formatIssue,
9090
isFileSystemCacheEnabledForDev,
9191
isWellKnownError,
92+
ModuleBuildError,
9293
processIssues,
9394
renderStyledStringToErrorAnsi,
9495
type EntryIssuesMap,
@@ -120,6 +121,7 @@ import {
120121
matchNextPageBundleRequest,
121122
} from './hot-reloader-shared-utils'
122123
import { getMcpMiddleware } from '../mcp/get-mcp-middleware'
124+
import { formatCompilationIssues } from '../mcp/tools/utils/format-compilation-issues'
123125
import { handleErrorStateResponse } from '../mcp/tools/get-errors'
124126
import { handlePageMetadataResponse } from '../mcp/tools/get-page-metadata'
125127
import { setStackFrameResolver } from '../mcp/tools/utils/format-errors'
@@ -1039,6 +1041,82 @@ export async function createHotReloaderTurbopack(
10391041
clientsWithoutHtmlRequestId.size + clientsByHtmlRequestId.size,
10401042
getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN,
10411043
getTurbopackProject: () => project,
1044+
compileRoute: async (pageOpts) => {
1045+
// ensurePage uses findPagePathData when no definition is provided,
1046+
// which calls normalizePagePath("/") → "/index" then findPageFile
1047+
// looking for "index.tsx" — neither of which matches "page.tsx" in
1048+
// the app dir. Pass a synthetic definition instead.
1049+
//
1050+
// currentEntrypoints.app is keyed by originalName which includes the
1051+
// trailing /page or /route segment (e.g. "/page" for the root route,
1052+
// "/blog/[slug]/page" for a dynamic page). Use normalizeAppPath to
1053+
// strip that suffix and find the entry matching the user-facing route.
1054+
let appOriginalName: string | undefined
1055+
for (const [name] of currentEntrypoints.app) {
1056+
if (normalizeAppPath(name) === pageOpts.page) {
1057+
appOriginalName = name
1058+
break
1059+
}
1060+
}
1061+
const ensureOpts = {
1062+
...pageOpts,
1063+
// Skip wiring HMR subscriptions: there is no client to receive
1064+
// updates for routes compiled this way, and these subscriptions
1065+
// are never unsubscribed (see TODOs in handleRouteType).
1066+
subscribeToChanges: false,
1067+
...(appOriginalName
1068+
? {
1069+
// Synthesize a definition so ensurePage bypasses findPagePathData.
1070+
// Only page and bundlePath are used from the definition:
1071+
// - page: the originalName used as the route key for currentEntrypoints lookup
1072+
// - bundlePath: must start with "app/" to set isInsideAppDir=true
1073+
definition: {
1074+
page: appOriginalName,
1075+
bundlePath: `app${appOriginalName}`,
1076+
filename: '',
1077+
} as any,
1078+
}
1079+
: {}),
1080+
}
1081+
1082+
// Snapshot the current issue maps before compilation so we can
1083+
// identify which entry keys were added or updated by this call.
1084+
// processIssues always creates a new Map() reference, so identity
1085+
// comparison detects changes even for re-compilations.
1086+
const snapshotBefore = new Map(currentEntryIssues)
1087+
1088+
// For app-page routes, processIssues is called with throwIssue=true,
1089+
// meaning it throws ModuleBuildError when there are compile errors—but
1090+
// it still writes the issues into currentEntryIssues before throwing.
1091+
// Catch ModuleBuildError so we can read those issues and return them
1092+
// as structured output rather than propagating the throw.
1093+
let moduleBuildError: ModuleBuildError | undefined
1094+
try {
1095+
await hotReloader.ensurePage(ensureOpts)
1096+
} catch (err) {
1097+
if (err instanceof ModuleBuildError) {
1098+
moduleBuildError = err
1099+
} else {
1100+
throw err
1101+
}
1102+
}
1103+
1104+
const rawIssues = []
1105+
for (const [key, issueMap] of currentEntryIssues) {
1106+
if (snapshotBefore.get(key) !== issueMap) {
1107+
rawIssues.push(...issueMap.values())
1108+
}
1109+
}
1110+
1111+
// If ensurePage threw ModuleBuildError but we found no new issues in
1112+
// the map (shouldn't happen, but be safe), re-surface the original
1113+
// error so its message and stack are preserved.
1114+
if (moduleBuildError && rawIssues.length === 0) {
1115+
throw moduleBuildError
1116+
}
1117+
1118+
return formatCompilationIssues(rawIssues)
1119+
},
10421120
}),
10431121
]
10441122
: []),
@@ -1532,6 +1610,7 @@ export async function createHotReloaderTurbopack(
15321610
definition,
15331611
isApp,
15341612
url: requestUrl,
1613+
subscribeToChanges = true,
15351614
}) {
15361615
// When there is no route definition this is an internal file not a route the user added.
15371616
// Middleware and instrumentation are handled in turbpack-utils.ts handleEntrypoints instead.
@@ -1682,7 +1761,11 @@ export async function createHotReloaderTurbopack(
16821761
logErrors: true,
16831762

16841763
hooks: {
1685-
subscribeToChanges: subscribeToClientChanges,
1764+
// Omit subscribeToChanges to skip wiring HMR subscriptions for
1765+
// one-shot compilations (e.g. compile_route MCP tool).
1766+
...(subscribeToChanges
1767+
? { subscribeToChanges: subscribeToClientChanges }
1768+
: null),
16861769
handleWrittenEndpoint: (id, result, forceDeleteCache) => {
16871770
currentWrittenEntrypoints.set(id, result)
16881771
assetMapper.setPathsForKey(id, result.clientPaths)

packages/next/src/server/dev/hot-reloader-types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,23 @@ export interface NextJsHotReloaderInterface {
267267
definition,
268268
isApp,
269269
url,
270+
subscribeToChanges,
270271
}: {
271272
page: string
272273
clientOnly: boolean
273274
appPaths?: ReadonlyArray<string> | null
274275
isApp?: boolean
275-
definition: RouteDefinition | undefined
276+
definition?: RouteDefinition
276277
url?: string
278+
/**
279+
* Whether to wire HMR change subscriptions for the compiled entry.
280+
* Defaults to true (the dev server uses these to push updates to
281+
* connected browsers). Pass false for one-shot compilations (e.g.
282+
* the `compile_route` MCP tool) where there is no client to receive
283+
* HMR updates — without this, repeated calls leak subscriptions that
284+
* keep firing on every file change for the life of the dev server.
285+
*/
286+
subscribeToChanges?: boolean
277287
}): Promise<void>
278288
close(): void
279289
}

packages/next/src/server/dev/hot-reloader-webpack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,6 +1690,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
16901690
getActiveConnectionCount: () =>
16911691
this.webpackHotMiddleware?.getClientCount() ?? 0,
16921692
getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN,
1693+
// compile_route is Turbopack-only; intentionally omitted here.
16931694
}),
16941695
]
16951696
: [])
@@ -1844,6 +1845,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
18441845
isApp?: boolean
18451846
definition?: RouteDefinition
18461847
url?: string
1848+
// subscribeToChanges is accepted for interface compatibility but is a
1849+
// no-op for webpack: webpack's on-demand entry handler does not wire HMR
1850+
// subscriptions per entry the way Turbopack does.
1851+
subscribeToChanges?: boolean
18471852
}): Promise<void> {
18481853
return this.hotReloaderSpan
18491854
.traceChild('ensure-page', {

packages/next/src/server/dev/turbopack-utils.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,12 @@ export type ClientState = {
134134
export type ClientStateMap = WeakMap<ws, ClientState>
135135

136136
// hooks only used by the dev server.
137+
// subscribeToChanges is optional: omit it to skip wiring HMR subscriptions
138+
// for one-shot compilations (e.g. the compile_route MCP tool) where there
139+
// is no client to receive updates and no unsubscribe path.
137140
type HandleRouteTypeHooks = {
138141
handleWrittenEndpoint: HandleWrittenEndpoint
139-
subscribeToChanges: StartChangeSubscription
142+
subscribeToChanges?: StartChangeSubscription
140143
}
141144

142145
export async function handleRouteType({
@@ -167,6 +170,8 @@ export async function handleRouteType({
167170

168171
readyIds?: ReadyIds // dev
169172

173+
// hooks.subscribeToChanges may be omitted to skip HMR subscriptions for
174+
// one-shot compilations (e.g. the compile_route MCP tool).
170175
hooks?: HandleRouteTypeHooks // dev
171176
}) {
172177
switch (route.type) {
@@ -251,7 +256,7 @@ export async function handleRouteType({
251256
if (dev) {
252257
// TODO subscriptions should only be caused by the WebSocket connections
253258
// otherwise we don't known when to unsubscribe and this leaking
254-
hooks?.subscribeToChanges(
259+
hooks?.subscribeToChanges?.(
255260
serverKey,
256261
false,
257262
route.dataEndpoint,
@@ -270,7 +275,7 @@ export async function handleRouteType({
270275
}
271276
}
272277
)
273-
hooks?.subscribeToChanges(
278+
hooks?.subscribeToChanges?.(
274279
clientKey,
275280
false,
276281
route.htmlEndpoint,
@@ -287,7 +292,7 @@ export async function handleRouteType({
287292
}
288293
)
289294
if (entrypoints.global.document) {
290-
hooks?.subscribeToChanges(
295+
hooks?.subscribeToChanges?.(
291296
getEntryKey('pages', 'server', '_document'),
292297
false,
293298
entrypoints.global.document,
@@ -344,7 +349,7 @@ export async function handleRouteType({
344349
if (dev) {
345350
// TODO subscriptions should only be caused by the WebSocket connections
346351
// otherwise we don't known when to unsubscribe and this leaking
347-
hooks?.subscribeToChanges(
352+
hooks?.subscribeToChanges?.(
348353
key,
349354
true,
350355
route.rscEndpoint,
@@ -864,7 +869,7 @@ export async function handlePagesErrorRoute({
864869

865870
const writtenEndpoint = await entrypoints.global.app.writeToDisk()
866871
hooks.handleWrittenEndpoint(key, writtenEndpoint, false)
867-
hooks.subscribeToChanges(
872+
hooks.subscribeToChanges?.(
868873
key,
869874
false,
870875
entrypoints.global.app,
@@ -893,7 +898,7 @@ export async function handlePagesErrorRoute({
893898

894899
const writtenEndpoint = await entrypoints.global.document.writeToDisk()
895900
hooks.handleWrittenEndpoint(key, writtenEndpoint, false)
896-
hooks.subscribeToChanges(
901+
hooks.subscribeToChanges?.(
897902
key,
898903
false,
899904
entrypoints.global.document,
@@ -919,7 +924,7 @@ export async function handlePagesErrorRoute({
919924

920925
const writtenEndpoint = await entrypoints.global.error.writeToDisk()
921926
hooks.handleWrittenEndpoint(key, writtenEndpoint, false)
922-
hooks.subscribeToChanges(
927+
hooks.subscribeToChanges?.(
923928
key,
924929
false,
925930
entrypoints.global.error,

packages/next/src/server/mcp/get-or-create-mcp-server.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { registerGetLogsTool } from './tools/get-logs'
66
import { registerGetActionByIdTool } from './tools/get-server-action-by-id'
77
import { registerGetRoutesTool } from './tools/get-routes'
88
import { registerGetCompilationIssuesTool } from './tools/get-compilation-issues'
9+
import { registerCompileRouteTool } from './tools/compile-route'
910
import type { HmrMessageSentToBrowser } from '../dev/hot-reloader-types'
1011
import type { NextConfigComplete } from '../config-shared'
1112
import type { Project } from '../../build/swc/types'
13+
import type { FormattedIssue } from './tools/utils/format-compilation-issues'
1214

1315
export interface McpServerOptions {
1416
projectPath: string
@@ -20,6 +22,10 @@ export interface McpServerOptions {
2022
getActiveConnectionCount: () => number
2123
getDevServerUrl: () => string | undefined
2224
getTurbopackProject?: () => Project | undefined
25+
compileRoute?: (opts: {
26+
page: string
27+
clientOnly: boolean
28+
}) => Promise<FormattedIssue[]>
2329
}
2430

2531
let mcpServer: McpServer | undefined
@@ -62,5 +68,9 @@ export const getOrCreateMcpServer = (options: McpServerOptions) => {
6268
registerGetCompilationIssuesTool(mcpServer, options.getTurbopackProject)
6369
}
6470

71+
if (options.compileRoute) {
72+
registerCompileRouteTool(mcpServer, options.compileRoute)
73+
}
74+
6575
return mcpServer
6676
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* MCP tool for compiling a specific route via the on-demand entry handler.
3+
*
4+
* Triggers on-demand compilation so the route's assets are built without making an
5+
* HTTP request to the route. This is the same call path the dev server uses
6+
* when a route is first navigated to, making it useful for warming the module
7+
* graph, measuring compile time, or pre-compiling routes for memory
8+
* benchmarking without requiring live backends.
9+
*/
10+
import type { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp'
11+
import { mcpTelemetryTracker } from '../mcp-telemetry-tracker'
12+
import type { FormattedIssue } from './utils/format-compilation-issues'
13+
import z from 'next/dist/compiled/zod'
14+
15+
export function registerCompileRouteTool(
16+
server: McpServer,
17+
compileRoute: (opts: {
18+
page: string
19+
clientOnly: boolean
20+
}) => Promise<FormattedIssue[]>
21+
) {
22+
server.registerTool(
23+
'compile_route',
24+
{
25+
description:
26+
'Compile a specific route (page or API route) without making an HTTP request. ' +
27+
'Triggers the same on-demand compilation the dev server uses when a route is first visited. ' +
28+
'Useful for warming up the module graph, measuring compile time, or pre-compiling routes for memory benchmarking. ' +
29+
'Returns { page, issues } on success where issues contains any compilation warnings or errors. ' +
30+
'Returns an error if the route does not exist.',
31+
inputSchema: {
32+
page: z
33+
.string()
34+
.describe(
35+
'The route specifier, e.g. "/", "/about", "/api/hello", "/blog/[slug]"'
36+
),
37+
},
38+
},
39+
async ({ page }) => {
40+
mcpTelemetryTracker.recordToolCall('mcp/compile_route')
41+
try {
42+
// clientOnly: false ensures both server and client bundles are compiled,
43+
// matching what happens on a real page navigation.
44+
const issues = await compileRoute({ page, clientOnly: false })
45+
return {
46+
content: [{ type: 'text', text: JSON.stringify({ page, issues }) }],
47+
}
48+
} catch (error) {
49+
const message = error instanceof Error ? error.message : String(error)
50+
const notFound =
51+
error instanceof Error &&
52+
(error as NodeJS.ErrnoException).code === 'ENOENT'
53+
return {
54+
isError: true,
55+
content: [
56+
{
57+
type: 'text',
58+
text: JSON.stringify(
59+
notFound ? { page, notFound: true } : { page, error: message }
60+
),
61+
},
62+
],
63+
}
64+
}
65+
}
66+
)
67+
}

packages/next/src/telemetry/events/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export type McpToolName =
260260
| 'mcp/get_routes'
261261
| 'mcp/get_server_action_by_id'
262262
| 'mcp/get_compilation_issues'
263+
| 'mcp/compile_route'
263264

264265
export type EventMcpToolUsage = {
265266
toolName: McpToolName

0 commit comments

Comments
 (0)