Skip to content
Open
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
40 changes: 40 additions & 0 deletions packages/next/src/server/mcp/get-mcp-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @jest-environment node
*/

import { getMcpAllowedHosts } from './get-mcp-middleware'

describe('getMcpAllowedHosts', () => {
it('allows loopback hosts on the dev server port', () => {
expect(getMcpAllowedHosts('http://localhost:3100', undefined)).toEqual([
'localhost',
'127.0.0.1',
'[::1]',
'localhost:3100',
'127.0.0.1:3100',
'[::1]:3100',
])
})

it('does not allow network interfaces when the dev server binds to all hosts', () => {
expect(
getMcpAllowedHosts('http://0.0.0.0:3100', '192.168.0.10:3100')
).toEqual([
'localhost',
'127.0.0.1',
'[::1]',
'localhost:3100',
'127.0.0.1:3100',
'[::1]:3100',
])
})

it('allows the current request host only when it is loopback', () => {
expect(getMcpAllowedHosts(undefined, '127.0.0.1:4000')).toContain(
'127.0.0.1:4000'
)
expect(getMcpAllowedHosts(undefined, '192.168.0.10:4000')).not.toContain(
'192.168.0.10:4000'
)
})
})
62 changes: 62 additions & 0 deletions packages/next/src/server/mcp/get-mcp-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,65 @@ import {
getOrCreateMcpServer,
type McpServerOptions,
} from './get-or-create-mcp-server'
import * as Log from '../../build/output/log'
import { parseBody } from '../api-utils/node/parse-body'
import { StreamableHTTPServerTransport } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/streamableHttp'

const LOOPBACK_MCP_HOSTS = ['localhost', '127.0.0.1', '[::1]']

function isLoopbackHostname(hostname: string): boolean {
const normalizedHostname = hostname.toLowerCase()

return (
normalizedHostname === 'localhost' ||
normalizedHostname === '127.0.0.1' ||
normalizedHostname === '::1' ||
normalizedHostname === '[::1]'
)
}

function getHostnameFromHostHeader(hostHeader: string | undefined) {
if (!hostHeader) return undefined

try {
return new URL(`http://${hostHeader}`).hostname
} catch {
return undefined
}
}

export function getMcpAllowedHosts(
devServerUrl: string | undefined,
requestHost: string | undefined
): string[] {
const allowedHosts = new Set(LOOPBACK_MCP_HOSTS)
let port: string | undefined

if (devServerUrl) {
try {
const parsedUrl = new URL(devServerUrl)
port = parsedUrl.port

if (isLoopbackHostname(parsedUrl.hostname)) {
allowedHosts.add(parsedUrl.host)
}
} catch {}
}

if (port) {
for (const host of LOOPBACK_MCP_HOSTS) {
allowedHosts.add(`${host}:${port}`)
}
}

const requestHostname = getHostnameFromHostHeader(requestHost)
if (requestHostname && isLoopbackHostname(requestHostname) && requestHost) {
allowedHosts.add(requestHost)
}

return Array.from(allowedHosts)
}

export function getMcpMiddleware(options: McpServerOptions) {
return async function (
req: IncomingMessage,
Expand All @@ -17,8 +73,13 @@ export function getMcpMiddleware(options: McpServerOptions) {
return next()
}
const mcpServer = getOrCreateMcpServer(options)
const requestHost = Array.isArray(req.headers.host)
? req.headers.host[0]
: req.headers.host
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableDnsRebindingProtection: true,
allowedHosts: getMcpAllowedHosts(options.getDevServerUrl(), requestHost),
})
try {
res.on('close', () => {
Expand All @@ -28,6 +89,7 @@ export function getMcpMiddleware(options: McpServerOptions) {
const parsedBody = await parseBody(req, 1024 * 1024) // 1MB limit
await transport.handleRequest(req, res, parsedBody)
} catch (error) {
Log.error('Failed to handle Next.js MCP request', error)
if (!res.headersSent) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json; charset=utf-8')
Expand Down