@payloadcms/plugin-mcp 4.0.0-internal.ea6e8f8 → 4.0.0-internal.ee5d8bd

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 (64) hide show
  1. package/dist/endpoint/access.d.ts.map +1 -1
  2. package/dist/endpoint/access.js +15 -3
  3. package/dist/endpoint/access.js.map +1 -1
  4. package/dist/endpoint/index.d.ts.map +1 -1
  5. package/dist/endpoint/index.js +13 -0
  6. package/dist/endpoint/index.js.map +1 -1
  7. package/dist/endpoint/oauth.d.ts +39 -0
  8. package/dist/endpoint/oauth.d.ts.map +1 -0
  9. package/dist/endpoint/oauth.js +94 -0
  10. package/dist/endpoint/oauth.js.map +1 -0
  11. package/dist/endpoint/oauthLoginToken.d.ts +15 -0
  12. package/dist/endpoint/oauthLoginToken.d.ts.map +1 -0
  13. package/dist/endpoint/oauthLoginToken.js +45 -0
  14. package/dist/endpoint/oauthLoginToken.js.map +1 -0
  15. package/dist/endpoint/oauthServer.d.ts +12 -0
  16. package/dist/endpoint/oauthServer.d.ts.map +1 -0
  17. package/dist/endpoint/oauthServer.js +386 -0
  18. package/dist/endpoint/oauthServer.js.map +1 -0
  19. package/dist/index.d.ts +10 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp/buildMcpServer.js +1 -1
  24. package/dist/mcp/buildMcpServer.js.map +1 -1
  25. package/dist/mcp/builtin/collections/authTools.js.map +1 -1
  26. package/dist/mcp/builtin/collections/createTool.d.ts.map +1 -1
  27. package/dist/mcp/builtin/collections/createTool.js.map +1 -1
  28. package/dist/mcp/builtin/collections/deleteTool.js.map +1 -1
  29. package/dist/mcp/builtin/collections/duplicateTool.js.map +1 -1
  30. package/dist/mcp/builtin/collections/findTool.js +1 -1
  31. package/dist/mcp/builtin/collections/findTool.js.map +1 -1
  32. package/dist/mcp/builtin/collections/restoreVersionTool.js.map +1 -1
  33. package/dist/mcp/builtin/collections/updateTool.js.map +1 -1
  34. package/dist/mcp/builtin/collections/uploadInstructionsTool.js.map +1 -1
  35. package/dist/mcp/builtin/globals/findTool.js.map +1 -1
  36. package/dist/mcp/builtin/globals/updateTool.js.map +1 -1
  37. package/dist/mcp/sanitizeMCPConfig.d.ts.map +1 -1
  38. package/dist/mcp/sanitizeMCPConfig.js +3 -0
  39. package/dist/mcp/sanitizeMCPConfig.js.map +1 -1
  40. package/dist/types.d.ts +81 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js.map +1 -1
  43. package/dist/utils/toStandardSchema.js.map +1 -1
  44. package/package.json +20 -23
  45. package/src/endpoint/access.ts +12 -3
  46. package/src/endpoint/index.ts +8 -0
  47. package/src/endpoint/oauth.ts +92 -0
  48. package/src/endpoint/oauthLoginToken.ts +59 -0
  49. package/src/endpoint/oauthServer.ts +440 -0
  50. package/src/index.ts +40 -5
  51. package/src/mcp/buildMcpServer.ts +2 -2
  52. package/src/mcp/builtin/collections/authTools.ts +2 -2
  53. package/src/mcp/builtin/collections/createTool.ts +1 -3
  54. package/src/mcp/builtin/collections/deleteTool.ts +1 -1
  55. package/src/mcp/builtin/collections/duplicateTool.ts +1 -1
  56. package/src/mcp/builtin/collections/findTool.ts +1 -1
  57. package/src/mcp/builtin/collections/restoreVersionTool.ts +1 -1
  58. package/src/mcp/builtin/collections/updateTool.ts +1 -1
  59. package/src/mcp/builtin/collections/uploadInstructionsTool.ts +1 -1
  60. package/src/mcp/builtin/globals/findTool.ts +1 -1
  61. package/src/mcp/builtin/globals/updateTool.ts +1 -1
  62. package/src/mcp/sanitizeMCPConfig.ts +1 -0
  63. package/src/types.ts +82 -0
  64. package/src/utils/toStandardSchema.ts +1 -1
@@ -0,0 +1,440 @@
1
+ import type {
2
+ OAuthClientInformationFull,
3
+ OAuthTokens,
4
+ OpenIdProviderDiscoveryMetadata,
5
+ } from '@modelcontextprotocol/server'
6
+ import type { Endpoint, PayloadHandler, PayloadRequest } from 'payload'
7
+
8
+ import { OAuthErrorCode } from '@modelcontextprotocol/server'
9
+ import { createHash } from 'crypto'
10
+ import { formatAdminURL } from 'payload/shared'
11
+
12
+ import type { MCPBuiltinOAuthConfig, MCPOAuthTokens } from '../types.js'
13
+
14
+ import { getPluginConfig } from '../utils/getPluginConfig.js'
15
+ import { getMCPURL } from './oauth.js'
16
+ import { issueLoginToken, refreshLoginToken } from './oauthLoginToken.js'
17
+
18
+ type Client = { name: string; redirectURIs: string[] }
19
+
20
+ type Consent = {
21
+ clientID: string
22
+ codeChallenge: string
23
+ collection: string
24
+ redirectURI: string
25
+ state: null | string
26
+ userID: number | string
27
+ }
28
+
29
+ type Code = {
30
+ clientID: string
31
+ codeChallenge: string
32
+ redirectURI: string
33
+ tokens: MCPOAuthTokens
34
+ }
35
+
36
+ type RefreshToken = {
37
+ clientID: string
38
+ refreshToken: string
39
+ }
40
+
41
+ /**
42
+ * Turns Payload into the OAuth authorization server for MCP clients when `oauth.type` is
43
+ * `'builtin'`. Clients register, the user allows them after logging in to the admin panel, and
44
+ * the client receives the tokens `issueToken` creates.
45
+ *
46
+ * Clients, consent forms, authorization codes, and refresh tokens are encrypted and handed to the
47
+ * client instead of being stored. Only the default tokens write to the database, to the user's
48
+ * login sessions.
49
+ */
50
+ export const oauthServerEndpoints: Endpoint[] = [
51
+ {
52
+ disableAuth: true,
53
+ handler: (req) => withIssuer(req, (issuer) => metadataResponse(issuer)),
54
+ method: 'get',
55
+ // OpenID discovery is the only metadata location MCP clients check below the issuer's path,
56
+ // which lets this work under /api without a route at the site root
57
+ path: '/mcp/oauth/.well-known/openid-configuration',
58
+ },
59
+ {
60
+ disableAuth: true,
61
+ handler: (req) => withIssuer(req, () => Response.json({ keys: [] })),
62
+ method: 'get',
63
+ path: '/mcp/oauth/jwks',
64
+ },
65
+ {
66
+ disableAuth: true,
67
+ handler: (req) => withIssuer(req, () => register(req)),
68
+ method: 'post',
69
+ path: '/mcp/oauth/register',
70
+ },
71
+ {
72
+ handler: (req) => withIssuer(req, () => authorize(req)),
73
+ method: 'get',
74
+ path: '/mcp/oauth/authorize',
75
+ },
76
+ {
77
+ handler: (req) => withIssuer(req, () => approve(req)),
78
+ method: 'post',
79
+ path: '/mcp/oauth/authorize',
80
+ },
81
+ {
82
+ disableAuth: true,
83
+ handler: (req) => withIssuer(req, () => token(req)),
84
+ method: 'post',
85
+ path: '/mcp/oauth/token',
86
+ },
87
+ ]
88
+
89
+ /** The authorization server URL, or `undefined` when Payload isn't the authorization server. */
90
+ const getIssuer = (req: PayloadRequest): string | undefined =>
91
+ getPluginConfig({ config: req.payload.config }).oauth?.type === 'builtin'
92
+ ? `${getMCPURL({ config: req.payload.config, url: req.url! })}/oauth`
93
+ : undefined
94
+
95
+ const withIssuer = (
96
+ req: PayloadRequest,
97
+ handler: (issuer: string) => Promise<Response> | Response,
98
+ ): Promise<Response> | Response => {
99
+ const issuer = getIssuer(req)
100
+ return issuer ? handler(issuer) : new Response(null, { status: 404 })
101
+ }
102
+
103
+ /**
104
+ * The user logged in to this browser, who can allow MCP clients. Tokens and API keys in the
105
+ * `Authorization` header can't, or an MCP client could use its access token to approve itself a
106
+ * new, separate login.
107
+ */
108
+ const getBrowserUser = (req: PayloadRequest): PayloadRequest['user'] =>
109
+ req.headers.has('Authorization') ? null : req.user
110
+
111
+ const metadataResponse = (issuer: string): Response =>
112
+ Response.json(
113
+ {
114
+ authorization_endpoint: `${issuer}/authorize`,
115
+ code_challenge_methods_supported: ['S256'],
116
+ grant_types_supported: ['authorization_code', 'refresh_token'],
117
+ // Required by OpenID discovery. No ID tokens are issued.
118
+ id_token_signing_alg_values_supported: ['RS256'],
119
+ issuer,
120
+ jwks_uri: `${issuer}/jwks`,
121
+ registration_endpoint: `${issuer}/register`,
122
+ response_types_supported: ['code'],
123
+ subject_types_supported: ['public'],
124
+ token_endpoint: `${issuer}/token`,
125
+ token_endpoint_auth_methods_supported: ['none'],
126
+ } satisfies OpenIdProviderDiscoveryMetadata,
127
+ // Not cached, since without `serverURL` its URLs come from the request
128
+ { headers: { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' } },
129
+ )
130
+
131
+ const register: PayloadHandler = async (req) => {
132
+ const { client_name, redirect_uris } = await req.json!().catch(() => ({}))
133
+
134
+ if (
135
+ !Array.isArray(redirect_uris) ||
136
+ !redirect_uris.length ||
137
+ !redirect_uris.every(
138
+ (uri) => typeof uri === 'string' && URL.canParse(uri) && !uri.includes('#'),
139
+ )
140
+ ) {
141
+ return Response.json({ error: OAuthErrorCode.InvalidRedirectUri }, { status: 400 })
142
+ }
143
+
144
+ const client: Client = {
145
+ name: typeof client_name === 'string' ? client_name : 'An MCP client',
146
+ redirectURIs: redirect_uris,
147
+ }
148
+
149
+ return Response.json(
150
+ {
151
+ client_id: seal({ type: 'client', data: client, req }),
152
+ client_name: client.name,
153
+ grant_types: ['authorization_code', 'refresh_token'],
154
+ redirect_uris: client.redirectURIs,
155
+ response_types: ['code'],
156
+ token_endpoint_auth_method: 'none',
157
+ } satisfies OAuthClientInformationFull,
158
+ { status: 201 },
159
+ )
160
+ }
161
+
162
+ const authorize: PayloadHandler = (req) => {
163
+ const url = new URL(req.url!)
164
+ const params = url.searchParams
165
+ const clientID = params.get('client_id')
166
+ const client = unseal<Client>({ type: 'client', req, value: clientID })
167
+ const redirectURI = params.get('redirect_uri')
168
+
169
+ // Never redirect to an address the client didn't register
170
+ if (!client || !redirectURI || !client.redirectURIs.includes(redirectURI)) {
171
+ return page({
172
+ content: '<p>Start the sign-in again from your MCP client.</p>',
173
+ status: 400,
174
+ title: 'This sign-in link is invalid',
175
+ })
176
+ }
177
+
178
+ const state = params.get('state')
179
+ const codeChallenge = params.get('code_challenge')
180
+
181
+ if (
182
+ params.get('response_type') !== 'code' ||
183
+ params.get('code_challenge_method') !== 'S256' ||
184
+ !codeChallenge
185
+ ) {
186
+ return redirect({ params: { error: OAuthErrorCode.InvalidRequest, state }, redirectURI })
187
+ }
188
+
189
+ const user = getBrowserUser(req)
190
+
191
+ if (!user) {
192
+ const loginURL = formatAdminURL({ adminRoute: req.payload.config.routes.admin, path: '/login' })
193
+
194
+ return new Response(null, {
195
+ headers: {
196
+ Location: `${loginURL}?redirect=${encodeURIComponent(url.pathname + url.search)}`,
197
+ },
198
+ status: 302,
199
+ })
200
+ }
201
+
202
+ const consent: Consent = {
203
+ clientID: clientID!,
204
+ codeChallenge,
205
+ collection: user.collection,
206
+ redirectURI,
207
+ state,
208
+ userID: user.id,
209
+ }
210
+
211
+ return page({
212
+ content: `<p>It will be able to use this app's MCP tools as ${escapeHTML(String(user.email ?? user.id))}, and you will return to ${escapeHTML(new URL(redirectURI).host || redirectURI)}.</p>
213
+ <form method="post">
214
+ <input name="consent" type="hidden" value="${seal({ type: 'consent', data: consent, expiresIn: 20 * 60 * 60, req })}">
215
+ <button name="decision" type="submit" value="allow">Allow</button>
216
+ <button name="decision" type="submit" value="deny">Deny</button>
217
+ </form>`,
218
+ status: 200,
219
+ title: `Allow ${client.name} to use your account?`,
220
+ })
221
+ }
222
+
223
+ const approve: PayloadHandler = async (req) => {
224
+ const form = new URLSearchParams(await req.text!())
225
+ const consent = unseal<Consent>({ type: 'consent', req, value: form.get('consent') })
226
+ const user = getBrowserUser(req)
227
+
228
+ // The form is only valid for the user it was shown to
229
+ if (!consent || user?.id !== consent.userID || user.collection !== consent.collection) {
230
+ return page({
231
+ content: '<p>Start the sign-in again from your MCP client.</p>',
232
+ status: 400,
233
+ title: 'This sign-in request expired',
234
+ })
235
+ }
236
+
237
+ const { clientID, codeChallenge, redirectURI, state } = consent
238
+
239
+ if (form.get('decision') !== 'allow') {
240
+ return redirect({ params: { error: OAuthErrorCode.AccessDenied, state }, redirectURI })
241
+ }
242
+
243
+ const code: Code = {
244
+ clientID,
245
+ codeChallenge,
246
+ redirectURI,
247
+ tokens: await getTokenHooks(req).issueToken({ req }),
248
+ }
249
+
250
+ return redirect({
251
+ params: { code: seal({ type: 'code', data: code, expiresIn: 60, req }), state },
252
+ redirectURI,
253
+ })
254
+ }
255
+
256
+ const token: PayloadHandler = async (req) => {
257
+ const form = new URLSearchParams(await req.text!())
258
+ const clientID = form.get('client_id')
259
+ const tokens =
260
+ clientID &&
261
+ (form.get('grant_type') === 'refresh_token'
262
+ ? await refresh({ clientID, form, req })
263
+ : redeemCode({ clientID, form, req }))
264
+
265
+ if (!clientID || !tokens) {
266
+ return Response.json({ error: OAuthErrorCode.InvalidGrant }, { status: 400 })
267
+ }
268
+
269
+ return Response.json(
270
+ {
271
+ access_token: tokens.accessToken,
272
+ expires_in: tokens.expiresIn,
273
+ refresh_token:
274
+ tokens.refreshToken &&
275
+ seal({
276
+ type: 'refresh',
277
+ data: { clientID, refreshToken: tokens.refreshToken } satisfies RefreshToken,
278
+ req,
279
+ }),
280
+ token_type: 'Bearer',
281
+ } satisfies OAuthTokens,
282
+ { headers: { 'Cache-Control': 'no-store' } },
283
+ )
284
+ }
285
+
286
+ const redeemCode = ({
287
+ clientID,
288
+ form,
289
+ req,
290
+ }: {
291
+ clientID: string
292
+ form: URLSearchParams
293
+ req: PayloadRequest
294
+ }): MCPOAuthTokens | undefined => {
295
+ const code = unseal<Code>({ type: 'code', req, value: form.get('code') })
296
+
297
+ if (
298
+ form.get('grant_type') === 'authorization_code' &&
299
+ code?.clientID === clientID &&
300
+ code.redirectURI === form.get('redirect_uri') &&
301
+ // PKCE: only the client that started the sign-in knows the verifier
302
+ createHash('sha256')
303
+ .update(form.get('code_verifier') ?? '')
304
+ .digest('base64url') === code.codeChallenge
305
+ ) {
306
+ return code.tokens
307
+ }
308
+ }
309
+
310
+ const refresh = async ({
311
+ clientID,
312
+ form,
313
+ req,
314
+ }: {
315
+ clientID: string
316
+ form: URLSearchParams
317
+ req: PayloadRequest
318
+ }): Promise<MCPOAuthTokens | null | undefined> => {
319
+ const refreshToken = unseal<RefreshToken>({
320
+ type: 'refresh',
321
+ req,
322
+ value: form.get('refresh_token'),
323
+ })
324
+
325
+ // Only the client a refresh token was issued to can use it
326
+ if (refreshToken?.clientID === clientID) {
327
+ return await getTokenHooks(req).refreshToken?.({
328
+ refreshToken: refreshToken.refreshToken,
329
+ req,
330
+ })
331
+ }
332
+ }
333
+
334
+ /** `issueToken` and `refreshToken`, or the defaults for Payload login tokens. */
335
+ const getTokenHooks = (req: PayloadRequest) => {
336
+ const oauth = getPluginConfig({ config: req.payload.config }).oauth as MCPBuiltinOAuthConfig
337
+
338
+ return oauth.issueToken
339
+ ? { issueToken: oauth.issueToken, refreshToken: oauth.refreshToken }
340
+ : { issueToken: issueLoginToken, refreshToken: refreshLoginToken }
341
+ }
342
+
343
+ /**
344
+ * Encrypts data for the client to hold, so it doesn't need to be stored. `type` keeps one kind
345
+ * from being used as another, e.g. a client ID as an authorization code.
346
+ */
347
+ const seal = ({
348
+ type,
349
+ data,
350
+ expiresIn,
351
+ req,
352
+ }: {
353
+ data: object
354
+ /** Seconds */
355
+ expiresIn?: number
356
+ req: PayloadRequest
357
+ type: string
358
+ }): string =>
359
+ req.payload.encrypt(
360
+ JSON.stringify({ ...data, type, exp: expiresIn && Date.now() + expiresIn * 1000 }),
361
+ )
362
+
363
+ const unseal = <T>({
364
+ type,
365
+ req,
366
+ value,
367
+ }: {
368
+ req: PayloadRequest
369
+ type: string
370
+ value: null | string
371
+ }): T | undefined => {
372
+ // Only the v1 format is authenticated. Older formats decrypt anything without an error.
373
+ if (!value?.startsWith('v1:')) {
374
+ return undefined
375
+ }
376
+
377
+ try {
378
+ // Previous secrets can be public, like a default secret from source code, so only client IDs
379
+ // accept them. Client IDs grant nothing, and this keeps clients registered when the secret changes.
380
+ const secret = type === 'client' ? undefined : req.payload.config.secret
381
+ const data = JSON.parse(req.payload.decrypt(value, { secret }))
382
+ return data.type === type && (!data.exp || data.exp > Date.now()) ? data : undefined
383
+ } catch {
384
+ return undefined
385
+ }
386
+ }
387
+
388
+ const redirect = ({
389
+ params,
390
+ redirectURI,
391
+ }: {
392
+ params: Record<string, null | string>
393
+ redirectURI: string
394
+ }): Response => {
395
+ const location = new URL(redirectURI)
396
+
397
+ for (const [key, value] of Object.entries(params)) {
398
+ if (value) {
399
+ location.searchParams.set(key, value)
400
+ }
401
+ }
402
+
403
+ return new Response(null, { headers: { Location: location.href }, status: 302 })
404
+ }
405
+
406
+ const page = ({
407
+ content,
408
+ status,
409
+ title,
410
+ }: {
411
+ content: string
412
+ status: number
413
+ title: string
414
+ }): Response =>
415
+ new Response(
416
+ `<!doctype html>
417
+ <html lang="en">
418
+ <head>
419
+ <meta charset="utf-8">
420
+ <meta name="viewport" content="width=device-width, initial-scale=1">
421
+ <title>${escapeHTML(title)}</title>
422
+ <style>
423
+ body { font-family: system-ui, sans-serif; margin: 4rem auto; max-width: 32rem; overflow-wrap: anywhere; padding: 0 1rem }
424
+ button { font: inherit; margin-inline-end: 0.5rem; padding: 0.5rem 1rem }
425
+ </style>
426
+ </head>
427
+ <body><h1>${escapeHTML(title)}</h1>${content}</body>
428
+ </html>`,
429
+ {
430
+ headers: {
431
+ // Stop other sites from framing the page to trick users into clicking Allow
432
+ 'Content-Security-Policy': "frame-ancestors 'none'",
433
+ 'Content-Type': 'text/html; charset=utf-8',
434
+ },
435
+ status,
436
+ },
437
+ )
438
+
439
+ const escapeHTML = (value: string): string =>
440
+ value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`)
package/src/index.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  import { definePlugin } from 'payload'
2
2
 
3
- import type { AuthorizedMCP, MCPPluginConfig, SanitizedMCPPluginConfig } from './types.js'
3
+ import type {
4
+ AuthorizedMCP,
5
+ MCPBuiltinOAuthConfig,
6
+ MCPExternalOAuthConfig,
7
+ MCPOAuthConfig,
8
+ MCPOAuthTokens,
9
+ MCPPluginConfig,
10
+ SanitizedMCPPluginConfig,
11
+ } from './types.js'
4
12
 
5
13
  import { mcpEndpoint } from './endpoint/index.js'
14
+ import { oauthProtectedResourceEndpoint } from './endpoint/oauth.js'
15
+ import { oauthServerEndpoints } from './endpoint/oauthServer.js'
6
16
  import { sanitizeMCPConfig } from './mcp/sanitizeMCPConfig.js'
7
17
 
8
18
  declare module 'payload' {
@@ -13,11 +23,27 @@ declare module 'payload' {
13
23
  /** After the plugin's `plugin` callback runs, `options` holds the sanitized config. */
14
24
  '@payloadcms/plugin-mcp': MCPPluginConfig
15
25
  }
26
+ export interface RequestContext {
27
+ /** Set on MCP requests before they're authenticated. */
28
+ mcp?: {
29
+ /** The MCP endpoint's address, which OAuth access tokens for MCP are issued for. */
30
+ resource: string
31
+ }
32
+ }
16
33
  }
17
34
 
18
- export type { AuthorizedMCP, MCPPluginConfig, SanitizedMCPPluginConfig }
35
+ export type {
36
+ AuthorizedMCP,
37
+ MCPBuiltinOAuthConfig,
38
+ MCPExternalOAuthConfig,
39
+ MCPOAuthConfig,
40
+ MCPOAuthTokens,
41
+ MCPPluginConfig,
42
+ SanitizedMCPPluginConfig,
43
+ }
19
44
 
20
45
  export { defineCollectionTool, defineGlobalTool, definePrompt, defineTool } from './defineTool.js'
46
+ export { oauthProtectedResourceHandler } from './endpoint/oauth.js'
21
47
 
22
48
  export const mcpPlugin = definePlugin<MCPPluginConfig>({
23
49
  slug: '@payloadcms/plugin-mcp',
@@ -29,7 +55,7 @@ export const mcpPlugin = definePlugin<MCPPluginConfig>({
29
55
  const registered = plugins['@payloadcms/plugin-mcp']
30
56
  if (registered) {
31
57
  // @ts-expect-error
32
- registered.sanitizedOptions = pluginConfig as unknown as typeof registered.options
58
+ registered.sanitizedOptions = pluginConfig
33
59
  }
34
60
 
35
61
  if (pluginConfig.disabled) {
@@ -40,15 +66,24 @@ export const mcpPlugin = definePlugin<MCPPluginConfig>({
40
66
  ...config,
41
67
  endpoints: [
42
68
  ...(config.endpoints ?? []),
43
- // Payload prefixes /api, so the full path is /api/mcp.
44
- { handler: mcpEndpoint, method: 'post', path: '/mcp' },
69
+ // Payload prefixes /api, so the full path is /api/mcp. getAuthorizedMCP authenticates the
70
+ // request with dev auto-login turned off, so Payload doesn't authenticate it first.
71
+ { disableAuth: true, handler: mcpEndpoint, method: 'post', path: '/mcp' },
45
72
  // Streamable HTTP's optional server=>client GET stream. We don't offer
46
73
  // one, so answer 405 per spec-— clients then skip it cleanly
47
74
  {
75
+ disableAuth: true,
48
76
  handler: () => new Response(null, { headers: { Allow: 'POST' }, status: 405 }),
49
77
  method: 'get',
50
78
  path: '/mcp',
51
79
  },
80
+ {
81
+ disableAuth: true,
82
+ handler: oauthProtectedResourceEndpoint,
83
+ method: 'get',
84
+ path: '/mcp/oauth-protected-resource',
85
+ },
86
+ ...oauthServerEndpoints,
52
87
  ],
53
88
  }
54
89
  },
@@ -250,7 +250,7 @@ const withSlugInput = ({ input }: { input?: ToolInputSchema }): ToolInputSchema
250
250
  const slugSchema = z.string().check(z.describe(description))
251
251
 
252
252
  if (!input) {
253
- return strictObject({ slug: slugSchema }) as unknown as ToolInputSchema
253
+ return strictObject({ slug: slugSchema })
254
254
  }
255
255
 
256
256
  if (typeof input === 'object' && input !== null && '~standard' in input) {
@@ -261,7 +261,7 @@ const withSlugInput = ({ input }: { input?: ToolInputSchema }): ToolInputSchema
261
261
  return input
262
262
  }
263
263
 
264
- return addSlugToJSONSchema({ description, schema: schema as JsonSchemaType })
264
+ return addSlugToJSONSchema({ description, schema })
265
265
  }
266
266
 
267
267
  return addSlugToJSONSchema({ description, schema: input })
@@ -235,7 +235,7 @@ export const unlockCollectionTool = defineCollectionTool({
235
235
  text: `# Account Unlocked\n\n**Collection:** ${slug}\n**Email:** ${input.email}\n**Result:** ${result ? 'Success' : 'Failed'}`,
236
236
  },
237
237
  ],
238
- doc: { result } as Record<string, unknown>,
238
+ doc: { result },
239
239
  }
240
240
  } catch (error) {
241
241
  const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -270,7 +270,7 @@ export const verifyCollectionTool = defineCollectionTool({
270
270
  text: `# Email Verification Successful\n\n**Collection:** ${slug}\n**Result:** ${result ? 'Success' : 'Failed'}`,
271
271
  },
272
272
  ],
273
- doc: { result } as Record<string, unknown>,
273
+ doc: { result },
274
274
  }
275
275
  } catch (error) {
276
276
  const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -83,9 +83,7 @@ export const createDocumentsTool = defineCollectionTool({
83
83
  select: returning ? select : { id: true },
84
84
  })
85
85
 
86
- docs.push(
87
- returning ? { doc: result as Record<string, unknown>, index } : { id: result.id, index },
88
- )
86
+ docs.push(returning ? { doc: result, index } : { id: result.id, index })
89
87
  } catch (error) {
90
88
  const message = error instanceof Error ? error.message : 'Unknown error'
91
89
  const formattedError = formatEntityError({
@@ -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
 
@@ -74,7 +74,7 @@ export const duplicateDocumentTool = defineCollectionTool({
74
74
  text: `Document duplicated successfully in collection "${slug}"!\nDuplicated document:\n\`\`\`json\n${JSON.stringify(result)}\n\`\`\``,
75
75
  },
76
76
  ],
77
- doc: result as Record<string, unknown>,
77
+ doc: result,
78
78
  }
79
79
  } catch (error) {
80
80
  const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -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}`)
@@ -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'
@@ -103,7 +103,7 @@ export const updateDocumentTool = defineCollectionTool({
103
103
  text: `Document updated successfully in collection "${slug}"!\nResult:\n\`\`\`json\n${JSON.stringify(responseResult)}\n\`\`\``,
104
104
  },
105
105
  ],
106
- doc: responseResult as Record<string, unknown>,
106
+ doc: responseResult,
107
107
  }
108
108
  }
109
109
 
@@ -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'
@@ -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
  }