Skip to content

Commit 4cfa495

Browse files
authored
fix(plugin-mcp): plugin can break next.js request handler because underlying Hono library modifies the global Request object (#15938)
Fixes #15856 Output from the added e2e test, before: <img width="658" height="499" alt="image" src="https://github.com/user-attachments/assets/e1dd01e7-7341-4dec-a940-c35675324925" /> After: <img width="657" height="457" alt="image" src="https://github.com/user-attachments/assets/575c00cf-bb06-43b4-a500-11de3c979ea9" />
1 parent 25a0e16 commit 4cfa495

4 files changed

Lines changed: 164 additions & 1 deletion

File tree

packages/plugin-mcp/src/endpoints/mcp.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,29 @@ export const initializeMCPHandler = (pluginOptions: PluginMCPServerConfig) => {
6363
? await pluginOptions.overrideAuth(req, getDefaultMcpAccessSettings)
6464
: await getDefaultMcpAccessSettings()
6565

66+
// @modelcontextprotocol/sdk's StreamableHTTPServerTransport uses @hono/node-server's
67+
// getRequestListener, which replaces global.Request and global.Response with Hono
68+
// custom classes. Unfortunately, we cannot pass overrideGlobalObjects: false because the option is
69+
// consumed inside the SDK transport and is not exposed to callers.
70+
// Save originals here and restore after the handler resolves so that Next.js
71+
// instanceof Response checks on subsequent route handlers keep working.
72+
const globals = globalThis as Record<string, unknown>
73+
const originalResponse = globals['Response']
74+
const originalRequest = globals['Request']
75+
6676
const handler = getMCPHandler(pluginOptions, mcpAccessSettings, req)
6777
const request = createRequestFromPayloadRequest(req)
68-
return await handler(request)
78+
79+
try {
80+
return await handler(request)
81+
} finally {
82+
if (globals['Response'] !== originalResponse) {
83+
Object.defineProperty(globalThis, 'Response', { value: originalResponse })
84+
}
85+
if (globals['Request'] !== originalRequest) {
86+
Object.defineProperty(globalThis, 'Request', { value: originalRequest })
87+
}
88+
}
6989
}
7090
return mcpHandler
7191
}

test/plugin-mcp/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ const dirname = path.dirname(filename)
2323
export const capturedMcpEvents: unknown[] = []
2424

2525
export default buildConfigWithDefaults({
26+
endpoints: [
27+
{
28+
handler: () => Response.json({ status: 'ok' }),
29+
method: 'get',
30+
path: '/health',
31+
},
32+
],
2633
admin: {
2734
importMap: {
2835
baseDir: path.resolve(dirname),

test/plugin-mcp/e2e.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { Page } from '@playwright/test'
2+
3+
import { expect, test } from '@playwright/test'
4+
import { randomUUID } from 'crypto'
5+
import path from 'path'
6+
import { fileURLToPath } from 'url'
7+
8+
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../__helpers/e2e/helpers.js'
9+
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
10+
import { devUser } from '../credentials.js'
11+
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
12+
13+
const filename = fileURLToPath(import.meta.url)
14+
const dirname = path.dirname(filename)
15+
16+
test.describe('MCP Plugin', () => {
17+
let page: Page
18+
let serverURL: string
19+
let apiKey: string
20+
21+
test.beforeAll(async ({ browser, request }, testInfo) => {
22+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
23+
24+
const { serverURL: serverFromInit } = await initPayloadE2ENoConfig({ dirname })
25+
serverURL = serverFromInit
26+
27+
const context = await browser.newContext()
28+
page = await context.newPage()
29+
initPageConsoleErrorCatch(page)
30+
31+
await ensureCompilationIsDone({ page, serverURL })
32+
33+
// Login as dev user to get a JWT token for API key creation
34+
const loginRes = await request.post(`${serverURL}/api/users/login`, {
35+
data: { email: devUser.email, password: devUser.password },
36+
})
37+
expect(loginRes.ok()).toBeTruthy()
38+
const loginData = await loginRes.json()
39+
const token = loginData.token
40+
const userId = loginData.user.id
41+
42+
// Create an API key with permissions to call tools/list
43+
const createKeyRes = await request.post(`${serverURL}/api/payload-mcp-api-keys`, {
44+
data: {
45+
enableAPIKey: true,
46+
label: 'E2E Test Key',
47+
apiKey: randomUUID(),
48+
user: userId,
49+
posts: { create: true, find: true, update: true, delete: true },
50+
products: { find: true },
51+
},
52+
headers: {
53+
Authorization: `JWT ${token}`,
54+
},
55+
})
56+
expect(createKeyRes.ok()).toBeTruthy()
57+
const keyData = await createKeyRes.json()
58+
apiKey = keyData.doc.apiKey
59+
})
60+
61+
test('should not poison the Next.js runtime after MCP requests', async ({ request }) => {
62+
// --- 1. Baseline: verify routes are healthy before any MCP calls ---
63+
64+
const healthBefore = await request.get(`${serverURL}/api/health`)
65+
expect(healthBefore.status()).toBe(200)
66+
67+
const notFoundBefore = await request.get(`${serverURL}/api/nonexistent-mcp-test-route`)
68+
expect(notFoundBefore.status()).toBe(404)
69+
70+
// --- 2. Send valid MCP requests ---
71+
// These trigger @modelcontextprotocol/sdk's StreamableHTTPServerTransport which
72+
// uses @hono/node-server's getRequestListener, replacing global.Request/Response.
73+
74+
const mcpHeaders = {
75+
Accept: 'application/json, text/event-stream',
76+
Authorization: `Bearer ${apiKey}`,
77+
'Content-Type': 'application/json',
78+
}
79+
80+
const initRes = await request.post(`${serverURL}/api/mcp`, {
81+
data: {
82+
id: 1,
83+
jsonrpc: '2.0',
84+
method: 'initialize',
85+
params: {
86+
capabilities: {},
87+
clientInfo: { name: 'e2e-test', version: '1.0.0' },
88+
protocolVersion: '2024-11-05',
89+
},
90+
},
91+
headers: mcpHeaders,
92+
})
93+
expect(initRes.ok()).toBeTruthy()
94+
95+
const toolsRes = await request.post(`${serverURL}/api/mcp`, {
96+
data: {
97+
id: 2,
98+
jsonrpc: '2.0',
99+
method: 'tools/list',
100+
params: {},
101+
},
102+
headers: mcpHeaders,
103+
})
104+
expect(toolsRes.ok()).toBeTruthy()
105+
106+
// --- 3. After MCP calls: routes must still respond correctly ---
107+
// Regression check for: https://github.com/payloadcms/payload/issues/15856
108+
// Without the fix, global.Response is permanently replaced by @hono/node-server,
109+
// causing Next.js route handler validation to fail with:
110+
// "Expected a Response object but received 'NextResponse'"
111+
112+
const healthAfter = await request.get(`${serverURL}/api/health`)
113+
expect(healthAfter.status()).toBe(200)
114+
115+
const notFoundAfter = await request.get(`${serverURL}/api/nonexistent-mcp-test-route`)
116+
expect(notFoundAfter.status()).toBe(404)
117+
})
118+
})

test/plugin-mcp/payload-types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export interface Config {
111111
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
112112
};
113113
locale: 'en' | 'es' | 'fr';
114+
widgets: {
115+
collections: CollectionsWidget;
116+
};
114117
user: User | PayloadMcpApiKey;
115118
jobs: {
116119
tasks: unknown;
@@ -225,6 +228,10 @@ export interface Post {
225228
* @maxItems 2
226229
*/
227230
location?: [number, number] | null;
231+
/**
232+
* A virtual field that is computed and not stored in the database
233+
*/
234+
computedTitle?: string | null;
228235
updatedAt: string;
229236
createdAt: string;
230237
_status?: ('draft' | 'published') | null;
@@ -745,6 +752,7 @@ export interface PostsSelect<T extends boolean = true> {
745752
content?: T;
746753
author?: T;
747754
location?: T;
755+
computedTitle?: T;
748756
updatedAt?: T;
749757
createdAt?: T;
750758
_status?: T;
@@ -1017,6 +1025,16 @@ export interface SiteSettingsSelect<T extends boolean = true> {
10171025
createdAt?: T;
10181026
globalType?: T;
10191027
}
1028+
/**
1029+
* This interface was referenced by `Config`'s JSON-Schema
1030+
* via the `definition` "collections_widget".
1031+
*/
1032+
export interface CollectionsWidget {
1033+
data?: {
1034+
[k: string]: unknown;
1035+
};
1036+
width: 'full';
1037+
}
10201038
/**
10211039
* This interface was referenced by `Config`'s JSON-Schema
10221040
* via the `definition` "auth".

0 commit comments

Comments
 (0)