Skip to content

Commit 62248b3

Browse files
authored
MCP server proxy (#16)
1 parent 9c856bb commit 62248b3

File tree

4 files changed

+370
-1
lines changed

4 files changed

+370
-1
lines changed

src/lib/nextjs-runtime-manager.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { exec } from "child_process"
2+
import { promisify } from "util"
3+
4+
const execAsync = promisify(exec)
5+
6+
interface NextJsServerInfo {
7+
port: number
8+
pid: number
9+
command: string
10+
}
11+
12+
interface NextJsMCPTool {
13+
name: string
14+
description?: string
15+
inputSchema?: Record<string, unknown>
16+
}
17+
18+
interface NextJsMCPResponse {
19+
jsonrpc: string
20+
result?: {
21+
tools?: NextJsMCPTool[]
22+
[key: string]: unknown
23+
}
24+
error?: {
25+
code: number
26+
message: string
27+
}
28+
id: number | string
29+
}
30+
31+
async function findNextJsServers(): Promise<NextJsServerInfo[]> {
32+
try {
33+
const { stdout } = await execAsync("ps aux")
34+
const lines = stdout.split("\n")
35+
const servers: NextJsServerInfo[] = []
36+
37+
for (const line of lines) {
38+
if (line.includes("next dev") || line.includes("next-server")) {
39+
const parts = line.trim().split(/\s+/)
40+
const pid = parseInt(parts[1], 10)
41+
const command = parts.slice(10).join(" ")
42+
43+
const portMatch = command.match(/--port[=\s]+(\d+)/) || command.match(/:(\d+)/)
44+
let port = 3000
45+
46+
if (portMatch) {
47+
port = parseInt(portMatch[1], 10)
48+
} else {
49+
const processInfo = await execAsync(`lsof -Pan -p ${pid} -i 2>/dev/null || true`)
50+
const portFromLsof = processInfo.stdout.match(/:(\d+).*LISTEN/)
51+
if (portFromLsof) {
52+
port = parseInt(portFromLsof[1], 10)
53+
}
54+
}
55+
56+
servers.push({ port, pid, command })
57+
}
58+
}
59+
60+
return servers
61+
} catch (error) {
62+
console.error("[Next.js Runtime Manager] Error finding Next.js servers:", error)
63+
return []
64+
}
65+
}
66+
67+
async function makeNextJsMCPRequest(
68+
port: number,
69+
method: string,
70+
params: Record<string, unknown> = {}
71+
): Promise<NextJsMCPResponse> {
72+
const url = `http://localhost:${port}/_next/mcp`
73+
74+
const jsonRpcRequest = {
75+
jsonrpc: "2.0",
76+
method,
77+
params,
78+
id: Date.now(),
79+
}
80+
81+
try {
82+
const response = await fetch(url, {
83+
method: "POST",
84+
headers: {
85+
"Content-Type": "application/json",
86+
"Accept": "application/json, text/event-stream",
87+
},
88+
body: JSON.stringify(jsonRpcRequest),
89+
})
90+
91+
if (!response.ok) {
92+
if (response.status === 404) {
93+
throw new Error(
94+
`MCP endpoint not found. Next.js MCP support requires Next.js 16+. ` +
95+
`If you're on an older version, upgrade using the 'upgrade-nextjs-16' MCP prompt. ` +
96+
`If you're already on Next.js 16+, ensure you're running the dev server with ` +
97+
`__NEXT_EXPERIMENTAL_MCP_SERVER=true or experimental.mcpServer: true in next.config.js`
98+
)
99+
}
100+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
101+
}
102+
103+
const text = await response.text()
104+
const dataLine = text.split("\n").find((line) => line.startsWith("data: "))
105+
106+
if (!dataLine) {
107+
throw new Error("Invalid SSE response: no data line found")
108+
}
109+
110+
const jsonData = dataLine.substring(6)
111+
const mcpResponse: NextJsMCPResponse = JSON.parse(jsonData)
112+
113+
if (mcpResponse.error) {
114+
throw new Error(`MCP Error: ${mcpResponse.error.message}`)
115+
}
116+
117+
return mcpResponse
118+
} catch (error) {
119+
if (error instanceof TypeError && error.message.includes("fetch failed")) {
120+
throw new Error(
121+
`Cannot connect to Next.js dev server on port ${port}. ` +
122+
`Ensure the server is running with __NEXT_EXPERIMENTAL_MCP_SERVER=true or ` +
123+
`experimental.mcpServer: true in next.config.js. ` +
124+
`If you're on Next.js 15 or earlier, upgrade to Next.js 16+ using the 'upgrade-nextjs-16' MCP prompt.`
125+
)
126+
}
127+
128+
if (
129+
error instanceof Error &&
130+
(error.message.includes("MCP endpoint not found") || error.message.includes("MCP Error"))
131+
) {
132+
throw error
133+
}
134+
135+
const errorMessage = error instanceof Error ? error.message : String(error)
136+
throw new Error(`Failed to call Next.js MCP endpoint: ${errorMessage}`)
137+
}
138+
}
139+
140+
export async function discoverNextJsServer(
141+
preferredPort?: number
142+
): Promise<NextJsServerInfo | null> {
143+
const servers = await findNextJsServers()
144+
145+
if (servers.length === 0) {
146+
return null
147+
}
148+
149+
if (preferredPort) {
150+
const server = servers.find((s) => s.port === preferredPort)
151+
if (server) {
152+
return server
153+
}
154+
}
155+
156+
if (servers.length === 1) {
157+
return servers[0]
158+
}
159+
160+
return null
161+
}
162+
163+
export async function listNextJsTools(port: number): Promise<NextJsMCPTool[]> {
164+
try {
165+
const response = await makeNextJsMCPRequest(port, "tools/list", {})
166+
return response.result?.tools || []
167+
} catch (error) {
168+
console.error("[Next.js Runtime Manager] Error listing tools:", error)
169+
return []
170+
}
171+
}
172+
173+
export async function callNextJsTool(
174+
port: number,
175+
toolName: string,
176+
args: Record<string, unknown>
177+
): Promise<unknown> {
178+
try {
179+
const response = await makeNextJsMCPRequest(port, "tools/call", {
180+
name: toolName,
181+
arguments: args,
182+
})
183+
184+
return response.result
185+
} catch (error) {
186+
const errorMessage = error instanceof Error ? error.message : String(error)
187+
throw new Error(`Failed to call tool '${toolName}': ${errorMessage}`)
188+
}
189+
}
190+
191+
export async function getAllAvailableServers(): Promise<NextJsServerInfo[]> {
192+
return findNextJsServers()
193+
}

src/mcp-tools/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
export { nextjsDocsTool } from "./nextjs-docs.js"
22
export { playwrightTool } from "./playwright.js"
3+
export { nextjsRuntimeTool } from "./nextjs-runtime.js"
34

45
// Export tools registry
56
import { nextjsDocsTool } from "./nextjs-docs.js"
67
import { playwrightTool } from "./playwright.js"
8+
import { nextjsRuntimeTool } from "./nextjs-runtime.js"
79

810
export const MCP_TOOLS = {
911
nextjs_docs: nextjsDocsTool,
1012
playwright: playwrightTool,
13+
nextjs_runtime: nextjsRuntimeTool,
1114
} as const
1215

src/mcp-tools/nextjs-runtime.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { tool } from "ai"
2+
import { z } from "zod"
3+
import {
4+
discoverNextJsServer,
5+
listNextJsTools,
6+
callNextJsTool,
7+
getAllAvailableServers,
8+
} from "../lib/nextjs-runtime-manager.js"
9+
10+
const nextjsRuntimeInputSchema = z.object({
11+
action: z
12+
.enum(["discover_servers", "list_tools", "call_tool"])
13+
.describe(
14+
"Action to perform: 'discover_servers' finds running Next.js servers, 'list_tools' lists available MCP tools from the Next.js runtime, 'call_tool' calls a specific tool"
15+
),
16+
17+
port: z
18+
.number()
19+
.optional()
20+
.describe(
21+
"Port number of the Next.js dev server. If not provided, will attempt to auto-discover. Required for 'list_tools' and 'call_tool' actions."
22+
),
23+
24+
toolName: z
25+
.string()
26+
.optional()
27+
.describe(
28+
"Name of the Next.js MCP tool to call. Required for 'call_tool' action. Use 'list_tools' first to discover available tool names."
29+
),
30+
31+
args: z
32+
.record(z.string(), z.unknown())
33+
.optional()
34+
.describe(
35+
"Arguments object to pass to the Next.js MCP tool. MUST be an object (e.g., {param: 'value'}), NOT a string. Only provide this parameter if the tool requires arguments - omit it entirely for tools that take no arguments. Use 'list_tools' to see the inputSchema for each tool."
36+
),
37+
})
38+
39+
export const nextjsRuntimeTool = tool({
40+
description: `Interact with a running Next.js development server's MCP endpoint.
41+
42+
REQUIREMENTS:
43+
- Next.js 16 or later (MCP support was added in v16)
44+
- If you're on Next.js 15 or earlier, use the 'upgrade-nextjs-16' MCP prompt to upgrade first
45+
46+
Next.js exposes an MCP (Model Context Protocol) endpoint at /_next/mcp when started with:
47+
- experimental.mcpServer: true in next.config.js, OR
48+
- __NEXT_EXPERIMENTAL_MCP_SERVER=true environment variable
49+
50+
This tool allows you to:
51+
1. Discover running Next.js dev servers and their ports
52+
2. List available MCP tools/functions exposed by the Next.js runtime
53+
3. Call those tools to interact with Next.js internals (e.g., get route info, clear cache, etc.)
54+
55+
Typical workflow:
56+
1. Use action='discover_servers' to find running Next.js servers
57+
2. Use action='list_tools' with the discovered port to see available tools and their input schemas
58+
3. Use action='call_tool' with port, toolName, and args (as an object, only if required) to invoke a specific tool
59+
60+
IMPORTANT: When calling tools:
61+
- The 'args' parameter MUST be an object (e.g., {key: "value"}), NOT a string
62+
- If a tool doesn't require arguments, OMIT the 'args' parameter entirely - do NOT pass {} or "{}"
63+
- Check the tool's inputSchema from 'list_tools' to see what arguments are required
64+
65+
If the MCP endpoint is not available:
66+
1. Check if you're running Next.js 16+ (if not, use the 'upgrade-nextjs-16' prompt)
67+
2. Ensure the dev server is started with __NEXT_EXPERIMENTAL_MCP_SERVER=true or experimental.mcpServer: true`,
68+
inputSchema: nextjsRuntimeInputSchema,
69+
execute: async (args: z.infer<typeof nextjsRuntimeInputSchema>): Promise<string> => {
70+
try {
71+
switch (args.action) {
72+
case "discover_servers": {
73+
const servers = await getAllAvailableServers()
74+
75+
if (servers.length === 0) {
76+
return JSON.stringify({
77+
success: false,
78+
message: "No running Next.js dev servers found",
79+
hint: "Start a Next.js dev server with __NEXT_EXPERIMENTAL_MCP_SERVER=true or experimental.mcpServer: true",
80+
})
81+
}
82+
83+
return JSON.stringify({
84+
success: true,
85+
servers: servers.map((s) => ({
86+
port: s.port,
87+
pid: s.pid,
88+
command: s.command,
89+
})),
90+
message: `Found ${servers.length} Next.js server(s)`,
91+
})
92+
}
93+
94+
case "list_tools": {
95+
if (!args.port) {
96+
const discovered = await discoverNextJsServer()
97+
if (!discovered) {
98+
return JSON.stringify({
99+
success: false,
100+
error:
101+
"No port specified and auto-discovery failed. Use action='discover_servers' first or provide a port.",
102+
})
103+
}
104+
args.port = discovered.port
105+
}
106+
107+
const tools = await listNextJsTools(args.port)
108+
109+
return JSON.stringify({
110+
success: true,
111+
port: args.port,
112+
tools: tools.map((t) => ({
113+
name: t.name,
114+
description: t.description,
115+
inputSchema: t.inputSchema,
116+
})),
117+
message: `Found ${tools.length} tool(s) available on Next.js server at port ${args.port}`,
118+
})
119+
}
120+
121+
case "call_tool": {
122+
if (!args.port) {
123+
const discovered = await discoverNextJsServer()
124+
if (!discovered) {
125+
return JSON.stringify({
126+
success: false,
127+
error:
128+
"No port specified and auto-discovery failed. Use action='discover_servers' first or provide a port.",
129+
})
130+
}
131+
args.port = discovered.port
132+
}
133+
134+
if (!args.toolName) {
135+
return JSON.stringify({
136+
success: false,
137+
error:
138+
"toolName is required for 'call_tool' action. Use action='list_tools' to discover available tool names.",
139+
})
140+
}
141+
142+
const result = await callNextJsTool(args.port, args.toolName, args.args || {})
143+
144+
return JSON.stringify({
145+
success: true,
146+
port: args.port,
147+
toolName: args.toolName,
148+
result,
149+
})
150+
}
151+
152+
default:
153+
return JSON.stringify({
154+
success: false,
155+
error: `Unknown action: ${args.action}`,
156+
})
157+
}
158+
} catch (error) {
159+
const errorMessage = error instanceof Error ? error.message : String(error)
160+
return JSON.stringify({
161+
success: false,
162+
error: errorMessage,
163+
action: args.action,
164+
})
165+
}
166+
},
167+
})

src/mcp-tools/playwright.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export const playwrightTool = tool({
7979
description: `Automate and test web applications using Playwright browser automation.
8080
This tool connects to playwright-mcp server and provides access to all Playwright capabilities.
8181
82+
IMPORTANT FOR NEXT.JS PROJECTS:
83+
If working with a Next.js application, PRIORITIZE using the 'nextjs_runtime' tool instead of browser console log forwarding.
84+
Next.js has built-in MCP integration that provides superior error reporting, build diagnostics, and runtime information
85+
directly from the Next.js dev server. Only use Playwright's console_messages action as a fallback when nextjs_runtime
86+
tools are not available or when you specifically need to test client-side browser behavior that Next.js runtime cannot capture.
87+
8288
Available actions:
8389
- start: Start Playwright browser (automatically installs if needed)
8490
- navigate: Navigate to a URL
@@ -87,7 +93,7 @@ Available actions:
8793
- fill_form: Fill multiple form fields at once
8894
- evaluate: Execute JavaScript in browser context
8995
- screenshot: Take a screenshot of the page
90-
- console_messages: Get browser console messages
96+
- console_messages: Get browser console messages (for Next.js, prefer nextjs_runtime tool instead)
9197
- close: Close the browser
9298
- drag: Perform drag and drop
9399
- upload_file: Upload files

0 commit comments

Comments
 (0)