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
12 changes: 12 additions & 0 deletions crates/next-napi-bindings/src/turbo_trace_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ pub struct TraceSpanInfo {
pub avg_corrected_duration: Option<i64>,
/// Raw span ID for aggregated groups (the index of the first span).
pub first_span_id: Option<String>,
/// TurboMalloc memory-usage samples recorded while this span
/// (or its example span, for aggregated groups) was live.
///
/// Each entry is `[ts_offset_from_span_start_in_ticks, bytes]`.
/// `100 ticks = 1 µs`. The offset is always `>= 0` and `<= span_duration`.
/// Capped and downsampled by the store.
pub memory_samples: Vec<Vec<i64>>,
}

/// The result of a `query_trace_spans` call.
Expand Down Expand Up @@ -125,6 +132,11 @@ pub fn query_trace_spans(
total_corrected_duration: s.total_corrected_duration.map(|v| v as i64),
avg_corrected_duration: s.avg_corrected_duration.map(|v| v as i64),
first_span_id: s.first_span_id,
memory_samples: s
.memory_samples
.into_iter()
.map(|(ts, mem)| vec![ts, mem as i64])
.collect(),
})
.collect(),
page: result.page as u32,
Expand Down
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"
}
33 changes: 32 additions & 1 deletion packages/next/src/cli/internal/turbo-trace-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ function formatRelative(ticks: number): string {
return prefix + formatDuration(Math.abs(ticks))
}

function formatBytes(bytes: number): string {
const KB = 1024
const MB = KB * 1024
const GB = MB * 1024
if (bytes >= GB) return `${(bytes / GB).toFixed(2)} GB`
if (bytes >= MB) return `${(bytes / MB).toFixed(2)} MB`
if (bytes >= KB) return `${(bytes / KB).toFixed(2)} KB`
return `${bytes} B`
}

function summarizeMemorySamples(samples: number[][]): string | null {
if (!samples || samples.length === 0) return null
const bytes = samples.map((s) => s[1])
const min = Math.min(...bytes)
const max = Math.max(...bytes)
const first = bytes[0]
const last = bytes[bytes.length - 1]
const delta = last - first
const deltaSign = delta >= 0 ? '+' : '-'
return (
`samples=${samples.length}, min=${formatBytes(min)}, max=${formatBytes(max)}, ` +
`start=${formatBytes(first)}, end=${formatBytes(last)}, ` +
`Δ=${deltaSign}${formatBytes(Math.abs(delta))}`
)
}

/**
* Render a single span (or aggregated span group) as a markdown section.
*/
Expand Down Expand Up @@ -74,6 +100,11 @@ function renderSpanMarkdown(span: TraceSpanInfo): string {
}
}

const memSummary = summarizeMemorySamples(span.memorySamples)
if (memSummary) {
md += `\n**Memory (TurboMalloc live bytes):** ${memSummary}\n`
}

md += '\n---\n\n'
return md
}
Expand Down Expand Up @@ -121,7 +152,7 @@ export async function startTurboTraceServerCli(
'query_spans',
{
description:
'Query spans from a turbopack trace file. Returns spans with timing, CPU usage, and attribute details. Set `outputType` to "json" for machine-readable output or "markdown" (default) for human-readable output. Use the `parent` parameter (with an ID from a previous result) to drill into children. Results are paginated to 20 spans per page.',
'Query spans from a turbopack trace file. Returns spans with timing, CPU usage, attribute details, and TurboMalloc live-memory samples recorded while each span was active. Set `outputType` to "json" for machine-readable output (including the raw `memorySamples` array of bytes per span) or "markdown" (default) for a human-readable summary. Use the `parent` parameter (with an ID from a previous result) to drill into children. Results are paginated to 20 spans per page.',
inputSchema: {
parent: z
.string()
Expand Down
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 @@ -92,6 +93,7 @@ import {
formatIssue,
isFileSystemCacheEnabledForDev,
isWellKnownError,
ModuleBuildError,
processIssues,
renderStyledStringToErrorAnsi,
type EntryIssuesMap,
Expand Down Expand Up @@ -123,6 +125,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 +1050,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 @@ -1539,6 +1653,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 +1804,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 @@ -134,6 +134,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 @@ -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
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