@payloadcms/plugin-mcp 4.0.0-internal.a1317b2 → 4.0.0-internal.a2d053c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/bin.js +0 -7
  2. package/dist/endpoint/access.d.ts.map +1 -1
  3. package/dist/endpoint/access.js +23 -3
  4. package/dist/endpoint/access.js.map +1 -1
  5. package/dist/endpoint/index.d.ts.map +1 -1
  6. package/dist/endpoint/index.js +46 -4
  7. package/dist/endpoint/index.js.map +1 -1
  8. package/dist/endpoint/oauth.d.ts +39 -0
  9. package/dist/endpoint/oauth.d.ts.map +1 -0
  10. package/dist/endpoint/oauth.js +94 -0
  11. package/dist/endpoint/oauth.js.map +1 -0
  12. package/dist/endpoint/oauthLoginToken.d.ts +21 -0
  13. package/dist/endpoint/oauthLoginToken.d.ts.map +1 -0
  14. package/dist/endpoint/oauthLoginToken.js +79 -0
  15. package/dist/endpoint/oauthLoginToken.js.map +1 -0
  16. package/dist/endpoint/oauthServer.d.ts +12 -0
  17. package/dist/endpoint/oauthServer.d.ts.map +1 -0
  18. package/dist/endpoint/oauthServer.js +362 -0
  19. package/dist/endpoint/oauthServer.js.map +1 -0
  20. package/dist/index.d.ts +10 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +15 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp/buildMcpServer.d.ts +1 -1
  25. package/dist/mcp/buildMcpServer.d.ts.map +1 -1
  26. package/dist/mcp/buildMcpServer.js +3 -3
  27. package/dist/mcp/buildMcpServer.js.map +1 -1
  28. package/dist/mcp/builtin/collections/authTools.d.ts.map +1 -1
  29. package/dist/mcp/builtin/collections/authTools.js +4 -3
  30. package/dist/mcp/builtin/collections/authTools.js.map +1 -1
  31. package/dist/mcp/builtin/collections/createTool.d.ts.map +1 -1
  32. package/dist/mcp/builtin/collections/createTool.js +8 -5
  33. package/dist/mcp/builtin/collections/createTool.js.map +1 -1
  34. package/dist/mcp/builtin/collections/deleteTool.js +6 -2
  35. package/dist/mcp/builtin/collections/deleteTool.js.map +1 -1
  36. package/dist/mcp/builtin/collections/duplicateTool.d.ts.map +1 -1
  37. package/dist/mcp/builtin/collections/duplicateTool.js +6 -2
  38. package/dist/mcp/builtin/collections/duplicateTool.js.map +1 -1
  39. package/dist/mcp/builtin/collections/fileInput.d.ts.map +1 -1
  40. package/dist/mcp/builtin/collections/fileInput.js +19 -54
  41. package/dist/mcp/builtin/collections/fileInput.js.map +1 -1
  42. package/dist/mcp/builtin/collections/findTool.js +8 -4
  43. package/dist/mcp/builtin/collections/findTool.js.map +1 -1
  44. package/dist/mcp/builtin/collections/getCollectionSchemaTool.d.ts.map +1 -1
  45. package/dist/mcp/builtin/collections/getCollectionSchemaTool.js +19 -3
  46. package/dist/mcp/builtin/collections/getCollectionSchemaTool.js.map +1 -1
  47. package/dist/mcp/builtin/collections/restoreVersionTool.js.map +1 -1
  48. package/dist/mcp/builtin/collections/updateTool.d.ts.map +1 -1
  49. package/dist/mcp/builtin/collections/updateTool.js +21 -8
  50. package/dist/mcp/builtin/collections/updateTool.js.map +1 -1
  51. package/dist/mcp/builtin/collections/uploadInstructionsTool.js.map +1 -1
  52. package/dist/mcp/builtin/globals/findTool.js.map +1 -1
  53. package/dist/mcp/builtin/globals/getGlobalSchemaTool.d.ts.map +1 -1
  54. package/dist/mcp/builtin/globals/getGlobalSchemaTool.js +19 -3
  55. package/dist/mcp/builtin/globals/getGlobalSchemaTool.js.map +1 -1
  56. package/dist/mcp/builtin/globals/updateTool.js.map +1 -1
  57. package/dist/mcp/sanitizeMCPConfig.d.ts.map +1 -1
  58. package/dist/mcp/sanitizeMCPConfig.js +3 -0
  59. package/dist/mcp/sanitizeMCPConfig.js.map +1 -1
  60. package/dist/stdio.js +4 -3
  61. package/dist/stdio.js.map +1 -1
  62. package/dist/types.d.ts +92 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js.map +1 -1
  65. package/dist/utils/seal.d.ts +18 -0
  66. package/dist/utils/seal.d.ts.map +1 -0
  67. package/dist/utils/seal.js +27 -0
  68. package/dist/utils/seal.js.map +1 -0
  69. package/dist/utils/toStandardSchema.js.map +1 -1
  70. package/package.json +23 -26
  71. package/src/endpoint/access.ts +22 -3
  72. package/src/endpoint/index.ts +41 -3
  73. package/src/endpoint/oauth.ts +92 -0
  74. package/src/endpoint/oauthLoginToken.ts +91 -0
  75. package/src/endpoint/oauthServer.ts +396 -0
  76. package/src/index.ts +40 -5
  77. package/src/mcp/buildMcpServer.ts +5 -5
  78. package/src/mcp/builtin/collections/authTools.ts +7 -4
  79. package/src/mcp/builtin/collections/createTool.ts +16 -5
  80. package/src/mcp/builtin/collections/deleteTool.ts +3 -3
  81. package/src/mcp/builtin/collections/duplicateTool.ts +3 -2
  82. package/src/mcp/builtin/collections/fileInput.ts +17 -59
  83. package/src/mcp/builtin/collections/findTool.ts +4 -4
  84. package/src/mcp/builtin/collections/getCollectionSchemaTool.ts +12 -1
  85. package/src/mcp/builtin/collections/restoreVersionTool.ts +1 -1
  86. package/src/mcp/builtin/collections/updateTool.ts +10 -6
  87. package/src/mcp/builtin/collections/uploadInstructionsTool.ts +1 -1
  88. package/src/mcp/builtin/globals/findTool.ts +1 -1
  89. package/src/mcp/builtin/globals/getGlobalSchemaTool.ts +11 -1
  90. package/src/mcp/builtin/globals/updateTool.ts +1 -1
  91. package/src/mcp/sanitizeMCPConfig.ts +1 -0
  92. package/src/stdio.ts +2 -2
  93. package/src/types.ts +94 -3
  94. package/src/utils/seal.ts +46 -0
  95. package/src/utils/toStandardSchema.ts +1 -1
  96. package/dist/mcp/builtin/collections/fileInput.spec.js +0 -86
  97. package/dist/mcp/builtin/collections/fileInput.spec.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { deleteDocumentsInputSchema } from 'payload'
1
+ import { deleteDocumentsInputSchema, parseDocumentID } from 'payload'
2
2
 
3
3
  import { defaultAccess } from '../../../defaultAccess.js'
4
4
  import { defineCollectionTool } from '../../../defineTool.js'
@@ -32,7 +32,7 @@ export const deleteDocumentsTool = defineCollectionTool({
32
32
  try {
33
33
  if (id !== undefined) {
34
34
  const result = await payload.delete({
35
- id,
35
+ id: parseDocumentID({ id, collectionSlug: slug, payload }),
36
36
  collection: slug,
37
37
  depth,
38
38
  overrideAccess: authorizedMCP.overrideAccess,
@@ -48,7 +48,7 @@ export const deleteDocumentsTool = defineCollectionTool({
48
48
  text: `Document deleted successfully from collection "${slug}"!\nDeleted document:\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
49
49
  },
50
50
  ],
51
- doc: result as Record<string, unknown>,
51
+ doc: result,
52
52
  }
53
53
  }
54
54
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  duplicateDocumentInputSchema,
3
3
  getCollectionVirtualFieldNames,
4
+ parseDocumentID,
4
5
  stripVirtualFields,
5
6
  transformPointDataToPayload,
6
7
  validateCollectionData,
@@ -50,7 +51,7 @@ export const duplicateDocumentTool = defineCollectionTool({
50
51
  const parsedData = inputData ? transformPointDataToPayload(inputData) : undefined
51
52
 
52
53
  const result = await payload.duplicate({
53
- id,
54
+ id: parseDocumentID({ id, collectionSlug: slug, payload }),
54
55
  collection: slug,
55
56
  depth,
56
57
  draft,
@@ -73,7 +74,7 @@ export const duplicateDocumentTool = defineCollectionTool({
73
74
  text: `Document duplicated successfully in collection "${slug}"!\nDuplicated document:\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
74
75
  },
75
76
  ],
76
- doc: result as Record<string, unknown>,
77
+ doc: result,
77
78
  }
78
79
  } catch (error) {
79
80
  const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -1,7 +1,11 @@
1
- import type { CollectionSlug, File, FileData, PayloadRequest } from 'payload'
1
+ import type { CollectionSlug, File, PayloadRequest } from 'payload'
2
2
 
3
3
  import { APIError, z } from 'payload'
4
- import { getExternalFile, getFileFromUploadInstructions, isURLAllowed } from 'payload/internal'
4
+ import {
5
+ externalURLInputSchema,
6
+ getFileFromUploadInstructions,
7
+ resolveURLUploadInput,
8
+ } from 'payload/internal'
5
9
  import { sanitizeFilename } from 'payload/shared'
6
10
 
7
11
  const mimeTypeSchema = z
@@ -30,11 +34,7 @@ export const fileInputSchema = z
30
34
  mimeType: mimeTypeSchema.check(z.describe('The file MIME type, for example image/png.')),
31
35
  source: z.literal('base64'),
32
36
  }),
33
- z.strictObject({
34
- name: z.optional(z.string().check(z.minLength(1))).check(z.describe('File name override.')),
35
- source: z.literal('externalURL'),
36
- url: z.url().check(z.describe('The http or https URL to download.')),
37
- }),
37
+ externalURLInputSchema,
38
38
  z.strictObject({
39
39
  file: uploadFileSchema.check(z.describe('getUploadInstructions file field post-upload.')),
40
40
  source: z.literal('uploadReference'),
@@ -75,6 +75,10 @@ export async function resolveFile({
75
75
  }
76
76
  }
77
77
 
78
+ if (input.source === 'externalURL') {
79
+ return resolveURLUploadInput({ slug, input, req })
80
+ }
81
+
78
82
  const uploadConfig = req.payload.collections[slug]?.config.upload
79
83
 
80
84
  if (!uploadConfig) {
@@ -82,48 +86,12 @@ export async function resolveFile({
82
86
  }
83
87
 
84
88
  const maxFileSize = req.payload.config.upload.limits?.fileSize
85
- let file: File
86
-
87
- if (input.source === 'base64') {
88
- const data = decodeBase64({ maxFileSize, value: input.data })
89
-
90
- file = {
91
- name: sanitizeFilename(input.name),
92
- data,
93
- mimetype: input.mimeType,
94
- size: data.length,
95
- }
96
- } else {
97
- if (uploadConfig.pasteURL === false) {
98
- throw new APIError(`Uploading files from URLs is disabled for collection "${slug}".`, 400)
99
- }
100
-
101
- const url = new URL(input.url)
102
-
103
- if (!['http:', 'https:'].includes(url.protocol)) {
104
- throw new APIError('File URLs must use http or https.', 400)
105
- }
106
-
107
- if (
108
- typeof uploadConfig.pasteURL === 'object' &&
109
- !isURLAllowed(input.url, uploadConfig.pasteURL.allowList)
110
- ) {
111
- throw new APIError('The provided file URL is not allowed.', 400)
112
- }
113
-
114
- file = await getExternalFile({
115
- data: {
116
- filename: sanitizeFilename(input.name || getURLFilename(url)),
117
- url: input.url,
118
- } as FileData,
119
- req,
120
- uploadConfig: {
121
- ...uploadConfig,
122
- externalFileHeaderFilter: uploadConfig.externalFileHeaderFilter ?? (() => ({})),
123
- },
124
- })
125
- file.mimetype = file.mimetype?.split(';')[0] || 'application/octet-stream'
126
- file.size = file.data.length
89
+ const data = decodeBase64({ maxFileSize, value: input.data })
90
+ const file: File = {
91
+ name: sanitizeFilename(input.name),
92
+ data,
93
+ mimetype: input.mimeType,
94
+ size: data.length,
127
95
  }
128
96
 
129
97
  if (maxFileSize !== undefined && Number.isFinite(maxFileSize) && file.size > maxFileSize) {
@@ -157,13 +125,3 @@ function decodeBase64({ maxFileSize, value }: { maxFileSize?: number; value: str
157
125
 
158
126
  return data
159
127
  }
160
-
161
- function getURLFilename(url: URL): string {
162
- const pathSegment = url.pathname.split('/').pop() || 'upload'
163
-
164
- try {
165
- return decodeURIComponent(pathSegment)
166
- } catch {
167
- return pathSegment
168
- }
169
- }
@@ -1,4 +1,4 @@
1
- import { findDocumentsInputSchema } from 'payload'
1
+ import { findDocumentsInputSchema, parseDocumentID } from 'payload'
2
2
 
3
3
  import { defaultAccess } from '../../../defaultAccess.js'
4
4
  import { defineCollectionTool } from '../../../defineTool.js'
@@ -45,10 +45,10 @@ export const findDocumentsTool = defineCollectionTool({
45
45
  )
46
46
 
47
47
  try {
48
- if (id) {
48
+ if (id !== undefined) {
49
49
  try {
50
50
  const doc = await payload.findByID({
51
- id,
51
+ id: parseDocumentID({ id, collectionSlug: slug, payload }),
52
52
  collection: slug,
53
53
  depth,
54
54
  overrideAccess: authorizedMCP.overrideAccess,
@@ -69,7 +69,7 @@ export const findDocumentsTool = defineCollectionTool({
69
69
  text: `Document from collection "${slug}":\n${JSON.stringify(doc)}`,
70
70
  },
71
71
  ],
72
- doc: doc as Record<string, unknown>,
72
+ doc,
73
73
  }
74
74
  } catch {
75
75
  logger.warn(`Document not found with ID: ${id} in collection: ${slug}`)
@@ -1,4 +1,5 @@
1
1
  import { getAccessResults, getCollectionInputSchema, getCollectionSchemaInputSchema } from 'payload'
2
+ import { getLLMInstructions } from 'payload/internal'
2
3
 
3
4
  import { defaultAccess } from '../../../defaultAccess.js'
4
5
  import { defineCollectionTool } from '../../../defineTool.js'
@@ -16,7 +17,8 @@ export const getCollectionSchemaTool = defineCollectionTool({
16
17
  readOnlyHint: true,
17
18
  title: 'Get Collection Schema',
18
19
  },
19
- description: 'Get the input schema for creating or updating documents in a collection.',
20
+ description:
21
+ 'Get the input schema and LLM instructions before creating or updating documents in a collection.',
20
22
  input: getCollectionSchemaInputSchema,
21
23
  }).handler(async ({ slug, authorizedMCP, req }) => {
22
24
  const permissions = authorizedMCP.overrideAccess
@@ -64,17 +66,26 @@ export const getCollectionSchemaTool = defineCollectionTool({
64
66
  }
65
67
  : { enabled: false }
66
68
 
69
+ const instructions = await getLLMInstructions({
70
+ slug,
71
+ type: 'collection',
72
+ overrideAccess: authorizedMCP.overrideAccess,
73
+ req,
74
+ })
75
+
67
76
  return {
68
77
  content: [
69
78
  {
70
79
  type: 'text',
71
80
  text: `Schema for collection "${slug}":\n\`\`\`json\n${JSON.stringify(inputSchema)}\n\`\`\`\nUpload configuration:\n\`\`\`json\n${JSON.stringify(upload)}\n\`\`\``,
72
81
  },
82
+ ...(instructions ? [{ type: 'text' as const, text: instructions }] : []),
73
83
  ],
74
84
  structuredContent: {
75
85
  slug,
76
86
  schema: inputSchema,
77
87
  upload,
88
+ ...(instructions ? { instructions } : {}),
78
89
  },
79
90
  }
80
91
  })
@@ -47,7 +47,7 @@ export const restoreVersionTool = defineCollectionTool({
47
47
  text: `Version "${id}" restored successfully in collection "${slug}"!\nRestored document:\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
48
48
  },
49
49
  ],
50
- doc: result as Record<string, unknown>,
50
+ doc: result,
51
51
  }
52
52
  } catch (error) {
53
53
  const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -3,6 +3,7 @@ import type { Where } from 'payload'
3
3
  import {
4
4
  getCollectionInputSchema,
5
5
  getCollectionVirtualFieldNames,
6
+ parseDocumentID,
6
7
  stripVirtualFields,
7
8
  transformPointDataToPayload,
8
9
  updateDocumentInputSchema,
@@ -46,6 +47,7 @@ export const updateDocumentTool = defineCollectionTool({
46
47
  overrideLock,
47
48
  populate,
48
49
  publishAllLocales,
50
+ returning,
49
51
  select,
50
52
  sort,
51
53
  trash,
@@ -74,7 +76,7 @@ export const updateDocumentTool = defineCollectionTool({
74
76
 
75
77
  if (id !== undefined) {
76
78
  const result = await payload.update({
77
- id,
79
+ id: parseDocumentID({ id, collectionSlug: slug, payload }),
78
80
  collection: slug,
79
81
  data: parsedData,
80
82
  depth,
@@ -86,20 +88,22 @@ export const updateDocumentTool = defineCollectionTool({
86
88
  populate,
87
89
  publishAllLocales,
88
90
  req,
89
- select,
91
+ select: returning ? select : { id: true },
90
92
  trash,
91
93
  unpublishAllLocales,
92
94
  ...(file ? { file } : {}),
93
95
  })
94
96
 
97
+ const responseResult = returning ? result : { id: result.id }
98
+
95
99
  return {
96
100
  content: [
97
101
  {
98
102
  type: 'text',
99
- text: `Document updated successfully in collection "${slug}"!\nUpdated document:\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
103
+ text: `Document updated successfully in collection "${slug}"!\nResult:\n\`\`\`json\n${JSON.stringify(responseResult)}\n\`\`\``,
100
104
  },
101
105
  ],
102
- doc: result as Record<string, unknown>,
106
+ doc: responseResult,
103
107
  }
104
108
  }
105
109
 
@@ -116,7 +120,7 @@ export const updateDocumentTool = defineCollectionTool({
116
120
  populate,
117
121
  publishAllLocales,
118
122
  req,
119
- select,
123
+ select: returning ? select : { id: true },
120
124
  sort,
121
125
  trash,
122
126
  unpublishAllLocales,
@@ -124,7 +128,7 @@ export const updateDocumentTool = defineCollectionTool({
124
128
  ...(file ? { file } : {}),
125
129
  })
126
130
 
127
- const docs = result.docs || []
131
+ const docs = returning ? result.docs : result.docs.map(({ id }) => ({ id }))
128
132
  const errors = result.errors || []
129
133
 
130
134
  let responseText = `Multiple documents updated in collection "${slug}"!\nUpdated: ${docs.length} documents\nErrors: ${errors.length}\n---`
@@ -9,7 +9,7 @@ export const getUploadInstructionsTool = defineCollectionTool({
9
9
  defaultAccess(args) &&
10
10
  Boolean(
11
11
  args.permissions?.collections?.[args.slug]?.create ||
12
- args.permissions?.collections?.[args.slug]?.update,
12
+ args.permissions?.collections?.[args.slug]?.update,
13
13
  ),
14
14
  annotations: {
15
15
  destructiveHint: false,
@@ -54,7 +54,7 @@ export const findGlobalTool = defineGlobalTool({
54
54
  text: `Global "${slug}":\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
55
55
  },
56
56
  ],
57
- doc: result as Record<string, unknown>,
57
+ doc: result,
58
58
  }
59
59
  } catch (error) {
60
60
  const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -1,4 +1,5 @@
1
1
  import { getAccessResults, getGlobalInputSchema, getGlobalSchemaInputSchema } from 'payload'
2
+ import { getLLMInstructions } from 'payload/internal'
2
3
 
3
4
  import { defaultAccess } from '../../../defaultAccess.js'
4
5
  import { defineGlobalTool } from '../../../defineTool.js'
@@ -16,7 +17,7 @@ export const getGlobalSchemaTool = defineGlobalTool({
16
17
  readOnlyHint: true,
17
18
  title: 'Get Global Schema',
18
19
  },
19
- description: 'Get the input schema for updating a global.',
20
+ description: 'Get the input schema and LLM instructions before updating a global.',
20
21
  input: getGlobalSchemaInputSchema,
21
22
  }).handler(async ({ slug, authorizedMCP, req }) => {
22
23
  const permissions = authorizedMCP.overrideAccess
@@ -48,16 +49,25 @@ export const getGlobalSchemaTool = defineGlobalTool({
48
49
  }
49
50
  }
50
51
 
52
+ const instructions = await getLLMInstructions({
53
+ slug,
54
+ type: 'global',
55
+ overrideAccess: authorizedMCP.overrideAccess,
56
+ req,
57
+ })
58
+
51
59
  return {
52
60
  content: [
53
61
  {
54
62
  type: 'text',
55
63
  text: `Schema for global "${slug}":\n\`\`\`json\n${JSON.stringify(inputSchema)}\n\`\`\``,
56
64
  },
65
+ ...(instructions ? [{ type: 'text' as const, text: instructions }] : []),
57
66
  ],
58
67
  structuredContent: {
59
68
  slug,
60
69
  schema: inputSchema,
70
+ ...(instructions ? { instructions } : {}),
61
71
  },
62
72
  }
63
73
  })
@@ -82,7 +82,7 @@ export const updateGlobalTool = defineGlobalTool({
82
82
  text: `Global "${slug}" updated successfully!\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
83
83
  },
84
84
  ],
85
- doc: result as Record<string, unknown>,
85
+ doc: result,
86
86
  }
87
87
  } catch (error) {
88
88
  const message = error instanceof Error ? error.message : 'Unknown error'
@@ -103,6 +103,7 @@ export const sanitizeMCPConfig = ({
103
103
  hooks: pluginConfig.hooks,
104
104
  items,
105
105
  mcp: pluginConfig.mcp,
106
+ oauth: pluginConfig.oauth === false ? undefined : (pluginConfig.oauth ?? { type: 'builtin' }),
106
107
  overrideGetAuthorizedMCP: pluginConfig.overrideGetAuthorizedMCP,
107
108
  }
108
109
  }
package/src/stdio.ts CHANGED
@@ -3,7 +3,7 @@ import type { Config, Plugin, SanitizedConfig } from 'payload'
3
3
  /* eslint-disable no-console */
4
4
  import { serveStdio } from '@modelcontextprotocol/server/stdio'
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url'
6
- import { createLocalReq, getPayload } from 'payload'
6
+ import { createPayloadRequest, getPayload } from 'payload'
7
7
  import { findConfig, loadEnv } from 'payload/node'
8
8
 
9
9
  import type { SanitizedMCPPluginConfig } from './types.js'
@@ -91,7 +91,7 @@ export const runMcpStdio = async (): Promise<void> => {
91
91
  headers.set('Authorization', process.env.PAYLOAD_MCP_AUTHORIZATION)
92
92
  }
93
93
 
94
- const req = await createLocalReq({ req: { headers } }, payload)
94
+ const req = await createPayloadRequest({ payload, req: { headers } })
95
95
  req.payloadAPI = 'MCP' as const
96
96
  const authorizedMCP = await getAuthorizedMCP({ overrideAccess, req })
97
97
 
package/src/types.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type {
2
2
  CallToolResult,
3
3
  ContentBlock,
4
+ Implementation,
4
5
  JsonSchemaType,
5
- McpServer,
6
+ McpServerOptions,
6
7
  ResourceTemplate,
7
8
  ServerContext,
8
9
  StandardSchemaWithJSON,
@@ -279,9 +280,24 @@ export type MCPPluginConfig = {
279
280
  afterToolCall?: MCPAfterToolCallHook[]
280
281
  }
281
282
  mcp?: {
283
+ /**
284
+ * Largest HTTP request body, in bytes, that the MCP endpoint accepts. Larger requests
285
+ * are answered with `413`. Base64 file uploads count toward this limit.
286
+ *
287
+ * @default 4194304 (4 MiB, the MCP SDK default)
288
+ */
289
+ maxRequestBodySize?: number
282
290
  serverOptions?: MCPServerOptions
283
291
  verboseLogs?: boolean
284
292
  }
293
+ /**
294
+ * How MCP clients sign in. MCP requests without an authenticated user get a `401` that points
295
+ * to the protected resource metadata (RFC 9728). Set to `false` to only accept API keys and
296
+ * anonymous requests.
297
+ *
298
+ * @default { type: 'builtin' }
299
+ */
300
+ oauth?: false | MCPOAuthConfig
285
301
  /**
286
302
  * Replace the default MCP authorization resolver.
287
303
  *
@@ -299,13 +315,88 @@ export type MCPPluginConfig = {
299
315
  tools?: MCPTopLevelToolsMap
300
316
  }
301
317
 
318
+ export type MCPOAuthConfig = MCPBuiltinOAuthConfig | MCPExternalOAuthConfig
319
+
320
+ /**
321
+ * This Payload app is the authorization server. Users sign in to the admin panel and allow the
322
+ * MCP client, which then gets an access token for them.
323
+ */
324
+ export type MCPBuiltinOAuthConfig = {
325
+ /**
326
+ * Creates the tokens the MCP client gets when the user clicks Allow. `req` is the user's own
327
+ * browser request, so `req.user` is the user who allowed it. Pass `authenticate` too if
328
+ * Payload's auth strategies don't accept the access token.
329
+ *
330
+ * @default a Payload login token for that user that only works for MCP, with a refresh token when
331
+ * the collection uses sessions
332
+ */
333
+ issueToken?: (args: { req: PayloadRequest }) => MaybePromise<MCPOAuthTokens>
334
+ /**
335
+ * Trades a refresh token from `issueToken`, or from an earlier refresh, for new tokens. Return
336
+ * `null` when it's no longer valid, and the client sends the user through sign-in again. `req`
337
+ * is the MCP client's request, without a user. Only used together with `issueToken`.
338
+ *
339
+ * OAuth requires noticing copied refresh tokens: return a new refresh token every time, and
340
+ * revoke the tokens when an already used refresh token comes back.
341
+ */
342
+ refreshToken?: (args: {
343
+ refreshToken: string
344
+ req: PayloadRequest
345
+ }) => MaybePromise<MCPOAuthTokens | null>
346
+ type: 'builtin'
347
+ } & MCPOAuthSharedConfig
348
+
349
+ export type MCPOAuthTokens = {
350
+ accessToken: string
351
+ /** Seconds until the access token expires. */
352
+ expiresIn?: number
353
+ /** Lets the client get new tokens without the user, through `refreshToken`. */
354
+ refreshToken?: string
355
+ }
356
+
357
+ /** Another service, e.g. your identity provider, is the authorization server. */
358
+ export type MCPExternalOAuthConfig = {
359
+ /**
360
+ * The authorization servers that issue access tokens for MCP clients. Clients require each one
361
+ * to exactly match the `issuer` in that server's metadata, so pass a function when the exact
362
+ * value is only known at runtime, e.g. from the server's OpenID configuration or per host.
363
+ */
364
+ authorizationServers: ((args: { req: PayloadRequest }) => MaybePromise<string[]>) | string[]
365
+ /**
366
+ * Scopes MCP clients should request, e.g. `['openid', 'email', 'profile']`. Without them,
367
+ * clients request none, which some authorization servers reject or answer with a token that
368
+ * doesn't identify the user.
369
+ */
370
+ scopes?: string[]
371
+ type: 'external'
372
+ } & MCPOAuthSharedConfig
373
+
374
+ type MCPOAuthSharedConfig = {
375
+ /**
376
+ * Called on MCP requests with a bearer token that Payload's auth strategies didn't accept.
377
+ * Return the Payload user, including its `collection`, or `null` to answer with the OAuth challenge.
378
+ */
379
+ authenticate?: (args: {
380
+ req: PayloadRequest
381
+ token: string
382
+ }) => MaybePromise<PayloadRequest['user']>
383
+ /**
384
+ * The URL clients call to reach the MCP endpoint.
385
+ *
386
+ * @default the absolute URL of `/api/mcp`, based on `serverURL` or the request URL
387
+ */
388
+ resource?: string
389
+ }
390
+
302
391
  export type SanitizedMCPPluginConfig = {
303
392
  items: MCPItem[]
393
+ /** `undefined` when OAuth is turned off. */
394
+ oauth?: MCPOAuthConfig
304
395
  } & Pick<MCPPluginConfig, 'disabled' | 'hooks' | 'mcp' | 'overrideGetAuthorizedMCP'>
305
396
 
306
397
  export type MCPServerOptions = {
307
- options?: ConstructorParameters<typeof McpServer>[1]
308
- serverInfo?: Partial<ConstructorParameters<typeof McpServer>[0]>
398
+ options?: McpServerOptions
399
+ serverInfo?: Partial<Implementation>
309
400
  }
310
401
 
311
402
  /**
@@ -0,0 +1,46 @@
1
+ import type { PayloadRequest } from 'payload'
2
+
3
+ /**
4
+ * Encrypts data for the client to hold, so it doesn't need to be stored. `type` keeps one kind
5
+ * from being used as another, e.g. a client ID as an authorization code.
6
+ */
7
+ export const seal = ({
8
+ type,
9
+ data,
10
+ expiresIn,
11
+ req,
12
+ }: {
13
+ data: object
14
+ /** Seconds */
15
+ expiresIn?: number
16
+ req: PayloadRequest
17
+ type: string
18
+ }): string =>
19
+ req.payload.encrypt(
20
+ JSON.stringify({ ...data, type, exp: expiresIn && Date.now() + expiresIn * 1000 }),
21
+ )
22
+
23
+ export const unseal = <T>({
24
+ type,
25
+ req,
26
+ value,
27
+ }: {
28
+ req: PayloadRequest
29
+ type: string
30
+ value: null | string
31
+ }): T | undefined => {
32
+ // Only the v1 format is authenticated. Older formats decrypt anything without an error.
33
+ if (!value?.startsWith('v1:')) {
34
+ return undefined
35
+ }
36
+
37
+ try {
38
+ // Previous secrets can be public, like a default secret from source code, so only client IDs
39
+ // accept them. Client IDs grant nothing, and this keeps clients registered when the secret changes.
40
+ const secret = type === 'client' ? undefined : req.payload.config.secret
41
+ const data = JSON.parse(req.payload.decrypt(value, { secret }))
42
+ return data.type === type && (!data.exp || data.exp > Date.now()) ? data : undefined
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
@@ -5,5 +5,5 @@ import type { ToolInputSchema } from '../types.js'
5
5
  /** Normalize a tool/prompt input to a Standard Schema. Raw JSON Schema gets wrapped; Standard Schema instances pass through. */
6
6
  export const toStandardSchema = (schema: ToolInputSchema): StandardSchemaWithJSON =>
7
7
  typeof schema === 'object' && schema !== null && '~standard' in schema
8
- ? (schema as StandardSchemaWithJSON)
8
+ ? schema
9
9
  : fromJsonSchema(schema)
@@ -1,86 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { fileInputSchema, resolveFile } from './fileInput.js';
3
- describe('MCP file input', ()=>{
4
- it('should reject malformed MIME types', ()=>{
5
- const result = fileInputSchema.safeParse({
6
- data: 'aGVsbG8=',
7
- mimeType: 'text/plain\r\nx-test: value',
8
- name: 'hello.txt',
9
- source: 'base64'
10
- });
11
- expect(result.success).toBe(false);
12
- });
13
- it('should accept a prepared upload', ()=>{
14
- const result = fileInputSchema.safeParse({
15
- file: {
16
- filename: 'image.png',
17
- mimeType: 'image/png',
18
- size: 123,
19
- uploadReference: {
20
- uploadId: 'upload-id'
21
- }
22
- },
23
- source: 'uploadReference'
24
- });
25
- expect(result.success).toBe(true);
26
- });
27
- it('should reject oversized base64 files', async ()=>{
28
- const req = createRequest({
29
- maxFileSize: 4
30
- });
31
- await expect(resolveFile({
32
- slug: 'media',
33
- input: {
34
- data: Buffer.from('hello').toString('base64'),
35
- mimeType: 'text/plain',
36
- name: 'hello.txt',
37
- source: 'base64'
38
- },
39
- req
40
- })).rejects.toThrow('File exceeds the 4 byte upload limit.');
41
- });
42
- it('should reject URLs outside the collection allowlist', async ()=>{
43
- const req = createRequest({
44
- allowList: [
45
- {
46
- hostname: 'assets.example.com',
47
- protocol: 'https'
48
- }
49
- ]
50
- });
51
- await expect(resolveFile({
52
- slug: 'media',
53
- input: {
54
- source: 'externalURL',
55
- url: 'https://example.com/image.png'
56
- },
57
- req
58
- })).rejects.toThrow('The provided file URL is not allowed.');
59
- });
60
- });
61
- function createRequest({ allowList, maxFileSize }) {
62
- return {
63
- payload: {
64
- collections: {
65
- media: {
66
- config: {
67
- upload: allowList ? {
68
- pasteURL: {
69
- allowList
70
- }
71
- } : {}
72
- }
73
- }
74
- },
75
- config: {
76
- upload: {
77
- limits: {
78
- fileSize: maxFileSize
79
- }
80
- }
81
- }
82
- }
83
- };
84
- }
85
-
86
- //# sourceMappingURL=fileInput.spec.js.map