Repository: vercel/next.js
Pull request: [mcp] capture next config schema errors

Capture the schema validation errors into mcp `get_errors()`

During dev, we also emit the schema validation errors when encounter errors while loading next.config. We capture those validation errors into a global error state for next instance, and expose them through mcp tool `get_errors`.

When users fix the next.config schema error in dev, the next config can be reloaded, in that case the process will be changed. So we use `process.pid` as part of the cache key for next config loader, in case we need a new validation to decide either we need to reset or update the config errors.

Changed files:
filename: packages/next/src/server/config.ts
patch: @@ -46,6 +46,7 @@ import { interopDefault } from '../lib/interop-default'
 import { djb2Hash } from '../shared/lib/hash'
 import type { NextAdapter } from '../build/adapter/build-complete'
 import { HardDeprecatedConfigError } from '../shared/lib/errors/hard-deprecated-config-error'
+import { NextInstanceErrorState } from './mcp/tools/next-instance-error-state'
 
 export { normalizeConfig } from './config-shared'
 export type { DomainLocale, NextConfig } from './config-shared'
@@ -1302,16 +1303,18 @@ function getCacheKey(
   dir: string,
   customConfig?: object | null,
   reactProductionProfiling?: boolean,
-  debugPrerender?: boolean
+  debugPrerender?: boolean,
+  pid?: number
 ): string {
   // The next.config.js is unique per project, so we can use the dir as the major key
-  // to generate the unique config key.
+  // to generate the unique config key. Include PID to invalidate on server restart.
   const keyData = JSON.stringify({
     dir,
     phase,
     hasCustomConfig: Boolean(customConfig),
     reactProductionProfiling: Boolean(reactProductionProfiling),
     debugPrerender: Boolean(debugPrerender),
+    pid: pid || 0,
   })
 
   return djb2Hash(keyData).toString(36)
@@ -1338,12 +1341,14 @@ export default async function loadConfig(
   } = {}
 ): Promise<NextConfigComplete> {
   // Generate cache key based on parameters that affect config output
+  // Include process.pid to invalidate cache on server restart
   const cacheKey = getCacheKey(
     phase,
     dir,
     customConfig,
     reactProductionProfiling,
-    debugPrerender
+    debugPrerender,
+    process.pid
   )
 
   // Check if we have a cached result
@@ -1360,6 +1365,10 @@ export default async function loadConfig(
     }
 
     return cachedResult.config
+  } else {
+    // Reset next.config errors before loading config
+    // This happens on every config load to ensure fresh validation
+    NextInstanceErrorState.nextConfig = []
   }
 
   // Original implementation continues below...
@@ -1486,6 +1495,9 @@ export default async function loadConfig(
         return userConfigModule
       }
     } catch (err) {
+      // Capture the error for MCP tool reporting
+      NextInstanceErrorState.nextConfig.push(err)
+
       // TODO: Modify docs to add cases of failing next.config.ts transformation
       curLog.error(
         `Failed to load ${configFileName}, see more info here https://nextjs.org/docs/messages/next-config-error`
@@ -1527,7 +1539,18 @@ export default async function loadConfig(
 
     // Always validate the config against schema in non minimal mode
     if (!process.env.NEXT_MINIMAL && !silent) {
-      validateConfigSchema(userConfig, configFileName, curLog.warn)
+      await validateConfigSchema(
+        userConfig,
+        configFileName,
+        curLog.warn,
+        (messages) => {
+          // Capture validation messages for MCP error reporting
+          if (messages.length > 0) {
+            const fullMessage = messages.join('\n')
+            NextInstanceErrorState.nextConfig.push(new Error(fullMessage))
+          }
+        }
+      )
     }
 
     if ((userConfig as any).target && (userConfig as any).target !== 'server') {
@@ -1906,7 +1929,8 @@ function cloneObject(obj: any): any {
 async function validateConfigSchema(
   userConfig: NextConfig,
   configFileName: string,
-  warn: (message: string) => void
+  warn: (message: string) => void,
+  onValidationMessages?: (messages: string[]) => void
 ) {
   // We only validate the config against schema in non minimal mode
   const { configSchema } =
@@ -1930,6 +1954,11 @@ async function validateConfigSchema(
       'See more info here: https://nextjs.org/docs/messages/invalid-next-config'
     )
 
+    // Call the callback with validation messages if provided
+    if (onValidationMessages) {
+      onValidationMessages(messages)
+    }
+
     if (shouldExit) {
       for (const message of messages) {
         console.error(message)

filename: packages/next/src/server/mcp/tools/get-errors.ts
patch: @@ -1,13 +1,17 @@
 /**
- * MCP tool for retrieving browser error state.
+ * MCP tool for retrieving error state from Next.js dev server.
  *
- * This tool demonstrates server-to-browser communication in Next.js dev mode.
- * It leverages the existing HMR infrastructure rather than creating new channels.
+ * This tool provides comprehensive error reporting including:
+ * - Next.js global errors (e.g., next.config validation errors)
+ * - Browser runtime errors with source-mapped stack traces
+ * - Build errors from webpack/turbopack compilation
+ *
+ * For browser errors, it leverages the HMR infrastructure for server-to-browser communication.
  *
  * Flow:
  *   MCP client → server generates request ID → HMR message to browser →
  *   browser queries error overlay state → HMR response back → server performs source mapping →
- *   formatted output.
+ *   combined with global errors → formatted output.
  */
 import type { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp'
 import type { OverlayState } from '../../../next-devtools/dev-overlay/shared'
@@ -21,6 +25,7 @@ import {
   handleBrowserPageResponse,
   DEFAULT_BROWSER_REQUEST_TIMEOUT_MS,
 } from './utils/browser-communication'
+import { NextInstanceErrorState } from './next-instance-error-state'
 
 export function registerGetErrorsTool(
   server: McpServer,
@@ -31,7 +36,7 @@ export function registerGetErrorsTool(
     'get_errors',
     {
       description:
-        'Get the current error state of the app when rendered in the browser, including any build or runtime errors with source-mapped stack traces',
+        'Get the current error state from the Next.js dev server, including Next.js global errors (e.g., next.config validation), browser runtime errors, and build errors with source-mapped stack traces',
       inputSchema: {},
     },
     async (_request) => {
@@ -55,18 +60,21 @@ export function registerGetErrorsTool(
           DEFAULT_BROWSER_REQUEST_TIMEOUT_MS
         )
 
-        const errorsByUrl = new Map<string, OverlayState>()
+        // The error state for each route
+        // key is the route path, value is the error state
+        const routesErrorState = new Map<string, OverlayState>()
         for (const response of responses) {
           if (response.data) {
-            errorsByUrl.set(response.url, response.data)
+            routesErrorState.set(response.url, response.data)
           }
         }
 
-        const hasErrors = Array.from(errorsByUrl.values()).some(
+        const hasRouteErrors = Array.from(routesErrorState.values()).some(
           (state) => state.errors.length > 0 || !!state.buildError
         )
+        const hasInstanceErrors = NextInstanceErrorState.nextConfig.length > 0
 
-        if (!hasErrors) {
+        if (!hasRouteErrors && !hasInstanceErrors) {
           return {
             content: [
               {
@@ -80,7 +88,10 @@ export function registerGetErrorsTool(
           }
         }
 
-        const output = await formatErrors(errorsByUrl)
+        const output = await formatErrors(
+          routesErrorState,
+          NextInstanceErrorState
+        )
 
         return {
           content: [

filename: packages/next/src/server/mcp/tools/next-instance-error-state.ts
patch: @@ -0,0 +1,22 @@
+/**
+ * Global error state for Next.js instance-level errors that are not associated
+ * with a specific browser session or route. This state is exposed through the MCP server's `get_errors`
+ * tool as well. This covers the errors that are global to the Next.js instance, such as errors in next.config.js.
+ *
+ *
+ * ## Usage
+ *
+ * This state is directly manipulated by various parts of the Next.js dev server:
+ *
+ * // Reset the error state
+ * NextInstanceErrorState.[errorType] = []
+ *
+ * // Capture an error for a specific error type
+ * NextInstanceErrorState.[errorType].push(err)
+ *
+ */
+export const NextInstanceErrorState: {
+  nextConfig: unknown[]
+} = {
+  nextConfig: [],
+}

filename: packages/next/src/server/mcp/tools/utils/format-errors.ts
patch: @@ -125,47 +125,75 @@ async function formatRuntimeError(
 }
 
 export async function formatErrors(
-  errorsByUrl: Map<string, OverlayState>
+  errorsByUrl: Map<string, OverlayState>,
+  nextInstanceErrors: { nextConfig: unknown[] } = { nextConfig: [] }
 ): Promise<string> {
-  let output = `# Found errors in ${errorsByUrl.size} browser session(s)\n\n`
+  let output = ''
 
-  for (const [url, overlayState] of errorsByUrl) {
-    const totalErrorCount =
-      overlayState.errors.length + (overlayState.buildError ? 1 : 0)
+  // Format Next.js instance errors first (e.g., next.config.js errors)
+  if (nextInstanceErrors.nextConfig.length > 0) {
+    output += `# Next.js Configuration Errors\n\n`
+    output += `**${nextInstanceErrors.nextConfig.length} error(s) found in next.config**\n\n`
 
-    if (totalErrorCount === 0) continue
-
-    let displayUrl = url
-    try {
-      const urlObj = new URL(url)
-      displayUrl = urlObj.pathname + urlObj.search + urlObj.hash
-    } catch {
-      // If URL parsing fails, use the original URL
-    }
-
-    output += `## Session: ${displayUrl}\n\n`
-    output += `**${totalErrorCount} error(s) found**\n\n`
-
-    // Build errors
-    if (overlayState.buildError) {
-      output += '### Build Error\n\n'
+    nextInstanceErrors.nextConfig.forEach((error, index) => {
+      output += `## Error ${index + 1}\n\n`
       output += '```\n'
-      output += overlayState.buildError
+      if (error instanceof Error) {
+        output += `${error.name}: ${error.message}\n`
+        if (error.stack) {
+          output += error.stack
+        }
+      } else {
+        output += String(error)
+      }
       output += '\n```\n\n'
-    }
-
-    // Runtime errors with source-mapped stack traces
-    if (overlayState.errors.length > 0) {
-      const runtimeErrors = await formatRuntimeError(
-        overlayState.errors,
-        overlayState.routerType === 'app'
-      )
-      output += runtimeErrors
-      output += '\n'
-    }
+    })
 
     output += '---\n\n'
   }
 
+  // Format browser session errors
+  if (errorsByUrl.size > 0) {
+    output += `# Found errors in ${errorsByUrl.size} browser session(s)\n\n`
+
+    for (const [url, overlayState] of errorsByUrl) {
+      const totalErrorCount =
+        overlayState.errors.length + (overlayState.buildError ? 1 : 0)
+
+      if (totalErrorCount === 0) continue
+
+      let displayUrl = url
+      try {
+        const urlObj = new URL(url)
+        displayUrl = urlObj.pathname + urlObj.search + urlObj.hash
+      } catch {
+        // If URL parsing fails, use the original URL
+      }
+
+      output += `## Session: ${displayUrl}\n\n`
+      output += `**${totalErrorCount} error(s) found**\n\n`
+
+      // Build errors
+      if (overlayState.buildError) {
+        output += '### Build Error\n\n'
+        output += '```\n'
+        output += overlayState.buildError
+        output += '\n```\n\n'
+      }
+
+      // Runtime errors with source-mapped stack traces
+      if (overlayState.errors.length > 0) {
+        const runtimeErrors = await formatRuntimeError(
+          overlayState.errors,
+          overlayState.routerType === 'app'
+        )
+        output += runtimeErrors
+        output += '\n'
+      }
+
+      output += '---\n\n'
+    }
+  }
+
   return output.trim()
 }

filename: test/development/mcp-server/mcp-server-get-errors.test.ts
patch: @@ -312,6 +312,59 @@ describe('mcp-server get_errors tool', () => {
       await s2.close()
     }
   })
+
+  it('should capture next.config errors and clear when fixed', async () => {
+    // Read the original config
+    const originalConfig = await next.readFile('next.config.js')
+
+    // Stop server, write invalid config, and restart
+    await next.stop()
+    await next.patchFile(
+      'next.config.js',
+      `module.exports = {
+  experimental: {
+    mcpServer: true,
+    invalidTestProperty: 'this should cause a validation warning',
+  },
+}`
+    )
+    await next.start()
+
+    // Open a browser session
+    await next.browser('/')
+
+    // Check that the config error is captured
+    let errors: string = ''
+    await retry(async () => {
+      const sessionId = 'test-config-error-' + Date.now()
+      errors = await callGetErrors(sessionId)
+      expect(errors).toContain('Next.js Configuration Errors')
+      expect(errors).toContain('error(s) found in next.config')
+    })
+
+    const strippedErrors = stripAnsi(errors)
+    expect(strippedErrors).toContain('Next.js Configuration Errors')
+    expect(strippedErrors).toContain('Invalid next.config.js options detected')
+    expect(strippedErrors).toContain('invalidTestProperty')
+
+    // Stop server, fix the config, and restart
+    await next.stop()
+    await next.patchFile('next.config.js', originalConfig)
+    await next.start()
+
+    // Open a browser session
+    await next.browser('/')
+
+    // Verify the config error is now gone
+    await retry(async () => {
+      const sessionId = 'test-config-fixed-' + Date.now()
+      const fixedErrors = await callGetErrors(sessionId)
+      const strippedFixed = stripAnsi(fixedErrors)
+      expect(strippedFixed).not.toContain('Next.js Configuration Errors')
+      expect(strippedFixed).not.toContain('invalidTestProperty')
+      expect(strippedFixed).toContain('No errors detected')
+    })
+  })
 })
 
 /**