mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
next mcp router
This commit is contained in:
@@ -438,6 +438,15 @@ program
|
||||
)
|
||||
.usage('[directory] [options]')
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description(
|
||||
'Starts an MCP server using stdio transport. Manages sessions for all Next.js dev servers on this machine.'
|
||||
)
|
||||
.action(() => {
|
||||
import('../cli/next-mcp-stdio.js')
|
||||
})
|
||||
|
||||
const internal = program
|
||||
.command('internal')
|
||||
.description(
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp'
|
||||
import { StdioServerTransport } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/stdio'
|
||||
import { z } from 'next/dist/compiled/zod'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const DEV_SERVER_REGISTRY_PATH = path.join(os.tmpdir(), 'next-dev-servers.json')
|
||||
|
||||
const HEARTBEAT_TIMEOUT_MS = 60000
|
||||
const REGISTRY_POLL_INTERVAL_MS = 2000
|
||||
|
||||
interface DevServerInfo {
|
||||
port: number
|
||||
hostname: string
|
||||
pid: number
|
||||
projectPath: string
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
interface DevServerRegistry {
|
||||
[projectPath: string]: DevServerInfo
|
||||
}
|
||||
|
||||
let devServerRegistry: DevServerRegistry = {}
|
||||
let registryWatcher: fs.FSWatcher | null = null
|
||||
let cleanupRegistryInterval: NodeJS.Timeout | null = null
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function loadDevServerRegistry(): DevServerRegistry {
|
||||
try {
|
||||
if (!fs.existsSync(DEV_SERVER_REGISTRY_PATH)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(DEV_SERVER_REGISTRY_PATH, 'utf-8')
|
||||
if (!content.trim()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const registry = JSON.parse(content) as DevServerRegistry
|
||||
const now = Date.now()
|
||||
const cleanedRegistry: DevServerRegistry = {}
|
||||
|
||||
for (const [projectPath, info] of Object.entries(registry)) {
|
||||
if (
|
||||
typeof info.port !== 'number' ||
|
||||
typeof info.hostname !== 'string' ||
|
||||
typeof info.pid !== 'number' ||
|
||||
typeof info.projectPath !== 'string' ||
|
||||
typeof info.lastSeen !== 'number'
|
||||
) {
|
||||
console.error(
|
||||
`[MCP] Invalid registry entry for ${projectPath}, skipping`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (now - info.lastSeen > HEARTBEAT_TIMEOUT_MS) {
|
||||
console.error(
|
||||
`[MCP] Dev server at ${projectPath} is stale (last seen ${Math.round((now - info.lastSeen) / 1000)}s ago), removing`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isProcessAlive(info.pid)) {
|
||||
console.error(
|
||||
`[MCP] Dev server at ${projectPath} (PID ${info.pid}) is not running, removing`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
cleanedRegistry[projectPath] = info
|
||||
}
|
||||
|
||||
return cleanedRegistry
|
||||
} catch (error) {
|
||||
console.error('[MCP] Failed to load dev server registry:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function watchDevServerRegistry(
|
||||
onChange: (registry: DevServerRegistry) => void
|
||||
): () => void {
|
||||
const dir = path.dirname(DEV_SERVER_REGISTRY_PATH)
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MCP] Failed to create registry directory:', error)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
registryWatcher = fs.watch(
|
||||
dir,
|
||||
{ persistent: false },
|
||||
(eventType, filename) => {
|
||||
if (filename === path.basename(DEV_SERVER_REGISTRY_PATH)) {
|
||||
try {
|
||||
const registry = loadDevServerRegistry()
|
||||
onChange(registry)
|
||||
} catch (error) {
|
||||
console.error('[MCP] Error handling registry change:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registryWatcher.on('error', (error) => {
|
||||
console.error('[MCP] Registry watcher error:', error)
|
||||
})
|
||||
|
||||
cleanupRegistryInterval = setInterval(() => {
|
||||
try {
|
||||
const registry = loadDevServerRegistry()
|
||||
onChange(registry)
|
||||
} catch (error) {
|
||||
console.error('[MCP] Error during periodic registry cleanup:', error)
|
||||
}
|
||||
}, REGISTRY_POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
if (registryWatcher) {
|
||||
registryWatcher.close()
|
||||
registryWatcher = null
|
||||
}
|
||||
if (cleanupRegistryInterval) {
|
||||
clearInterval(cleanupRegistryInterval)
|
||||
cleanupRegistryInterval = null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MCP] Failed to setup registry watcher:', error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
function findDevServerByContext(context?: string): DevServerInfo | null {
|
||||
const servers = Object.values(devServerRegistry)
|
||||
|
||||
if (servers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (servers.length === 1) {
|
||||
return servers[0]
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (devServerRegistry[context]) {
|
||||
return devServerRegistry[context]
|
||||
}
|
||||
|
||||
const normalizedContext = path.normalize(context)
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.projectPath === normalizedContext) {
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.projectPath.includes(normalizedContext)) {
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of servers) {
|
||||
if (normalizedContext.includes(path.basename(server.projectPath))) {
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function proxyMcpRequest(
|
||||
method: string,
|
||||
params: unknown,
|
||||
context?: string,
|
||||
toolName?: string
|
||||
): Promise<any> {
|
||||
const devServer = findDevServerByContext(context)
|
||||
|
||||
if (!devServer) {
|
||||
const projectCount = Object.keys(devServerRegistry).length
|
||||
if (projectCount === 0) {
|
||||
throw new Error(
|
||||
'No dev servers running. Please start a dev server with `next dev`'
|
||||
)
|
||||
} else if (context) {
|
||||
const availableProjects = Object.keys(devServerRegistry)
|
||||
.map((p) => ` - ${p}`)
|
||||
.join('\n')
|
||||
throw new Error(
|
||||
`Could not find dev server for context "${context}".\n` +
|
||||
`Available projects:\n${availableProjects}\n\n` +
|
||||
`Use get_project_path() with one of the paths above.`
|
||||
)
|
||||
} else {
|
||||
const availableProjects = Object.keys(devServerRegistry)
|
||||
.map((p) => ` - ${p}`)
|
||||
.join('\n')
|
||||
throw new Error(
|
||||
`Multiple dev servers running (${projectCount}). Please specify projectPath.\n` +
|
||||
`Available projects:\n${availableProjects}\n\n` +
|
||||
`Example: get_errors({ projectPath: "${Object.keys(devServerRegistry)[0]}" })`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const url = `http://${devServer.hostname}:${devServer.port}/_next/mcp`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params,
|
||||
id: 1,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error(
|
||||
`Request to dev server timed out (${devServer.projectPath}). ` +
|
||||
`The dev server may be unresponsive.`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to connect to dev server at ${devServer.hostname}:${devServer.port} (${devServer.projectPath}). ` +
|
||||
`Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Dev server returned HTTP ${response.status}: ${response.statusText} (${devServer.projectPath})`
|
||||
)
|
||||
}
|
||||
|
||||
let data: {
|
||||
error?: { code?: number; message?: string }
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid JSON response from dev server (${devServer.projectPath})`
|
||||
)
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
if (
|
||||
toolName &&
|
||||
data.error.message &&
|
||||
(data.error.message.toLowerCase().includes('unknown tool') ||
|
||||
data.error.message.toLowerCase().includes('not found'))
|
||||
) {
|
||||
throw new Error(
|
||||
`Tool '${toolName}' is not supported in this project (${devServer.projectPath}).\n\n` +
|
||||
`This tool may require a newer version of Next.js.\n` +
|
||||
`Try upgrading: npm install next@latest`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
data.error.message || `MCP error code ${data.error.code || 'unknown'}`
|
||||
)
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
|
||||
async function startStdioMcpServer(): Promise<void> {
|
||||
devServerRegistry = loadDevServerRegistry()
|
||||
|
||||
const cleanup = watchDevServerRegistry((registry) => {
|
||||
devServerRegistry = registry
|
||||
const serverCount = Object.keys(registry).length
|
||||
console.error(`[MCP] Registry updated: ${serverCount} dev server(s) active`)
|
||||
for (const [projectPath, info] of Object.entries(registry)) {
|
||||
console.error(`[MCP] - ${projectPath} on ${info.hostname}:${info.port}`)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.error('[MCP] Received SIGINT, shutting down...')
|
||||
cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.error('[MCP] Received SIGTERM, shutting down...')
|
||||
cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('[MCP] Uncaught exception:', error)
|
||||
cleanup()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[MCP] Unhandled rejection:', reason)
|
||||
cleanup()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
const mcpServer = new McpServer({
|
||||
name: 'Next.js Dev Server Manager (stdio)',
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
mcpServer.registerTool(
|
||||
'list_dev_servers',
|
||||
{
|
||||
description: 'List all running Next.js dev servers',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const servers = Object.entries(devServerRegistry).map(
|
||||
([projectPath, info]) => ({
|
||||
projectPath,
|
||||
port: info.port,
|
||||
hostname: info.hostname,
|
||||
pid: info.pid,
|
||||
})
|
||||
)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ servers }, null, 2),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'get_project_path',
|
||||
{
|
||||
description:
|
||||
'Get the absolute path to the Next.js project directory. Optional projectPath context to specify which dev server.',
|
||||
inputSchema: {
|
||||
projectPath: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
return proxyMcpRequest(
|
||||
'tools/call',
|
||||
{
|
||||
name: 'get_project_path',
|
||||
arguments: {},
|
||||
},
|
||||
request.projectPath,
|
||||
'get_project_path'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'get_errors',
|
||||
{
|
||||
description:
|
||||
'Get the current error state of the app when rendered in the browser. Optional projectPath to specify which dev server.',
|
||||
inputSchema: {
|
||||
projectPath: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
return proxyMcpRequest(
|
||||
'tools/call',
|
||||
{
|
||||
name: 'get_errors',
|
||||
arguments: {},
|
||||
},
|
||||
request.projectPath,
|
||||
'get_errors'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'get_page_metadata',
|
||||
{
|
||||
description:
|
||||
'Get metadata about the currently loaded page in the browser. Optional projectPath to specify which dev server.',
|
||||
inputSchema: {
|
||||
projectPath: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
return proxyMcpRequest(
|
||||
'tools/call',
|
||||
{
|
||||
name: 'get_page_metadata',
|
||||
arguments: {},
|
||||
},
|
||||
request.projectPath,
|
||||
'get_page_metadata'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'get_logs',
|
||||
{
|
||||
description:
|
||||
'Get recent build and runtime logs from the dev server. Optional projectPath to specify which dev server.',
|
||||
inputSchema: {
|
||||
projectPath: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
return proxyMcpRequest(
|
||||
'tools/call',
|
||||
{
|
||||
name: 'get_logs',
|
||||
arguments: {},
|
||||
},
|
||||
request.projectPath,
|
||||
'get_logs'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'get_server_action_by_id',
|
||||
{
|
||||
description:
|
||||
'Get information about a specific server action by its ID. Optional projectPath to specify which dev server.',
|
||||
inputSchema: {
|
||||
actionId: z.string(),
|
||||
projectPath: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
return proxyMcpRequest(
|
||||
'tools/call',
|
||||
{
|
||||
name: 'get_server_action_by_id',
|
||||
arguments: { actionId: request.actionId },
|
||||
},
|
||||
request.projectPath,
|
||||
'get_server_action_by_id'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
await mcpServer.connect(transport)
|
||||
|
||||
const serverCount = Object.keys(devServerRegistry).length
|
||||
console.error('[MCP] Session manager started')
|
||||
console.error('[MCP] Managing all Next.js dev servers on this machine')
|
||||
console.error(`[MCP] Currently tracking ${serverCount} dev server(s)`)
|
||||
|
||||
if (serverCount === 0) {
|
||||
console.error('[MCP] Waiting for dev servers to start...')
|
||||
console.error('[MCP] Run `next dev` in a Next.js project to register it')
|
||||
}
|
||||
}
|
||||
|
||||
startStdioMcpServer().catch((error) => {
|
||||
console.error('[MCP] Fatal error:', error)
|
||||
console.error('[MCP] Stack:', error instanceof Error ? error.stack : error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -364,6 +364,11 @@ export async function startServer(
|
||||
process.env.__NEXT_EXPERIMENTAL_HTTPS = '1'
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
const { registerDevServer } = require('../mcp/standalone-mcp-server')
|
||||
registerDevServer(dir, port, formattedHostname)
|
||||
}
|
||||
|
||||
// Only load env and config in dev to for logging purposes
|
||||
let envInfo: string[] | undefined
|
||||
let experimentalFeatures: ConfiguredExperimentalFeature[] | undefined
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
# Next.js MCP (Model Context Protocol) Integration
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Next.js provides an MCP server that allows AI assistants like Claude Code to introspect and interact with Next.js development servers.
|
||||
|
||||
### Components
|
||||
|
||||
1. **Session Manager** (`next-mcp-stdio.ts`)
|
||||
- Runs as a standalone process via `npx next@canary mcp`
|
||||
- Manages multiple Next.js dev servers across the machine
|
||||
- Uses stdio transport for communication with Claude Code
|
||||
- Routes MCP requests to the correct dev server
|
||||
|
||||
2. **Dev Server Registry** (`/tmp/next-dev-servers.json`)
|
||||
- Global registry of all running Next.js dev servers
|
||||
- Updated via heartbeat every 30 seconds
|
||||
- Auto-cleaned of stale/dead servers
|
||||
|
||||
3. **Dev Server Integration** (`standalone-mcp-server.ts`)
|
||||
- Each `next dev` registers itself in the global registry
|
||||
- Implements MCP tools via `/_next/mcp` endpoint
|
||||
- Provides access to errors, logs, metadata, etc.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Claude Code │
|
||||
└────────┬─────────┘
|
||||
│ stdio (JSON-RPC)
|
||||
↓
|
||||
┌────────────────────────────────┐
|
||||
│ Session Manager │
|
||||
│ (npx next@canary mcp) │
|
||||
│ • Watches registry │
|
||||
│ • Routes requests │
|
||||
└─────┬────────────┬────────────┘
|
||||
│ HTTP │ HTTP
|
||||
↓ ↓
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ next@14 │ │ next@15 │
|
||||
│ :3000 │ │ :3001 │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Claude Code Setup
|
||||
|
||||
Add to Claude Code's MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nextjs": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "next@canary", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Start dev server - it auto-registers
|
||||
cd ~/my-next-app
|
||||
next dev
|
||||
|
||||
# Claude Code can now access MCP tools
|
||||
```
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
### `list_dev_servers()`
|
||||
|
||||
Lists all running Next.js dev servers.
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"projectPath": "/Users/alice/app-a",
|
||||
"port": 3000,
|
||||
"hostname": "localhost",
|
||||
"pid": 12345
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `get_project_path({ projectPath?: string })`
|
||||
|
||||
Get the absolute path to a Next.js project.
|
||||
|
||||
**Parameters:**
|
||||
- `projectPath` (optional): Path hint for multi-project scenarios
|
||||
|
||||
**Returns:** Absolute project path
|
||||
|
||||
### `get_errors({ projectPath?: string })`
|
||||
|
||||
Get current browser error state including build and runtime errors.
|
||||
|
||||
**Parameters:**
|
||||
- `projectPath` (optional): Path hint for multi-project scenarios
|
||||
|
||||
**Returns:** Formatted error messages with source-mapped stack traces
|
||||
|
||||
### `get_page_metadata({ projectPath?: string })`
|
||||
|
||||
Get metadata about the currently loaded page in the browser.
|
||||
|
||||
**Parameters:**
|
||||
- `projectPath` (optional): Path hint for multi-project scenarios
|
||||
|
||||
**Returns:** Page metadata (route, params, searchParams, etc.)
|
||||
|
||||
### `get_logs({ projectPath?: string })`
|
||||
|
||||
Get recent build and runtime logs from the dev server.
|
||||
|
||||
**Parameters:**
|
||||
- `projectPath` (optional): Path hint for multi-project scenarios
|
||||
|
||||
**Returns:** Recent log entries
|
||||
|
||||
### `get_server_action_by_id({ actionId: string, projectPath?: string })`
|
||||
|
||||
Get information about a specific server action.
|
||||
|
||||
**Parameters:**
|
||||
- `actionId` (required): The server action ID
|
||||
- `projectPath` (optional): Path hint for multi-project scenarios
|
||||
|
||||
**Returns:** Server action details
|
||||
|
||||
## Multi-Project Support
|
||||
|
||||
### Single Project (Auto-routing)
|
||||
|
||||
```javascript
|
||||
// No projectPath needed - routes to only dev server
|
||||
get_errors()
|
||||
```
|
||||
|
||||
### Multiple Projects (Requires Context)
|
||||
|
||||
```javascript
|
||||
// List all dev servers
|
||||
list_dev_servers()
|
||||
|
||||
// Specify project explicitly
|
||||
get_errors({ projectPath: "/Users/alice/app-a" })
|
||||
get_errors({ projectPath: "app-a" }) // Partial match OK
|
||||
```
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
The session manager advertises all tools from the version you run (`next@canary`), but each dev server implements only the tools it supports.
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Session manager from next@canary has get_ai_hints
|
||||
// Dev server running next@14 doesn't have it
|
||||
|
||||
get_ai_hints({ projectPath: "/old-project" })
|
||||
// Error: Tool 'get_ai_hints' is not supported in this project.
|
||||
// This tool may require a newer version of Next.js.
|
||||
// Try upgrading: npm install next@latest
|
||||
```
|
||||
|
||||
This allows gradual upgrades and testing of new features.
|
||||
|
||||
## Registry Format
|
||||
|
||||
**Location:** `/tmp/next-dev-servers.json` (macOS/Linux) or `%TEMP%\next-dev-servers.json` (Windows)
|
||||
|
||||
**Schema:**
|
||||
```typescript
|
||||
{
|
||||
"/absolute/path/to/project": {
|
||||
"port": 3000,
|
||||
"hostname": "localhost",
|
||||
"pid": 12345,
|
||||
"projectPath": "/absolute/path/to/project",
|
||||
"lastSeen": 1704067200000 // Unix timestamp
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup & Lifecycle
|
||||
|
||||
### Automatic Cleanup
|
||||
|
||||
Servers are removed from registry when:
|
||||
- Process exits normally (`process.on('exit')`)
|
||||
- Process receives SIGINT/SIGTERM
|
||||
- Heartbeat expires (>60 seconds without update)
|
||||
- Process PID no longer exists
|
||||
|
||||
### Manual Cleanup
|
||||
|
||||
```bash
|
||||
# Remove stale registry
|
||||
rm /tmp/next-dev-servers.json
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### No Dev Servers Running
|
||||
|
||||
```
|
||||
Error: No dev servers running. Please start a dev server with `next dev`
|
||||
```
|
||||
|
||||
### Multiple Dev Servers (No Context)
|
||||
|
||||
```
|
||||
Error: Multiple dev servers running (2). Please specify projectPath.
|
||||
Available projects:
|
||||
- /Users/alice/app-a
|
||||
- /Users/alice/app-b
|
||||
|
||||
Example: get_errors({ projectPath: "/Users/alice/app-a" })
|
||||
```
|
||||
|
||||
### Dev Server Not Found
|
||||
|
||||
```
|
||||
Error: Could not find dev server for context "app-c".
|
||||
Available projects:
|
||||
- /Users/alice/app-a
|
||||
- /Users/alice/app-b
|
||||
|
||||
Use get_project_path() with one of the paths above.
|
||||
```
|
||||
|
||||
### Connection Timeout
|
||||
|
||||
```
|
||||
Error: Request to dev server timed out (/Users/alice/app-a).
|
||||
The dev server may be unresponsive.
|
||||
```
|
||||
|
||||
### Tool Not Supported
|
||||
|
||||
```
|
||||
Error: Tool 'get_ai_hints' is not supported in this project (/Users/alice/app-a).
|
||||
|
||||
This tool may require a newer version of Next.js.
|
||||
Try upgrading: npm install next@latest
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Atomic File Writes
|
||||
|
||||
Registry updates use atomic writes to prevent corruption:
|
||||
|
||||
```typescript
|
||||
// Write to temp file
|
||||
fs.writeFileSync(`${registryPath}.${pid}.tmp`, data)
|
||||
// Atomic rename
|
||||
fs.renameSync(tempPath, registryPath)
|
||||
```
|
||||
|
||||
### Heartbeat System
|
||||
|
||||
Each dev server updates `lastSeen` every 30 seconds. Session manager polls every 2 seconds to clean stale entries.
|
||||
|
||||
### Process Detection
|
||||
|
||||
Uses `process.kill(pid, 0)` to check if process is alive without sending a signal.
|
||||
|
||||
### Signal Handling
|
||||
|
||||
Proper exit codes:
|
||||
- `SIGINT` → exit code 130
|
||||
- `SIGTERM` → exit code 143
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Session Manager Logs
|
||||
|
||||
```bash
|
||||
# Run manually to see logs
|
||||
npx next@canary mcp
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
[MCP] Session manager started
|
||||
[MCP] Managing all Next.js dev servers on this machine
|
||||
[MCP] Currently tracking 0 dev server(s)
|
||||
[MCP] Waiting for dev servers to start...
|
||||
[MCP] Run `next dev` in a Next.js project to register it
|
||||
[MCP] Registry updated: 1 dev server(s) active
|
||||
[MCP] - /Users/alice/app-a on localhost:3000
|
||||
```
|
||||
|
||||
### Check Registry
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
cat /tmp/next-dev-servers.json | jq
|
||||
|
||||
# Windows
|
||||
type %TEMP%\next-dev-servers.json
|
||||
```
|
||||
|
||||
### Test MCP Tools
|
||||
|
||||
Use `fetch` to test tools directly:
|
||||
|
||||
```javascript
|
||||
const response = await fetch('http://localhost:3000/_next/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_errors', arguments: {} },
|
||||
id: 1
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Local-only**: Registry and MCP endpoints are only accessible on localhost
|
||||
2. **No auth**: Assumes trusted local environment
|
||||
3. **PID validation**: Prevents stale entries from zombie processes
|
||||
4. **Path normalization**: Prevents path traversal attacks
|
||||
|
||||
## Performance
|
||||
|
||||
- **Registry size**: ~1KB per dev server
|
||||
- **Heartbeat overhead**: ~1 file write per 30s per dev server
|
||||
- **Tool call latency**: <100ms (HTTP proxy overhead)
|
||||
- **Memory**: ~5MB for session manager
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **WebSocket transport**: If MCP adds WebSocket support
|
||||
2. **Tool discovery**: Dynamic tool registration from dev servers
|
||||
3. **Authentication**: Add token-based auth for registry access
|
||||
4. **Remote dev servers**: Support cloud dev environments
|
||||
5. **Performance metrics**: Track tool call latency and usage
|
||||
@@ -0,0 +1,199 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const DEV_SERVER_REGISTRY_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
'next-dev-servers.json'
|
||||
)
|
||||
const HEARTBEAT_INTERVAL_MS = 30000
|
||||
|
||||
interface DevServerInfo {
|
||||
port: number
|
||||
hostname: string
|
||||
pid: number
|
||||
projectPath: string
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
interface DevServerRegistry {
|
||||
[projectPath: string]: DevServerInfo
|
||||
}
|
||||
|
||||
let heartbeatInterval: NodeJS.Timeout | null = null
|
||||
let registrationComplete = false
|
||||
|
||||
function readRegistryFile(): DevServerRegistry {
|
||||
try {
|
||||
if (!fs.existsSync(DEV_SERVER_REGISTRY_PATH)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(DEV_SERVER_REGISTRY_PATH, 'utf-8')
|
||||
if (!content.trim()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return JSON.parse(content) as DevServerRegistry
|
||||
} catch (error) {
|
||||
console.error('[MCP] Error reading registry file:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeRegistryFile(registry: DevServerRegistry): boolean {
|
||||
try {
|
||||
const dir = path.dirname(DEV_SERVER_REGISTRY_PATH)
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
const tempPath = `${DEV_SERVER_REGISTRY_PATH}.${process.pid}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(registry, null, 2), 'utf-8')
|
||||
|
||||
fs.renameSync(tempPath, DEV_SERVER_REGISTRY_PATH)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[MCP] Error writing registry file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeartbeat(projectPath: string): void {
|
||||
try {
|
||||
const registry = readRegistryFile()
|
||||
|
||||
if (registry[projectPath]) {
|
||||
registry[projectPath].lastSeen = Date.now()
|
||||
writeRegistryFile(registry)
|
||||
} else {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MCP] Failed to update heartbeat:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDevServer(
|
||||
projectPath: string,
|
||||
port: number,
|
||||
hostname: string
|
||||
): void {
|
||||
if (registrationComplete) {
|
||||
console.warn(
|
||||
'[MCP] Dev server already registered, skipping duplicate registration'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedPath = path.resolve(projectPath)
|
||||
const registry = readRegistryFile()
|
||||
const currentPid = process.pid
|
||||
|
||||
if (registry[normalizedPath]) {
|
||||
const existing = registry[normalizedPath]
|
||||
|
||||
if (existing.pid !== currentPid) {
|
||||
const isOtherServerAlive = isProcessAlive(existing.pid)
|
||||
|
||||
if (isOtherServerAlive) {
|
||||
console.error(
|
||||
`\n[MCP] ERROR: Another dev server is already running for this project!\n` +
|
||||
` Project: ${normalizedPath}\n` +
|
||||
` Existing server: PID ${existing.pid} on ${existing.hostname}:${existing.port}\n` +
|
||||
` This server: PID ${currentPid} on ${hostname}:${port}\n\n` +
|
||||
`Please stop the other dev server first, or use a different project directory.\n`
|
||||
)
|
||||
return
|
||||
} else {
|
||||
console.warn(
|
||||
`[MCP] Stale server entry found (PID ${existing.pid} is dead), replacing...`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry[normalizedPath] = {
|
||||
port,
|
||||
hostname,
|
||||
pid: currentPid,
|
||||
projectPath: normalizedPath,
|
||||
lastSeen: Date.now(),
|
||||
}
|
||||
|
||||
if (!writeRegistryFile(registry)) {
|
||||
throw new Error('Failed to write registry file')
|
||||
}
|
||||
|
||||
registrationComplete = true
|
||||
|
||||
heartbeatInterval = setInterval(
|
||||
() => updateHeartbeat(normalizedPath),
|
||||
HEARTBEAT_INTERVAL_MS
|
||||
)
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
unregisterDevServer(normalizedPath)
|
||||
}
|
||||
|
||||
process.once('exit', cleanup)
|
||||
process.once('SIGINT', () => {
|
||||
cleanup()
|
||||
process.exit(130)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
cleanup()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[MCP] Registered dev server: ${normalizedPath} on ${hostname}:${port}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[MCP] Failed to register dev server:', error)
|
||||
registrationComplete = false
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterDevServer(projectPath: string): void {
|
||||
if (!registrationComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedPath = path.resolve(projectPath)
|
||||
const registry = readRegistryFile()
|
||||
|
||||
if (!registry[normalizedPath]) {
|
||||
return
|
||||
}
|
||||
|
||||
delete registry[normalizedPath]
|
||||
|
||||
writeRegistryFile(registry)
|
||||
|
||||
console.log(`[MCP] Unregistered dev server: ${normalizedPath}`)
|
||||
registrationComplete = false
|
||||
} catch (error) {
|
||||
console.error('[MCP] Failed to unregister dev server:', error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user