Skip to content
Draft
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
4 changes: 3 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\`<Suspense>\\` 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 <Suspense> 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"
}
122 changes: 121 additions & 1 deletion packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
formatIssue,
isFileSystemCacheEnabledForDev,
isWellKnownError,
ModuleBuildError,
processIssues,
renderStyledStringToErrorAnsi,
type EntryIssuesMap,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be clearer to just create the definition here instead of having the ternary below

it could just be 'extraParams' and be unconditionally spread so all the comments are in one place

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),
}
},
}),
]
: []),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
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
21 changes: 13 additions & 8 deletions packages/next/src/server/dev/turbopack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@ 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
subscribeToChanges?: StartChangeSubscription
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of making this be optional would it be better to just pass a no-op subscribe function down?

}

export async function handleRouteType({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -270,7 +275,7 @@ export async function handleRouteType({
}
}
)
hooks?.subscribeToChanges(
hooks?.subscribeToChanges?.(
clientKey,
false,
route.htmlEndpoint,
Expand All @@ -287,7 +292,7 @@ export async function handleRouteType({
}
)
if (entrypoints.global.document) {
hooks?.subscribeToChanges(
hooks?.subscribeToChanges?.(
getEntryKey('pages', 'server', '_document'),
false,
entrypoints.global.document,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
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
}
Loading
Loading