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
1 change: 1 addition & 0 deletions .config/eslintignore.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default globalIgnores([
'examples/with-typescript-graphql/lib/gql/',
'test/development/basic/hmr/components/parse-error.js',
'test/development/mcp-server/fixtures/default-template/app/build-error/page.tsx',
'test/development/mcp-server/fixtures/compilation-errors-app/app/syntax-error/page.tsx',
'test/production/debug-build-path/fixtures/with-compile-error/app/broken/page.tsx',
'packages/next-swc/native/index.d.ts',
'packages/next-swc/docs/assets/**/*',
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ test/e2e/app-dir/server-source-maps/fixtures/default/internal-pkg/ignored.js
test/e2e/app-dir/server-source-maps/fixtures/default/internal-pkg/sourcemapped.js
test/e2e/app-dir/server-source-maps/fixtures/default/external-pkg/sourcemapped.js
test/development/mcp-server/fixtures/default-template/app/build-error/page.tsx
test/development/mcp-server/fixtures/compilation-errors-app/app/syntax-error/page.tsx
# Tested against auto-generated content that isn't formatted
test/integration/typescript-app-type-declarations/next-env.strictRouteTypes.d.ts

Expand Down
64 changes: 64 additions & 0 deletions crates/next-napi-bindings/src/next_api/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,70 @@ pub async fn project_write_analyze_data(
})
}

#[turbo_tasks::function(operation)]
async fn get_all_compilation_issues_inner_operation(
container: ResolvedVc<ProjectContainer>,
) -> Result<Vc<()>> {
let project = container.project();
// Build the module graph for every endpoint without chunking, code gen, or disk emission.
// We iterate endpoints rather than calling project.whole_app_module_graphs() because the
// latter calls drop_issues() in development mode (to avoid duplicate per-route HMR noise).
// Per-endpoint module_graphs() calls are not subject to that suppression, so issues like
// missing modules and transform errors are properly collected as collectables here.
let endpoint_groups = project.get_all_endpoint_groups(false).await?;
for (_, endpoint_group) in endpoint_groups.iter() {
endpoint_group.module_graphs().as_side_effect().await?;
}
Ok(Vc::cell(()))
}

#[turbo_tasks::function(operation)]
async fn get_all_compilation_issues_operation(
container: ResolvedVc<ProjectContainer>,
) -> Result<Vc<OperationResult>> {
let inner_op = get_all_compilation_issues_inner_operation(container);
let filter = issue_filter_from_container(container);
let (_, issues, diagnostics, effects) =
strongly_consistent_catch_collectables(inner_op, filter).await?;
Ok(OperationResult {
issues,
diagnostics,
effects,
}
.cell())
}

#[tracing::instrument(level = "info", name = "get all compilation issues", skip_all)]
#[napi]
pub async fn project_get_all_compilation_issues(
#[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External<ProjectInstance>,
) -> napi::Result<TurbopackResult<()>> {
let container = project.container;
let (issues, diagnostics) = project
.turbopack_ctx
.turbo_tasks()
.run_once(async move {
let op = get_all_compilation_issues_operation(container);
let OperationResult {
issues,
diagnostics,
effects: _,
} = &*op.read_strongly_consistent().await?;
Ok((issues.clone(), diagnostics.clone()))
})
.await
.map_err(|e| napi::Error::from_reason(PrettyPrintError(&e).to_string()))?;

Ok(TurbopackResult {
result: (),
issues: issues.iter().map(|i| NapiIssue::from(&**i)).collect(),
diagnostics: diagnostics
.iter()
.map(|d| NapiDiagnostic::from(d))
.collect(),
})
}

/// Opens the Turbopack persistent cache database at the given path and performs a full compaction.
///
/// The `path` should point to the `<distDir>/cache/turbopack` directory.
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/build/swc/generated-native.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ export declare function projectWriteAnalyzeData(
project: { __napiType: 'Project' },
appDirOnly: boolean
): Promise<TurbopackResult>
export declare function projectGetAllCompilationIssues(project: {
__napiType: 'Project'
}): Promise<TurbopackResult>
/**
* Opens the Turbopack persistent cache database at the given path and performs a full compaction.
*
Expand Down
7 changes: 7 additions & 0 deletions packages/next/src/build/swc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,13 @@ function bindingToApi(
return napiResult
}

async getAllCompilationIssues(): Promise<TurbopackResult<void>> {
const napiResult = (await binding.projectGetAllCompilationIssues(
this._nativeProject
)) as TurbopackResult<void>
return napiResult
}

async writeAllEntrypointsToDisk(
appDirOnly: boolean
): Promise<TurbopackResult<Partial<RawEntrypoints>>> {
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/build/swc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ export interface Project {

writeAnalyzeData(appDirOnly: boolean): Promise<TurbopackResult<void>>

getAllCompilationIssues(): Promise<TurbopackResult<void>>

writeAllEntrypointsToDisk(
appDirOnly: boolean
): Promise<TurbopackResult<Partial<RawEntrypoints>>>
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,7 @@ export async function createHotReloaderTurbopack(
getActiveConnectionCount: () =>
clientsWithoutHtmlRequestId.size + clientsByHtmlRequestId.size,
getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN,
getTurbopackProject: () => project,
}),
]
: []),
Expand Down
8 changes: 8 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 @@ -5,6 +5,7 @@ import { registerGetPageMetadataTool } from './tools/get-page-metadata'
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 type { HmrMessageSentToBrowser } from '../dev/hot-reloader-types'
import type { NextConfigComplete } from '../config-shared'

Expand All @@ -17,6 +18,9 @@ export interface McpServerOptions {
sendHmrMessage: (message: HmrMessageSentToBrowser) => void
getActiveConnectionCount: () => number
getDevServerUrl: () => string | undefined
getTurbopackProject?: () =>
| import('../../build/swc/types').Project
| undefined
}

let mcpServer: McpServer | undefined
Expand Down Expand Up @@ -55,5 +59,9 @@ export const getOrCreateMcpServer = (options: McpServerOptions) => {
appDir: options.appDir,
})

if (options.getTurbopackProject) {
registerGetCompilationIssuesTool(mcpServer, options.getTurbopackProject)
}

return mcpServer
}
64 changes: 64 additions & 0 deletions packages/next/src/server/mcp/tools/get-compilation-issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp'
import type { Project } from '../../../build/swc/types'
import { mcpTelemetryTracker } from '../mcp-telemetry-tracker'

export function registerGetCompilationIssuesTool(
server: McpServer,
getProject: () => Project | undefined
) {
server.registerTool(
'get_compilation_issues',
{
description:
'Build the module graph for all routes and return all compilation issues ' +
'(resolve errors, missing modules, transform errors, etc.). ' +
'Does not require a browser session. Covers all routes proactively.',
inputSchema: {},
},
async () => {
mcpTelemetryTracker.recordToolCall('mcp/get_compilation_issues')

try {
const project = getProject()
if (!project) {
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
error:
'Turbopack project is not available. This tool requires the Turbopack bundler.',
}),
},
],
}
}

const result = await project.getAllCompilationIssues()

return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
issues: result.issues,
diagnostics: result.diagnostics,
}),
},
],
}
} catch (error) {
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
},
],
}
}
}
)
}
1 change: 1 addition & 0 deletions packages/next/src/telemetry/events/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export type McpToolName =
| 'mcp/get_project_metadata'
| 'mcp/get_routes'
| 'mcp/get_server_action_by_id'
| 'mcp/get_compilation_issues'

export type EventMcpToolUsage = {
toolName: McpToolName
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { something } from './non-existent-module'

export default function MissingModulePage() {
return <div>{something}</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function HomePage() {
return <div>Home Page - No Errors</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function SyntaxErrorPage() {
return <div>Broken JSX
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
experimental: {
mcpServer: true,
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import path from 'path'
import { nextTestSetup } from 'e2e-utils'

describe('mcp-server get_compilation_issues tool', () => {
const { next, skipped } = nextTestSetup({
files: path.join(__dirname, 'fixtures', 'compilation-errors-app'),
skipDeployment: true,
})

if (skipped) {
return
}

async function callMcpTool(id: string) {
const response = await fetch(`${next.url}/_next/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'get_compilation_issues', arguments: {} },
}),
})

const text = await response.text()
const match = text.match(/data: ({.*})/s)
expect(match).toBeTruthy()
const result = JSON.parse(match![1])
return JSON.parse(result.result?.content?.[0]?.text)
}

it('should return compilation issues without requiring a browser session', async () => {
const response = await callMcpTool('test-no-session')
expect(response).toHaveProperty('issues')
expect(response).toHaveProperty('diagnostics')
expect(Array.isArray(response.issues)).toBe(true)
})

it('should detect module-not-found errors', async () => {
const response = await callMcpTool('test-module-not-found')

const errorIssues = response.issues.filter(
(issue: any) => issue.severity === 'error' || issue.severity === 'fatal'
)
expect(errorIssues.length).toBeGreaterThan(0)

const moduleNotFoundIssue = errorIssues.find(
(issue: any) =>
issue.filePath.includes('missing-module') ||
JSON.stringify(issue.title).includes('non-existent-module')
)
expect(moduleNotFoundIssue).toBeDefined()
})

it('should detect syntax errors', async () => {
const response = await callMcpTool('test-syntax-error')

const errorIssues = response.issues.filter(
(issue: any) => issue.severity === 'error' || issue.severity === 'fatal'
)

const syntaxErrorIssue = errorIssues.find((issue: any) =>
issue.filePath.includes('syntax-error')
)
expect(syntaxErrorIssue).toBeDefined()
})

it('should include issue metadata fields', async () => {
const response = await callMcpTool('test-issue-shape')

const errorIssues = response.issues.filter(
(issue: any) => issue.severity === 'error' || issue.severity === 'fatal'
)
expect(errorIssues.length).toBeGreaterThan(0)

const issue = errorIssues[0]
expect(issue).toHaveProperty('severity')
expect(issue).toHaveProperty('filePath')
expect(issue).toHaveProperty('title')
expect(typeof issue.severity).toBe('string')
expect(typeof issue.filePath).toBe('string')
})
})
Loading