Skip to content

Commit 1affdd1

Browse files
committed
expand to support url shaped strings not just specifiers
1 parent 68b1578 commit 1affdd1

7 files changed

Lines changed: 287 additions & 55 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Through `next-devtools-mcp`, agents can use the following tools:
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
8989
- **`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.
90+
- **`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.
9191

9292
## Using with agents
9393

packages/next/errors.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1179,5 +1179,7 @@
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.",
11811181
"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"
1182+
"1181": "Compilation failed but no issues were recorded",
1183+
"1182": "no route matched for path \"%s\"",
1184+
"1183": "compileRoute: either routeSpecifier or path is required"
11831185
}

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

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ import {
122122
} from './hot-reloader-shared-utils'
123123
import { getMcpMiddleware } from '../mcp/get-mcp-middleware'
124124
import { formatCompilationIssues } from '../mcp/tools/utils/format-compilation-issues'
125+
import { resolvePathToRoute } from '../mcp/tools/utils/resolve-path-to-route'
125126
import { handleErrorStateResponse } from '../mcp/tools/get-errors'
126127
import { handlePageMetadataResponse } from '../mcp/tools/get-page-metadata'
127128
import { setStackFrameResolver } from '../mcp/tools/utils/format-errors'
@@ -1041,7 +1042,36 @@ export async function createHotReloaderTurbopack(
10411042
clientsWithoutHtmlRequestId.size + clientsByHtmlRequestId.size,
10421043
getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN,
10431044
getTurbopackProject: () => project,
1044-
compileRoute: async (pageOpts) => {
1045+
compileRoute: async ({ routeSpecifier, path }) => {
1046+
// Resolve the caller's input to a concrete route specifier. The
1047+
// path-mode branch reuses the dev router's own live route table
1048+
// (opts.fsChecker) — the same one resolve-routes.ts consults on
1049+
// every incoming HTTP request — so first-match ordering and live
1050+
// route updates are inherited for free.
1051+
let page: string
1052+
if (routeSpecifier != null) {
1053+
page = routeSpecifier
1054+
} else if (path != null) {
1055+
const resolved = resolvePathToRoute(path, {
1056+
appFiles: opts.fsChecker.appFiles,
1057+
pageFiles: opts.fsChecker.pageFiles,
1058+
dynamicRoutes: opts.fsChecker.getDynamicRoutes(),
1059+
})
1060+
if ('notFound' in resolved) {
1061+
const err: NodeJS.ErrnoException = new Error(
1062+
`no route matched for path "${resolved.pathname}"`
1063+
)
1064+
err.code = 'ENOENT'
1065+
throw err
1066+
}
1067+
page = resolved.routeSpecifier
1068+
} else {
1069+
// Tool handler rejects the empty case; defend the boundary.
1070+
throw new Error(
1071+
'compileRoute: either routeSpecifier or path is required'
1072+
)
1073+
}
1074+
10451075
// ensurePage uses findPagePathData when no definition is provided,
10461076
// which calls normalizePagePath("/") → "/index" then findPageFile
10471077
// looking for "index.tsx" — neither of which matches "page.tsx" in
@@ -1053,13 +1083,17 @@ export async function createHotReloaderTurbopack(
10531083
// strip that suffix and find the entry matching the user-facing route.
10541084
let appOriginalName: string | undefined
10551085
for (const [name] of currentEntrypoints.app) {
1056-
if (normalizeAppPath(name) === pageOpts.page) {
1086+
if (normalizeAppPath(name) === page) {
10571087
appOriginalName = name
10581088
break
10591089
}
10601090
}
10611091
const ensureOpts = {
1062-
...pageOpts,
1092+
page,
1093+
// Compile both server and client bundles, matching what happens
1094+
// on a real page navigation. Client-only compilation isn't a
1095+
// meaningful MCP use case so we don't expose it as a knob.
1096+
clientOnly: false,
10631097
// Skip wiring HMR subscriptions: there is no client to receive
10641098
// updates for routes compiled this way, and these subscriptions
10651099
// are never unsubscribed (see TODOs in handleRouteType).
@@ -1115,7 +1149,10 @@ export async function createHotReloaderTurbopack(
11151149
throw moduleBuildError
11161150
}
11171151

1118-
return formatCompilationIssues(rawIssues)
1152+
return {
1153+
routeSpecifier: page,
1154+
issues: formatCompilationIssues(rawIssues),
1155+
}
11191156
},
11201157
}),
11211158
]

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ export interface McpServerOptions {
2323
getDevServerUrl: () => string | undefined
2424
getTurbopackProject?: () => Project | undefined
2525
compileRoute?: (opts: {
26-
page: string
27-
clientOnly: boolean
28-
}) => Promise<FormattedIssue[]>
26+
routeSpecifier?: string
27+
path?: string
28+
}) => Promise<{ routeSpecifier: string; issues: FormattedIssue[] }>
2929
}
3030

3131
let mcpServer: McpServer | undefined

packages/next/src/server/mcp/tools/compile-route.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import z from 'next/dist/compiled/zod'
1515
export function registerCompileRouteTool(
1616
server: McpServer,
1717
compileRoute: (opts: {
18-
page: string
19-
clientOnly: boolean
20-
}) => Promise<FormattedIssue[]>
18+
routeSpecifier?: string
19+
path?: string
20+
}) => Promise<{ routeSpecifier: string; issues: FormattedIssue[] }>
2121
) {
2222
server.registerTool(
2323
'compile_route',
@@ -26,24 +26,57 @@ export function registerCompileRouteTool(
2626
'Compile a specific route (page or API route) without making an HTTP request. ' +
2727
'Triggers the same on-demand compilation the dev server uses when a route is first visited. ' +
2828
'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.',
29+
'Returns { routeSpecifier, issues } on success where routeSpecifier is the resolved route and issues contains any compilation warnings or errors. ' +
30+
'Returns an error if no matching route exists.',
3131
inputSchema: {
32-
page: z
32+
routeSpecifier: z
3333
.string()
3434
.describe(
35-
'The route specifier, e.g. "/", "/about", "/api/hello", "/blog/[slug]"'
36-
),
35+
'A route specifier as returned by the get_routes tool (e.g. "/", "/blog/[slug]", "/api/users/[id]"). ' +
36+
'Mutually exclusive with `path`; provide exactly one.'
37+
)
38+
.optional(),
39+
path: z
40+
.string()
41+
.describe(
42+
'A URL path on this site (e.g. "/blog/hello-world", "/docs/a/b/c"). ' +
43+
'Query strings are allowed and ignored. Do not include scheme/host/port. ' +
44+
"The path is resolved to its matching route specifier using the dev router's live route table. " +
45+
'Mutually exclusive with `routeSpecifier`; provide exactly one.'
46+
)
47+
.optional(),
3748
},
3849
},
39-
async ({ page }) => {
50+
async ({ routeSpecifier, path }) => {
4051
mcpTelemetryTracker.recordToolCall('mcp/compile_route')
52+
53+
if ((routeSpecifier == null) === (path == null)) {
54+
return {
55+
isError: true,
56+
content: [
57+
{
58+
type: 'text',
59+
text: JSON.stringify({
60+
error: 'Provide exactly one of `routeSpecifier` or `path`.',
61+
}),
62+
},
63+
],
64+
}
65+
}
66+
4167
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 })
68+
const { routeSpecifier: resolvedRouteSpecifier, issues } =
69+
await compileRoute({ routeSpecifier, path })
4570
return {
46-
content: [{ type: 'text', text: JSON.stringify({ page, issues }) }],
71+
content: [
72+
{
73+
type: 'text',
74+
text: JSON.stringify({
75+
routeSpecifier: resolvedRouteSpecifier,
76+
issues,
77+
}),
78+
},
79+
],
4780
}
4881
} catch (error) {
4982
const message = error instanceof Error ? error.message : String(error)
@@ -56,7 +89,9 @@ export function registerCompileRouteTool(
5689
{
5790
type: 'text',
5891
text: JSON.stringify(
59-
notFound ? { page, notFound: true } : { page, error: message }
92+
notFound
93+
? { notFound: true, input: path ?? routeSpecifier }
94+
: { input: path ?? routeSpecifier, error: message }
6095
),
6196
},
6297
],
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Resolves a URL path (e.g. "/blog/hello-world") to its matching Next.js route
3+
* specifier (e.g. "/blog/[slug]") using the dev router's own live route table.
4+
*
5+
* The `matchers` argument is a thin view of `fsChecker` from the router-server
6+
* process — the same data structure `resolve-routes.ts` iterates on every
7+
* incoming HTTP request — so first-match ordering and live route updates are
8+
* inherited for free.
9+
*/
10+
export interface RouteMatcherView {
11+
appFiles: ReadonlySet<string>
12+
pageFiles: ReadonlySet<string>
13+
dynamicRoutes: ReadonlyArray<{
14+
page: string
15+
match: (pathname: string) => false | object
16+
}>
17+
}
18+
19+
export function resolvePathToRoute(
20+
path: string,
21+
matchers: RouteMatcherView
22+
): { routeSpecifier: string } | { notFound: true; pathname: string } {
23+
let pathname = path
24+
const q = pathname.indexOf('?')
25+
if (q >= 0) pathname = pathname.slice(0, q)
26+
const h = pathname.indexOf('#')
27+
if (h >= 0) pathname = pathname.slice(0, h)
28+
if (!pathname.startsWith('/')) pathname = '/' + pathname
29+
if (pathname !== '/' && pathname.endsWith('/')) {
30+
pathname = pathname.slice(0, -1)
31+
}
32+
33+
if (matchers.appFiles.has(pathname) || matchers.pageFiles.has(pathname)) {
34+
return { routeSpecifier: pathname }
35+
}
36+
37+
for (const route of matchers.dynamicRoutes) {
38+
// Skip SSG/SSP data-route variants prepended by setup-dev-bundler.
39+
if (route.page.startsWith('/_next/data/')) continue
40+
if (route.match(pathname)) {
41+
return { routeSpecifier: route.page }
42+
}
43+
}
44+
45+
return { notFound: true, pathname }
46+
}

0 commit comments

Comments
 (0)