Skip to content

Commit e38cdc0

Browse files
authored
feat: add HTTPS support for Next.js dev servers (#88)
1 parent 1fa3a73 commit e38cdc0

File tree

5 files changed

+159
-11
lines changed

5 files changed

+159
-11
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ _*.md
1616
test/fixtures/**/pnpm-lock.yaml
1717
test/fixtures/**/node_modules/
1818
test/fixtures/**/.next/
19+
20+
certificates

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@modelcontextprotocol/sdk": "1.21.0",
3737
"find-process": "2.0.0",
3838
"pid-port": "2.0.0",
39+
"undici": "7.16.0",
3940
"zod": "3.25.76"
4041
},
4142
"devDependencies": {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/_internal/nextjs-runtime-manager.ts

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { findProcess } from "./find-process-import.js"
22
import { pidToPorts } from "pid-port"
33
import { exec } from "child_process"
44
import { promisify } from "util"
5+
import { Agent as UndiciAgent } from "undici"
56

67
const execAsync = promisify(exec)
78

@@ -41,6 +42,116 @@ function isWSL(): boolean {
4142
)
4243
}
4344

45+
// Cache detected protocol per port to avoid repeated detection
46+
const protocolCache = new Map<number, "http" | "https">()
47+
48+
let insecureHttpsAgent: UndiciAgent | undefined
49+
50+
/**
51+
* Get fetch options for HTTPS requests
52+
* Automatically allows insecure TLS for HTTPS (self-signed certificates)
53+
* Can be disabled via NEXT_DEVTOOLS_ALLOW_INSECURE_TLS=false
54+
*/
55+
function getFetchOptions(protocol: "http" | "https") {
56+
// For HTTPS, automatically allow insecure TLS (for self-signed certificates)
57+
// Can be disabled via environment variable: NEXT_DEVTOOLS_ALLOW_INSECURE_TLS=false
58+
const allowInsecure =
59+
protocol === "https" && (
60+
process.env.NEXT_DEVTOOLS_ALLOW_INSECURE_TLS !== "false" ||
61+
process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0"
62+
)
63+
64+
if (protocol !== "https" || !allowInsecure) return {}
65+
66+
if (!insecureHttpsAgent) {
67+
insecureHttpsAgent = new UndiciAgent({ connect: { rejectUnauthorized: false } })
68+
}
69+
return { dispatcher: insecureHttpsAgent }
70+
}
71+
72+
/**
73+
* Automatically detect protocol by trying HTTPS first, then falling back to HTTP
74+
* Caches the result per port to avoid repeated detection
75+
*/
76+
async function detectProtocol(port: number): Promise<"http" | "https"> {
77+
// Return cached protocol if available
78+
if (protocolCache.has(port)) {
79+
return protocolCache.get(port)!
80+
}
81+
82+
const host = process.env.NEXT_DEVTOOLS_HOST ?? "localhost"
83+
84+
// Try HTTPS first (with insecure TLS allowed for self-signed certificates)
85+
try {
86+
const httpsUrl = `https://${host}:${port}/_next/mcp`
87+
const httpsFetchOptions = getFetchOptions("https")
88+
const controller = new AbortController()
89+
const timeoutId = setTimeout(() => controller.abort(), 500) // Short timeout for quick failure
90+
91+
const response = await fetch(httpsUrl, {
92+
...httpsFetchOptions,
93+
method: "POST",
94+
headers: {
95+
"Content-Type": "application/json",
96+
Accept: "application/json, text/event-stream",
97+
},
98+
body: JSON.stringify({
99+
jsonrpc: "2.0",
100+
method: "tools/list",
101+
params: {},
102+
id: 1,
103+
}),
104+
signal: controller.signal,
105+
})
106+
107+
clearTimeout(timeoutId)
108+
109+
// If HTTPS succeeds (even if it returns an error, it means the protocol is correct)
110+
if (response.status !== 404) {
111+
protocolCache.set(port, "https")
112+
return "https"
113+
}
114+
} catch (error) {
115+
// HTTPS failed, continue to try HTTP
116+
}
117+
118+
// HTTPS failed, fallback to HTTP
119+
try {
120+
const httpUrl = `http://${host}:${port}/_next/mcp`
121+
const controller = new AbortController()
122+
const timeoutId = setTimeout(() => controller.abort(), 500)
123+
124+
const response = await fetch(httpUrl, {
125+
method: "POST",
126+
headers: {
127+
"Content-Type": "application/json",
128+
Accept: "application/json, text/event-stream",
129+
},
130+
body: JSON.stringify({
131+
jsonrpc: "2.0",
132+
method: "tools/list",
133+
params: {},
134+
id: 1,
135+
}),
136+
signal: controller.signal,
137+
})
138+
139+
clearTimeout(timeoutId)
140+
141+
// HTTP succeeded
142+
if (response.status !== 404) {
143+
protocolCache.set(port, "http")
144+
return "http"
145+
}
146+
} catch (error) {
147+
// Both protocols failed
148+
}
149+
150+
// Default to HTTP (backward compatibility)
151+
protocolCache.set(port, "http")
152+
return "http"
153+
}
154+
44155
/**
45156
* Get listening ports for a process using ss command (WSL-compatible)
46157
* ss output format: LISTEN 0 511 *:3000 *:* users:(("next-server",pid=4660,fd=24))
@@ -136,7 +247,10 @@ async function makeNextJsMCPRequest(
136247
method: string,
137248
params: Record<string, unknown> = {}
138249
): Promise<NextJsMCPResponse> {
139-
const url = `http://localhost:${port}/_next/mcp`
250+
const protocol = await detectProtocol(port) // Auto-detect protocol
251+
const host = process.env.NEXT_DEVTOOLS_HOST ?? "localhost"
252+
const url = `${protocol}://${host}:${port}/_next/mcp`
253+
const fetchOptions = getFetchOptions(protocol)
140254

141255
const jsonRpcRequest = {
142256
jsonrpc: "2.0",
@@ -147,6 +261,7 @@ async function makeNextJsMCPRequest(
147261

148262
try {
149263
const response = await fetch(url, {
264+
...fetchOptions,
150265
method: "POST",
151266
headers: {
152267
"Content-Type": "application/json",
@@ -250,11 +365,15 @@ export async function callNextJsTool(
250365
*/
251366
async function verifyMCPEndpoint(port: number): Promise<boolean> {
252367
try {
253-
const url = `http://localhost:${port}/_next/mcp`
368+
const protocol = await detectProtocol(port) // Auto-detect protocol
369+
const host = process.env.NEXT_DEVTOOLS_HOST ?? "localhost"
370+
const url = `${protocol}://${host}:${port}/_next/mcp`
371+
const fetchOptions = getFetchOptions(protocol)
254372
const controller = new AbortController()
255373
const timeoutId = setTimeout(() => controller.abort(), 1000) // 1 second timeout
256374

257375
const response = await fetch(url, {
376+
...fetchOptions,
258377
method: "POST",
259378
headers: {
260379
"Content-Type": "application/json",
@@ -299,3 +418,6 @@ export async function getAllAvailableServers(
299418

300419
return verifiedServers
301420
}
421+
422+
// Export detectProtocol for use in nextjs-runtime.ts
423+
export { detectProtocol }

src/tools/nextjs-runtime.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
listNextJsTools,
55
callNextJsTool,
66
getAllAvailableServers,
7+
detectProtocol,
78
} from "../_internal/nextjs-runtime-manager.js"
89

910
export const inputSchema = {
@@ -128,24 +129,37 @@ export async function handler(args: NextjsRuntimeArgs): Promise<string> {
128129
})
129130
}
130131

132+
// Detect protocol for each server (uses cached results)
133+
const serversWithProtocol = await Promise.all(
134+
servers.map(async (s) => {
135+
const protocol = await detectProtocol(s.port)
136+
return {
137+
...s,
138+
protocol,
139+
url: `${protocol}://localhost:${s.port}`,
140+
mcpEndpoint: `${protocol}://localhost:${s.port}/_next/mcp`,
141+
}
142+
})
143+
)
144+
131145
return JSON.stringify({
132146
success: true,
133-
count: servers.length,
134-
servers: servers.map((s) => ({
147+
count: serversWithProtocol.length,
148+
servers: serversWithProtocol.map((s) => ({
135149
port: s.port,
136150
pid: s.pid,
137151
command: s.command,
138-
url: `http://localhost:${s.port}`,
139-
mcpEndpoint: `http://localhost:${s.port}/_next/mcp`,
152+
url: s.url,
153+
mcpEndpoint: s.mcpEndpoint,
140154
})),
141155
message: verifyMCP
142-
? `Found ${servers.length} Next.js server${
143-
servers.length === 1 ? "" : "s"
156+
? `Found ${serversWithProtocol.length} Next.js server${
157+
serversWithProtocol.length === 1 ? "" : "s"
144158
} running with MCP support`
145-
: `Found ${servers.length} Next.js server${
146-
servers.length === 1 ? "" : "s"
159+
: `Found ${serversWithProtocol.length} Next.js server${
160+
serversWithProtocol.length === 1 ? "" : "s"
147161
} running (MCP verification skipped)`,
148-
summary: servers.map((s) => `Server on port ${s.port} (PID: ${s.pid})`).join("\n"),
162+
summary: serversWithProtocol.map((s) => `Server on port ${s.port} (PID: ${s.pid})`).join("\n"),
149163
})
150164
}
151165

0 commit comments

Comments
 (0)