@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.
- package/dist/endpoint/access.d.ts.map +1 -1
- package/dist/endpoint/access.js +15 -3
- package/dist/endpoint/access.js.map +1 -1
- package/dist/endpoint/index.d.ts.map +1 -1
- package/dist/endpoint/index.js +13 -0
- package/dist/endpoint/index.js.map +1 -1
- package/dist/endpoint/oauth.d.ts +39 -0
- package/dist/endpoint/oauth.d.ts.map +1 -0
- package/dist/endpoint/oauth.js +94 -0
- package/dist/endpoint/oauth.js.map +1 -0
- package/dist/endpoint/oauthLoginToken.d.ts +15 -0
- package/dist/endpoint/oauthLoginToken.d.ts.map +1 -0
- package/dist/endpoint/oauthLoginToken.js +45 -0
- package/dist/endpoint/oauthLoginToken.js.map +1 -0
- package/dist/endpoint/oauthServer.d.ts +12 -0
- package/dist/endpoint/oauthServer.d.ts.map +1 -0
- package/dist/endpoint/oauthServer.js +386 -0
- package/dist/endpoint/oauthServer.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/buildMcpServer.js +1 -1
- package/dist/mcp/buildMcpServer.js.map +1 -1
- package/dist/mcp/builtin/collections/authTools.js.map +1 -1
- package/dist/mcp/builtin/collections/createTool.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/createTool.js.map +1 -1
- package/dist/mcp/builtin/collections/deleteTool.js.map +1 -1
- package/dist/mcp/builtin/collections/duplicateTool.js.map +1 -1
- package/dist/mcp/builtin/collections/findTool.js +1 -1
- package/dist/mcp/builtin/collections/findTool.js.map +1 -1
- package/dist/mcp/builtin/collections/restoreVersionTool.js.map +1 -1
- package/dist/mcp/builtin/collections/updateTool.js.map +1 -1
- package/dist/mcp/builtin/collections/uploadInstructionsTool.js.map +1 -1
- package/dist/mcp/builtin/globals/findTool.js.map +1 -1
- package/dist/mcp/builtin/globals/updateTool.js.map +1 -1
- package/dist/mcp/sanitizeMCPConfig.d.ts.map +1 -1
- package/dist/mcp/sanitizeMCPConfig.js +3 -0
- package/dist/mcp/sanitizeMCPConfig.js.map +1 -1
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/toStandardSchema.js.map +1 -1
- package/package.json +20 -23
- package/src/endpoint/access.ts +12 -3
- package/src/endpoint/index.ts +8 -0
- package/src/endpoint/oauth.ts +92 -0
- package/src/endpoint/oauthLoginToken.ts +59 -0
- package/src/endpoint/oauthServer.ts +440 -0
- package/src/index.ts +40 -5
- package/src/mcp/buildMcpServer.ts +2 -2
- package/src/mcp/builtin/collections/authTools.ts +2 -2
- package/src/mcp/builtin/collections/createTool.ts +1 -3
- package/src/mcp/builtin/collections/deleteTool.ts +1 -1
- package/src/mcp/builtin/collections/duplicateTool.ts +1 -1
- package/src/mcp/builtin/collections/findTool.ts +1 -1
- package/src/mcp/builtin/collections/restoreVersionTool.ts +1 -1
- package/src/mcp/builtin/collections/updateTool.ts +1 -1
- package/src/mcp/builtin/collections/uploadInstructionsTool.ts +1 -1
- package/src/mcp/builtin/globals/findTool.ts +1 -1
- package/src/mcp/builtin/globals/updateTool.ts +1 -1
- package/src/mcp/sanitizeMCPConfig.ts +1 -0
- package/src/types.ts +82 -0
- 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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 })
|
|
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
|
|
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 }
|
|
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 }
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|