Skip to content

Commit 73a18dc

Browse files
feat(plugin-mcp): adds custom auth config (#14538)
- Adds `overrideAuth` to the MCP Plugin. - Adds `MCPAccessSettings` as an exported type from the mcpPlugin ```ts import { type MCPAccessSettings, mcpPlugin } from '@payloadcms/plugin-mcp' // ... other config plugins: [ mcpPlugin({ // ... other plugin config overrideAuth: (req) => { const { payload } = req payload.logger.info('[Override MCP auth]:') return { posts: { find: true, }, products: { find: true, update: true, }, } as MCPAccessSettings }, }) ] ``` ### Custom Auth Behaviors The bypassed system uses an API Key that contains `MCPAccessSettings` information. The `overrideAuth` function will bypass the API Key system and use your function when authorizing requests. This means that your function must return a valid `MCPAccessSettings` object to use.
1 parent 1a4ce44 commit 73a18dc

File tree

11 files changed

+133
-41
lines changed

11 files changed

+133
-41
lines changed

packages/payload/src/errors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { MissingFile } from './MissingFile.js'
2020
export { NotFound } from './NotFound.js'
2121
export { QueryError } from './QueryError.js'
2222
export { ReservedFieldName } from './ReservedFieldName.js'
23+
export { UnauthorizedError } from './UnauthorizedError.js'
2324
export { UnverifiedEmail } from './UnverifiedEmail.js'
2425
export { ValidationError, ValidationErrorName } from './ValidationError.js'
2526
export type { ValidationFieldError } from './ValidationError.js'

packages/payload/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,7 @@ export {
14311431
MissingFile,
14321432
NotFound,
14331433
QueryError,
1434+
UnauthorizedError,
14341435
UnverifiedEmail,
14351436
ValidationError,
14361437
ValidationErrorName,

packages/plugin-mcp/src/collections/createApiKeysCollection.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const addEnabledCollectionTools = (collections: PluginMCPServerConfig['collectio
3131
return enabledCollectionSlugs.map((enabledCollectionSlug) => ({
3232
type: 'collapsible' as const,
3333
admin: {
34+
description: `Manage client access to ${enabledCollectionSlug}`,
3435
position: 'sidebar' as const,
3536
},
3637
fields: [
@@ -169,6 +170,8 @@ export const createAPIKeysCollection = (
169170
return {
170171
slug: 'payload-mcp-api-keys',
171172
admin: {
173+
description:
174+
'API keys control which collections, resources, tools, and prompts MCP clients can access',
172175
group: 'MCP',
173176
useAsTitle: 'label',
174177
},
@@ -208,6 +211,7 @@ export const createAPIKeysCollection = (
208211
{
209212
type: 'collapsible' as const,
210213
admin: {
214+
description: 'Manage client access to tools',
211215
position: 'sidebar' as const,
212216
},
213217
fields: [
@@ -228,6 +232,7 @@ export const createAPIKeysCollection = (
228232
{
229233
type: 'collapsible' as const,
230234
admin: {
235+
description: 'Manage client access to resources',
231236
position: 'sidebar' as const,
232237
},
233238
fields: [
@@ -248,6 +253,7 @@ export const createAPIKeysCollection = (
248253
{
249254
type: 'collapsible' as const,
250255
admin: {
256+
description: 'Manage client access to prompts',
251257
position: 'sidebar' as const,
252258
},
253259
fields: [
@@ -273,6 +279,7 @@ export const createAPIKeysCollection = (
273279
{
274280
type: 'collapsible' as const,
275281
admin: {
282+
description: 'Manage client access to experimental tools',
276283
position: 'sidebar' as const,
277284
},
278285
fields: [

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

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from 'crypto'
2-
import { APIError, type PayloadHandler, type Where } from 'payload'
2+
import { type PayloadHandler, UnauthorizedError, type Where } from 'payload'
33

44
import type { MCPAccessSettings, PluginMCPServerConfig } from '../types.js'
55

@@ -13,45 +13,55 @@ export const initializeMCPHandler = (pluginOptions: PluginMCPServerConfig) => {
1313
const MCPHandlerOptions = MCPOptions.handlerOptions || {}
1414
const useVerboseLogs = MCPHandlerOptions.verboseLogs ?? false
1515

16-
const apiKey = req.headers.get('Authorization')?.startsWith('Bearer ')
17-
? req.headers.get('Authorization')?.replace('Bearer ', '').trim()
18-
: null
16+
const getDefaultMcpAccessSettings = async (overrideApiKey?: null | string) => {
17+
const apiKey =
18+
(overrideApiKey ?? req.headers.get('Authorization')?.startsWith('Bearer '))
19+
? req.headers.get('Authorization')?.replace('Bearer ', '').trim()
20+
: null
1921

20-
if (apiKey === null) {
21-
throw new APIError('API Key is required', 401)
22-
}
22+
if (apiKey === null) {
23+
throw new UnauthorizedError()
24+
}
2325

24-
const sha256APIKeyIndex = crypto
25-
.createHmac('sha256', payload.secret)
26-
.update(apiKey || '')
27-
.digest('hex')
26+
const sha256APIKeyIndex = crypto
27+
.createHmac('sha256', payload.secret)
28+
.update(apiKey || '')
29+
.digest('hex')
2830

29-
const apiKeyConstraints = [
30-
{
31-
apiKeyIndex: {
32-
equals: sha256APIKeyIndex,
31+
const apiKeyConstraints = [
32+
{
33+
apiKeyIndex: {
34+
equals: sha256APIKeyIndex,
35+
},
3336
},
34-
},
35-
]
36-
const where: Where = {
37-
or: apiKeyConstraints,
38-
}
37+
]
3938

40-
const { docs } = await payload.find({
41-
collection: 'payload-mcp-api-keys',
42-
where,
43-
})
39+
const where: Where = {
40+
or: apiKeyConstraints,
41+
}
4442

45-
if (docs.length === 0) {
46-
throw new APIError('API Key is invalid', 401)
47-
}
43+
const { docs } = await payload.find({
44+
collection: 'payload-mcp-api-keys',
45+
limit: 1,
46+
pagination: false,
47+
where,
48+
})
4849

49-
const mcpAccessSettings = docs[0] as MCPAccessSettings
50+
if (docs.length === 0) {
51+
throw new UnauthorizedError()
52+
}
5053

51-
if (useVerboseLogs) {
52-
payload.logger.info('[payload-mcp] API Key is valid')
54+
if (useVerboseLogs) {
55+
payload.logger.info('[payload-mcp] API Key is valid')
56+
}
57+
58+
return docs[0] as MCPAccessSettings
5359
}
5460

61+
const mcpAccessSettings = pluginOptions.overrideAuth
62+
? await pluginOptions.overrideAuth(req, getDefaultMcpAccessSettings)
63+
: await getDefaultMcpAccessSettings()
64+
5565
const handler = getMCPHandler(pluginOptions, mcpAccessSettings, req)
5666
const request = createRequestFromPayloadRequest(req)
5767
return await handler(request)

packages/plugin-mcp/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Config } from 'payload'
22

3-
import type { PluginMCPServerConfig } from './types.js'
3+
import type { MCPAccessSettings, PluginMCPServerConfig } from './types.js'
44

55
import { createAPIKeysCollection } from './collections/createApiKeysCollection.js'
66
import { initializeMCPHandler } from './endpoints/mcp.js'
77

8+
export type { MCPAccessSettings }
89
/**
910
* The MCP Plugin for Payload. This plugin allows you to add MCP capabilities to your Payload project.
1011
*

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { APIError, type PayloadRequest } from 'payload'
1+
import { AuthenticationError, type PayloadRequest } from 'payload'
22

33
export const createRequestFromPayloadRequest = (req: PayloadRequest) => {
44
if (!req.url) {
5-
throw new APIError('Request URL is required', 500)
5+
throw new AuthenticationError()
66
}
77
return new Request(req.url, {
88
body: req.body,

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { findResourceTool } from './tools/resource/find.js'
1616
import { updateResourceTool } from './tools/resource/update.js'
1717

1818
// Experimental Tools
19+
/**
20+
* @experimental This tools are experimental and may change or be removed in the future.
21+
*/
1922
import { authTool } from './tools/auth/auth.js'
2023
import { forgotPasswordTool } from './tools/auth/forgotPassword.js'
2124
import { loginTool } from './tools/auth/login.js'
@@ -108,10 +111,10 @@ export const getMCPHandler = (
108111
const toolCapabilities = mcpAccessSettings?.[
109112
`${toCamelCase(enabledCollectionSlug)}`
110113
] as Record<string, unknown>
111-
const allowCreate: boolean | undefined = toolCapabilities['create'] as boolean
112-
const allowUpdate: boolean | undefined = toolCapabilities['update'] as boolean
113-
const allowFind: boolean | undefined = toolCapabilities['find'] as boolean
114-
const allowDelete: boolean | undefined = toolCapabilities['delete'] as boolean
114+
const allowCreate: boolean | undefined = toolCapabilities?.create as boolean
115+
const allowUpdate: boolean | undefined = toolCapabilities?.update as boolean
116+
const allowFind: boolean | undefined = toolCapabilities?.find as boolean
117+
const allowDelete: boolean | undefined = toolCapabilities?.delete as boolean
115118

116119
if (allowCreate) {
117120
registerTool(
@@ -194,7 +197,7 @@ export const getMCPHandler = (
194197
// Custom tools
195198
customMCPTools.forEach((tool) => {
196199
const camelCasedToolName = toCamelCase(tool.name)
197-
const isToolEnabled = mcpAccessSettings['payload-mcp-tool']?.[camelCasedToolName] ?? true
200+
const isToolEnabled = mcpAccessSettings['payload-mcp-tool']?.[camelCasedToolName] ?? false
198201

199202
registerTool(
200203
isToolEnabled,
@@ -209,7 +212,7 @@ export const getMCPHandler = (
209212
customMCPPrompts.forEach((prompt) => {
210213
const camelCasedPromptName = toCamelCase(prompt.name)
211214
const isPromptEnabled =
212-
mcpAccessSettings['payload-mcp-prompt']?.[camelCasedPromptName] ?? true
215+
mcpAccessSettings['payload-mcp-prompt']?.[camelCasedPromptName] ?? false
213216

214217
if (isPromptEnabled) {
215218
server.registerPrompt(
@@ -233,7 +236,7 @@ export const getMCPHandler = (
233236
customMCPResources.forEach((resource) => {
234237
const camelCasedResourceName = toCamelCase(resource.name)
235238
const isResourceEnabled =
236-
mcpAccessSettings['payload-mcp-resource']?.[camelCasedResourceName] ?? true
239+
mcpAccessSettings['payload-mcp-resource']?.[camelCasedResourceName] ?? false
237240

238241
if (isResourceEnabled) {
239242
server.registerResource(

packages/plugin-mcp/src/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,26 @@ export type PluginMCPServerConfig = {
215215
parameters: z.ZodRawShape
216216
}[]
217217
}
218+
219+
/**
220+
* Override the API key collection.
221+
* This allows you to add fields to the API key collection or modify the collection in any way you want.
222+
* @param collection - The API key collection.
223+
* @returns The modified API key collection.
224+
*/
218225
overrideApiKeyCollection?: (collection: CollectionConfig) => CollectionConfig
219226

227+
/**
228+
* Override the authentication method.
229+
* This allows you to use a custom authentication method instead of the default API key authentication.
230+
* @param req - The request object.
231+
* @returns The MCP access settings.
232+
*/
233+
overrideAuth?: (
234+
req: PayloadRequest,
235+
getDefaultMcpAccessSettings: (overrideApiKey?: null | string) => Promise<MCPAccessSettings>,
236+
) => MCPAccessSettings | Promise<MCPAccessSettings>
237+
220238
/**
221239
* Set the users collection that API keys should be associated with.
222240
*/

test/plugin-mcp/config.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
2-
import { mcpPlugin } from '@payloadcms/plugin-mcp'
2+
import { type MCPAccessSettings, mcpPlugin } from '@payloadcms/plugin-mcp'
33
import path from 'path'
44
import { fileURLToPath } from 'url'
55
import { z } from 'zod'
@@ -24,6 +24,37 @@ export default buildConfigWithDefaults({
2424
onInit: seed,
2525
plugins: [
2626
mcpPlugin({
27+
/**
28+
* Override the authentication method.
29+
* This allows you to use a custom authentication method instead of the default API key authentication.
30+
* @param req - The request object.
31+
* @returns The MCP access settings.
32+
*/
33+
// overrideAuth: (req) => {
34+
// const { payload } = req
35+
36+
// payload.logger.info('[Override MCP auth]:')
37+
38+
// return {
39+
// posts: {
40+
// find: true,
41+
// },
42+
// products: {
43+
// find: true,
44+
// update: true,
45+
// },
46+
// 'payload-mcp-tool': {
47+
// diceRoll: true,
48+
// },
49+
// 'payload-mcp-prompt': {
50+
// echo: true,
51+
// },
52+
// 'payload-mcp-resource': {
53+
// data: true,
54+
// dataByID: true,
55+
// },
56+
// } as MCPAccessSettings
57+
// },
2758
overrideApiKeyCollection: (collection) => {
2859
collection.fields.push({
2960
name: 'override',

test/plugin-mcp/int.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ describe('@payloadcms/plugin-mcp', () => {
139139
expect(data.error.message).toBe('Method not allowed.')
140140
})
141141

142+
it('should not allow POST /api/mcp with unauthorized API key', async () => {
143+
const apiKey = await getApiKey()
144+
const response = await restClient.POST('/mcp', {
145+
headers: {
146+
Authorization: `Bearer fake${apiKey}key`,
147+
Accept: 'application/json, text/event-stream',
148+
'Content-Type': 'application/json',
149+
},
150+
body: JSON.stringify({}),
151+
})
152+
153+
const json: any = await response.json()
154+
155+
expect(response.status).toBe(401)
156+
expect(json?.errors).toBeDefined()
157+
expect(json.errors[0].message).toBe('Unauthorized, you must be logged in to make this request.')
158+
})
159+
142160
it('should ping', async () => {
143161
const apiKey = await getApiKey()
144162
const response = await restClient.POST('/mcp', {

0 commit comments

Comments
 (0)