@peoplethread/saas-mcp 0.0.0-stage → 0.3.1
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/README.md +6 -2
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/management.d.ts +80 -0
- package/dist/client/management.js +78 -0
- package/dist/core/http.d.ts +27 -0
- package/dist/core/http.js +29 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/redaction.d.ts +11 -0
- package/dist/core/redaction.js +43 -0
- package/dist/core/scopes.d.ts +13 -0
- package/dist/core/scopes.js +35 -0
- package/dist/core/types.d.ts +252 -0
- package/dist/core/types.js +1 -0
- package/dist/core/urls.d.ts +97 -0
- package/dist/core/urls.js +106 -0
- package/dist/hono/index.d.ts +1 -0
- package/dist/hono/index.js +1 -0
- package/dist/hono/routes.d.ts +295 -0
- package/dist/hono/routes.js +739 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/better-auth.d.ts +84 -0
- package/dist/node/better-auth.js +77 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +2 -0
- package/dist/node/oauth.d.ts +18 -0
- package/dist/node/oauth.js +39 -0
- package/dist/react/McpActivity.d.ts +32 -0
- package/dist/react/McpActivity.js +182 -0
- package/dist/react/McpConnectedClients.d.ts +27 -0
- package/dist/react/McpConnectedClients.js +34 -0
- package/dist/react/McpConnectorOverview.d.ts +21 -0
- package/dist/react/McpConnectorOverview.js +12 -0
- package/dist/react/McpConsentPanel.d.ts +47 -0
- package/dist/react/McpConsentPanel.js +18 -0
- package/dist/react/McpDeveloperTokens.d.ts +27 -0
- package/dist/react/McpDeveloperTokens.js +46 -0
- package/dist/react/McpToolReference.d.ts +17 -0
- package/dist/react/McpToolReference.js +19 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +6 -0
- package/dist/react/mcp-settings-shared.d.ts +29 -0
- package/dist/react/mcp-settings-shared.js +37 -0
- package/package.json +98 -3
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2
|
+
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { authorizationServerMetadataUrlForIssuer, buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata, trimTrailingSlash, validateMcpHttpEnvelope } from "../core/index.js";
|
|
4
|
+
import { addSeconds, hashMcpToken, parseBearerToken, randomMcpToken, verifyPkceS256 } from "../node/index.js";
|
|
5
|
+
import { formatMcpScopeString, hasRequiredMcpScopes, includesUnsupportedMcpScope, normalizeMcpScopes, requestedMcpScopes } from "../core/scopes.js";
|
|
6
|
+
/**
|
|
7
|
+
* Thrown by `createMcpContext` to refuse a session for a token that authenticated but
|
|
8
|
+
* cannot act on this resource — a principal whose access to the addressed tenant has
|
|
9
|
+
* gone away, say.
|
|
10
|
+
*
|
|
11
|
+
* The router answers `401` with `error="invalid_token"` rather than letting it surface
|
|
12
|
+
* as a server fault. The token is intact, so the description is arguable, but the
|
|
13
|
+
* outcome it produces is the correct one: the condition is durable, and a client that
|
|
14
|
+
* retries the same credential will fail identically until it re-authorizes.
|
|
15
|
+
*/
|
|
16
|
+
export class McpContextUnauthorizedError extends Error {
|
|
17
|
+
constructor(message = "Bearer token cannot be used with this resource") {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "McpContextUnauthorizedError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Thrown by `resolveConsentIdentity` when no human is signed in to the product.
|
|
24
|
+
*
|
|
25
|
+
* The authorization endpoint answers this by sending the browser to the product's sign-in
|
|
26
|
+
* page and returning it to the same authorization request, because the condition is one the
|
|
27
|
+
* user can fix in place. It is deliberately distinct from every other failure that resolver
|
|
28
|
+
* can raise: those are reported to the OAuth client, and this one is not, since telling a
|
|
29
|
+
* client "invalid request" for a missing cookie leaves the user with nothing to act on.
|
|
30
|
+
*/
|
|
31
|
+
export class McpConsentSignInRequiredError extends Error {
|
|
32
|
+
constructor(message = "Sign-in is required to authorize this MCP client") {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "McpConsentSignInRequiredError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Thrown by `resolveConsentIdentity` when a human *is* signed in but may not authorize the
|
|
39
|
+
* requested resource — no membership in the addressed workspace, say.
|
|
40
|
+
*
|
|
41
|
+
* Terminal, unlike `McpConsentSignInRequiredError`: signing in again cannot change the
|
|
42
|
+
* answer, so the router refuses to the client with `access_denied` rather than looping the
|
|
43
|
+
* browser through sign-in. The message reaches the client as `error_description`, so it must
|
|
44
|
+
* not name a resource the caller has not been shown to be entitled to know exists.
|
|
45
|
+
*/
|
|
46
|
+
export class McpConsentForbiddenError extends Error {
|
|
47
|
+
constructor(message = "This account cannot authorize the requested resource") {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "McpConsentForbiddenError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function json(data, status = 200, headers = {}) {
|
|
53
|
+
return new Response(JSON.stringify(data), {
|
|
54
|
+
status,
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
...headers
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function configuredResponse(data, status = 200) {
|
|
62
|
+
return json(data, status, { "cache-control": "no-store", "Access-Control-Allow-Origin": "*" });
|
|
63
|
+
}
|
|
64
|
+
function tokenResponse(data) {
|
|
65
|
+
return json(data, 200, { "cache-control": "no-store", pragma: "no-cache" });
|
|
66
|
+
}
|
|
67
|
+
function oauthError(error, description, status = 400) {
|
|
68
|
+
return json({ error, error_description: description }, status, { "cache-control": "no-store", pragma: "no-cache" });
|
|
69
|
+
}
|
|
70
|
+
function jsonRpcErrorResponse(status, code, message, id = null, headers = {}) {
|
|
71
|
+
return json({
|
|
72
|
+
jsonrpc: "2.0",
|
|
73
|
+
error: { code, message },
|
|
74
|
+
id
|
|
75
|
+
}, status, headers);
|
|
76
|
+
}
|
|
77
|
+
function bearerUnauthorized(config, message = "Missing or invalid bearer token", challengeError) {
|
|
78
|
+
return jsonRpcErrorResponse(401, ErrorCode.InvalidRequest, message, null, {
|
|
79
|
+
"WWW-Authenticate": buildMcpBearerChallenge({
|
|
80
|
+
appBaseUrl: config.appBaseUrl,
|
|
81
|
+
protectedResourceMetadataPath: config.routes.protectedResourceMetadataPath,
|
|
82
|
+
scopes: config.supportedScopes,
|
|
83
|
+
error: challengeError
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function formValue(value) {
|
|
88
|
+
return typeof value === "string" ? value : undefined;
|
|
89
|
+
}
|
|
90
|
+
async function readBody(c) {
|
|
91
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
92
|
+
if (contentType.includes("application/json")) {
|
|
93
|
+
return ((await c.req.json().catch(() => ({}))) ?? {});
|
|
94
|
+
}
|
|
95
|
+
const form = await c.req.parseBody().catch(() => ({}));
|
|
96
|
+
return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, formValue(value)]));
|
|
97
|
+
}
|
|
98
|
+
function queryOrBody(c, body, key) {
|
|
99
|
+
const fromQuery = c.req.query(key);
|
|
100
|
+
if (fromQuery !== undefined)
|
|
101
|
+
return fromQuery;
|
|
102
|
+
const fromBody = body[key];
|
|
103
|
+
return typeof fromBody === "string" ? fromBody : undefined;
|
|
104
|
+
}
|
|
105
|
+
function clientIdFromRequest(c, body) {
|
|
106
|
+
const clientId = queryOrBody(c, body, "client_id");
|
|
107
|
+
if (!clientId)
|
|
108
|
+
throw new Error("client_id is required");
|
|
109
|
+
return clientId;
|
|
110
|
+
}
|
|
111
|
+
function validateRedirectUri(clientRedirectUris, redirectUri) {
|
|
112
|
+
if (!redirectUri || !clientRedirectUris.includes(redirectUri))
|
|
113
|
+
throw new Error("redirect_uri is invalid for this OAuth client");
|
|
114
|
+
return redirectUri;
|
|
115
|
+
}
|
|
116
|
+
function validateResource(resource, config) {
|
|
117
|
+
const resolved = resource ?? config.resourceUri;
|
|
118
|
+
if (resolved !== config.resourceUri)
|
|
119
|
+
throw new Error("resource is invalid for this MCP server");
|
|
120
|
+
return resolved;
|
|
121
|
+
}
|
|
122
|
+
function requestedResource(c, body, config) {
|
|
123
|
+
return validateResource(queryOrBody(c, body, "resource"), config);
|
|
124
|
+
}
|
|
125
|
+
function redirectWithError(redirectUri, failure) {
|
|
126
|
+
const url = new URL(redirectUri);
|
|
127
|
+
url.searchParams.set("error", failure.error);
|
|
128
|
+
if (failure.description)
|
|
129
|
+
url.searchParams.set("error_description", failure.description);
|
|
130
|
+
if (failure.state)
|
|
131
|
+
url.searchParams.set("state", failure.state);
|
|
132
|
+
return Response.redirect(url, 302);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Named fields rather than positions: `state` and `description` are both optional strings
|
|
136
|
+
* with opposite meanings — one is the client's opaque CSRF value, the other is prose — so a
|
|
137
|
+
* positional pair of them is a swap that type-checks and corrupts the value a client verifies.
|
|
138
|
+
*/
|
|
139
|
+
function authorizationRequestError(redirectUri, failure) {
|
|
140
|
+
return redirectUri
|
|
141
|
+
? redirectWithError(redirectUri, failure)
|
|
142
|
+
: oauthError(failure.error, failure.description, failure.status ?? 400);
|
|
143
|
+
}
|
|
144
|
+
/** Report a fault the router swallowed, through the product's logger when it supplied one. */
|
|
145
|
+
function reportServerFault(adapters, message, error) {
|
|
146
|
+
if (adapters.logError)
|
|
147
|
+
adapters.logError(message, error);
|
|
148
|
+
else
|
|
149
|
+
console.error(message, error);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Marks an authorization request that has already been sent through sign-in once.
|
|
153
|
+
*
|
|
154
|
+
* Bounds the bounce to a single round trip: if the request comes back still reporting no
|
|
155
|
+
* signed-in human, the router refuses instead of redirecting again. Without it, a session
|
|
156
|
+
* cookie that never reaches the host serving the authorize route — a split-host deployment,
|
|
157
|
+
* say — spins the browser forever instead of producing a diagnosable OAuth error.
|
|
158
|
+
*/
|
|
159
|
+
const SIGN_IN_RETURN_PARAM = "mcp_signed_in";
|
|
160
|
+
/** Consent decision parameters, read by the POST authorize handler. */
|
|
161
|
+
const CONSENT_DECISION_PARAMS = ["consent_action", "decision", "action"];
|
|
162
|
+
/**
|
|
163
|
+
* What an OAuth client is told when the product's identity resolver fails unexpectedly.
|
|
164
|
+
*
|
|
165
|
+
* Fixed text, deliberately: the only descriptions that leave this endpoint are ones written
|
|
166
|
+
* for a stranger's eyes, and an arbitrary exception message is not one of them.
|
|
167
|
+
*/
|
|
168
|
+
const CONSENT_IDENTITY_FAILED = "The authorization server could not determine who is signing in";
|
|
169
|
+
function signInReturnTarget(c, body) {
|
|
170
|
+
const target = new URL(c.req.url);
|
|
171
|
+
// A consent POST carries the authorization request in its body, and the browser's request
|
|
172
|
+
// after sign-in is a GET, so fold the body into the query to re-present the same request.
|
|
173
|
+
// Everything is carried, not a list of the parameters this server happens to understand:
|
|
174
|
+
// a GET's query survives the round trip whole, and a client that sends an extension
|
|
175
|
+
// parameter should not lose it purely for having sent it by POST.
|
|
176
|
+
for (const [name, value] of Object.entries(body)) {
|
|
177
|
+
if (target.searchParams.has(name))
|
|
178
|
+
continue;
|
|
179
|
+
if (name === SIGN_IN_RETURN_PARAM)
|
|
180
|
+
continue;
|
|
181
|
+
if (typeof value === "string" && value.length > 0)
|
|
182
|
+
target.searchParams.set(name, value);
|
|
183
|
+
}
|
|
184
|
+
// The decision is deliberately left behind, from the query as well as the body: an approval
|
|
185
|
+
// given before sign-in must not be replayed as though whoever signed in afterwards had
|
|
186
|
+
// granted it. Only the POST handler reads these, so today the returning GET would ignore
|
|
187
|
+
// them anyway — dropping them here means that stays true if the GET path ever changes.
|
|
188
|
+
for (const name of CONSENT_DECISION_PARAMS)
|
|
189
|
+
target.searchParams.delete(name);
|
|
190
|
+
return target;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Resolve a configured product page address against the app's base URL.
|
|
194
|
+
*
|
|
195
|
+
* Resolution alone, with no pathname assignment after it: overwriting the pathname turns two
|
|
196
|
+
* reasonable configurations into nonsense with no error. An absolute URL — what a product whose
|
|
197
|
+
* identity lives on a separate host supplies — would be re-rooted under `appBaseUrl` as a path,
|
|
198
|
+
* and a path carrying a query would have its `?` percent-encoded into the pathname. Both work
|
|
199
|
+
* as written when `URL` is left to do the resolving.
|
|
200
|
+
*/
|
|
201
|
+
function productPageUrl(appBaseUrl, path) {
|
|
202
|
+
return new URL(path, `${trimTrailingSlash(appBaseUrl)}/`);
|
|
203
|
+
}
|
|
204
|
+
function signInRedirect(config, signInPath, target) {
|
|
205
|
+
target.searchParams.set(SIGN_IN_RETURN_PARAM, "1");
|
|
206
|
+
const url = productPageUrl(config.appBaseUrl, signInPath);
|
|
207
|
+
url.searchParams.set("callbackURL", `${target.pathname}${target.search}`);
|
|
208
|
+
return Response.redirect(url, 302);
|
|
209
|
+
}
|
|
210
|
+
function consentRedirect(config, request) {
|
|
211
|
+
const consentPath = config.routes.consentPagePath ?? "/oauth/consent";
|
|
212
|
+
const url = productPageUrl(config.appBaseUrl, consentPath);
|
|
213
|
+
url.searchParams.set("client_id", request.client.id);
|
|
214
|
+
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
215
|
+
url.searchParams.set("response_type", "code");
|
|
216
|
+
url.searchParams.set("code_challenge", request.codeChallenge);
|
|
217
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
218
|
+
url.searchParams.set("scope", formatMcpScopeString(request.scopes));
|
|
219
|
+
url.searchParams.set("resource", request.resource);
|
|
220
|
+
url.searchParams.set("workspace_id", request.workspaceId);
|
|
221
|
+
url.searchParams.set("user_id", request.userId);
|
|
222
|
+
if (request.state)
|
|
223
|
+
url.searchParams.set("state", request.state);
|
|
224
|
+
return Response.redirect(url, 302);
|
|
225
|
+
}
|
|
226
|
+
function forwardedProto(c) {
|
|
227
|
+
const forwardedProtoHeader = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
|
|
228
|
+
if (forwardedProtoHeader)
|
|
229
|
+
return forwardedProtoHeader;
|
|
230
|
+
const forwardedHeader = c.req.header("forwarded")?.split(",")[0];
|
|
231
|
+
const protoPair = forwardedHeader
|
|
232
|
+
?.split(";")
|
|
233
|
+
.map((part) => part.trim())
|
|
234
|
+
.find((part) => part.toLowerCase().startsWith("proto="));
|
|
235
|
+
return protoPair?.slice("proto=".length).replace(/^"|"$/g, "").toLowerCase() ?? null;
|
|
236
|
+
}
|
|
237
|
+
async function parseMcpBody(request) {
|
|
238
|
+
if (request.method !== "POST")
|
|
239
|
+
return null;
|
|
240
|
+
const body = await request.clone().json().catch(() => null);
|
|
241
|
+
const messages = Array.isArray(body) ? body : body ? [body] : [];
|
|
242
|
+
const isInitialize = messages.some((message) => typeof message === "object" && message && message.method === "initialize");
|
|
243
|
+
const requestMessage = messages.find((message) => typeof message === "object" && message && "id" in message);
|
|
244
|
+
return { body, isInitialize, id: requestMessage?.id ?? null };
|
|
245
|
+
}
|
|
246
|
+
function calledToolName(parsedBody) {
|
|
247
|
+
const messages = Array.isArray(parsedBody) ? parsedBody : parsedBody ? [parsedBody] : [];
|
|
248
|
+
for (const message of messages) {
|
|
249
|
+
if (!message || typeof message !== "object")
|
|
250
|
+
continue;
|
|
251
|
+
const candidate = message;
|
|
252
|
+
if (candidate.method === "tools/call" && typeof candidate.params?.name === "string")
|
|
253
|
+
return { name: candidate.params.name, id: candidate.id ?? null };
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
function sameScopes(left, right) {
|
|
258
|
+
return left.length === right.length && left.every((scope) => right.includes(scope));
|
|
259
|
+
}
|
|
260
|
+
function tokenMatchesSession(_context, token, sessionToken) {
|
|
261
|
+
return (sessionToken.grantId === token.grantId &&
|
|
262
|
+
sessionToken.clientId === token.clientId &&
|
|
263
|
+
sessionToken.workspaceId === token.workspaceId &&
|
|
264
|
+
sessionToken.userId === token.userId &&
|
|
265
|
+
sessionToken.sourceKind === token.sourceKind &&
|
|
266
|
+
sameScopes(sessionToken.scopes, token.scopes));
|
|
267
|
+
}
|
|
268
|
+
async function authenticateMcpRequest(c, config, adapters) {
|
|
269
|
+
const rawToken = parseBearerToken(c.req.raw);
|
|
270
|
+
if (!rawToken)
|
|
271
|
+
return bearerUnauthorized(config);
|
|
272
|
+
const token = await adapters.validateOAuthToken({
|
|
273
|
+
tokenHash: hashMcpToken(rawToken),
|
|
274
|
+
kind: "access",
|
|
275
|
+
resource: config.resourceUri,
|
|
276
|
+
recordLastUsed: true
|
|
277
|
+
});
|
|
278
|
+
if (!token)
|
|
279
|
+
return bearerUnauthorized(config, "Bearer token is expired, revoked, malformed, or issued for another resource");
|
|
280
|
+
return { rawToken, token };
|
|
281
|
+
}
|
|
282
|
+
function authInfo(rawToken, token, config) {
|
|
283
|
+
return {
|
|
284
|
+
token: rawToken,
|
|
285
|
+
clientId: token.clientId,
|
|
286
|
+
scopes: [...token.scopes],
|
|
287
|
+
expiresAt: Math.trunc(Date.parse(token.expiresAt) / 1000),
|
|
288
|
+
resource: new URL(config.resourceUri)
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async function validateAuthorizationRequest(c, body, config, adapters) {
|
|
292
|
+
let clientId;
|
|
293
|
+
try {
|
|
294
|
+
clientId = clientIdFromRequest(c, body);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return oauthError("invalid_request", "client_id is required");
|
|
298
|
+
}
|
|
299
|
+
const client = await adapters.getOAuthClient(clientId);
|
|
300
|
+
if (!client)
|
|
301
|
+
return oauthError("invalid_client", "OAuth client not found", 401);
|
|
302
|
+
let redirectUri;
|
|
303
|
+
try {
|
|
304
|
+
redirectUri = validateRedirectUri(client.redirectUris, queryOrBody(c, body, "redirect_uri"));
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return oauthError("invalid_request", "redirect_uri is invalid for this OAuth client");
|
|
308
|
+
}
|
|
309
|
+
const state = queryOrBody(c, body, "state");
|
|
310
|
+
if (queryOrBody(c, body, "response_type") !== "code")
|
|
311
|
+
return authorizationRequestError(redirectUri, { error: "unsupported_response_type", description: "Only response_type=code is supported", state });
|
|
312
|
+
let resource;
|
|
313
|
+
try {
|
|
314
|
+
resource = requestedResource(c, body, config);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return authorizationRequestError(redirectUri, { error: "invalid_request", description: "resource is invalid for this MCP server", state });
|
|
318
|
+
}
|
|
319
|
+
const codeChallenge = queryOrBody(c, body, "code_challenge");
|
|
320
|
+
const codeChallengeMethod = queryOrBody(c, body, "code_challenge_method");
|
|
321
|
+
if (!codeChallenge || codeChallengeMethod !== "S256")
|
|
322
|
+
return authorizationRequestError(redirectUri, { error: "invalid_request", description: "PKCE S256 code challenge is required", state });
|
|
323
|
+
const rawScope = queryOrBody(c, body, "scope");
|
|
324
|
+
const allowedScopes = client.scopes.length > 0 ? client.scopes : config.supportedScopes;
|
|
325
|
+
if (includesUnsupportedMcpScope(rawScope, allowedScopes))
|
|
326
|
+
return authorizationRequestError(redirectUri, { error: "invalid_scope", description: "Requested scope is not supported for this OAuth client", state });
|
|
327
|
+
const scopes = requestedMcpScopes(rawScope, allowedScopes, config.defaultScopes);
|
|
328
|
+
try {
|
|
329
|
+
const identity = await adapters.resolveConsentIdentity({
|
|
330
|
+
headers: c.req.raw.headers,
|
|
331
|
+
workspaceId: queryOrBody(c, body, "workspace_id"),
|
|
332
|
+
userId: queryOrBody(c, body, "user_id")
|
|
333
|
+
});
|
|
334
|
+
return { client, redirectUri, state, resource, codeChallenge, scopes, workspaceId: identity.workspaceId, userId: identity.userId };
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
// Three different answers hide behind one throw here. A missing session is the user's to
|
|
338
|
+
// fix and gets sign-in; a refusal is terminal and goes back to the client; anything else
|
|
339
|
+
// is a malformed request, as before.
|
|
340
|
+
if (error instanceof McpConsentSignInRequiredError) {
|
|
341
|
+
const target = signInReturnTarget(c, body);
|
|
342
|
+
if (!config.routes.signInPath) {
|
|
343
|
+
return authorizationRequestError(redirectUri, { error: "server_error", description: "This authorization server has no sign-in location configured", state });
|
|
344
|
+
}
|
|
345
|
+
if (target.searchParams.has(SIGN_IN_RETURN_PARAM)) {
|
|
346
|
+
return authorizationRequestError(redirectUri, { error: "access_denied", description: "Returning from sign-in did not produce a signed-in session", state });
|
|
347
|
+
}
|
|
348
|
+
return signInRedirect(config, config.routes.signInPath, target);
|
|
349
|
+
}
|
|
350
|
+
if (error instanceof McpConsentForbiddenError) {
|
|
351
|
+
return authorizationRequestError(redirectUri, { error: "access_denied", description: error.message, state });
|
|
352
|
+
}
|
|
353
|
+
// Anything else is a fault, not an answer. The message belongs to the product's identity
|
|
354
|
+
// stack — a database, a session provider, a repository — and this endpoint delivers it to
|
|
355
|
+
// whatever redirect URI a self-registered client asked for, so it is logged here and never
|
|
356
|
+
// sent. `server_error` is also the truthful code: nothing about the request was wrong.
|
|
357
|
+
reportServerFault(adapters, "[saas-mcp] resolveConsentIdentity failed", error);
|
|
358
|
+
return authorizationRequestError(redirectUri, {
|
|
359
|
+
error: "server_error",
|
|
360
|
+
description: CONSENT_IDENTITY_FAILED,
|
|
361
|
+
state,
|
|
362
|
+
status: 500
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function handleMcpRequest(c, options, activeSessions, sessionTokens) {
|
|
367
|
+
const config = options.resolveConfig(c.req.url);
|
|
368
|
+
const envelope = validateMcpHttpEnvelope({
|
|
369
|
+
url: new URL(c.req.url),
|
|
370
|
+
method: c.req.method,
|
|
371
|
+
headers: c.req.raw.headers,
|
|
372
|
+
requireHttps: config.requireHttps,
|
|
373
|
+
allowedHosts: config.allowedHosts,
|
|
374
|
+
allowedOrigins: config.allowedOrigins,
|
|
375
|
+
supportedProtocolVersions: config.supportedProtocolVersions,
|
|
376
|
+
forwardedProto: forwardedProto(c)
|
|
377
|
+
});
|
|
378
|
+
if (!envelope.ok) {
|
|
379
|
+
if (envelope.reason === "query_token")
|
|
380
|
+
return bearerUnauthorized(config, envelope.message);
|
|
381
|
+
return jsonRpcErrorResponse(envelope.status, ErrorCode.InvalidRequest, envelope.message);
|
|
382
|
+
}
|
|
383
|
+
const auth = await authenticateMcpRequest(c, config, options.adapters);
|
|
384
|
+
if (auth instanceof Response)
|
|
385
|
+
return auth;
|
|
386
|
+
const parsed = await parseMcpBody(c.req.raw);
|
|
387
|
+
const sessionId = c.req.header("mcp-session-id") ?? undefined;
|
|
388
|
+
if (!parsed?.isInitialize && !sessionId) {
|
|
389
|
+
return jsonRpcErrorResponse(400, ErrorCode.InvalidRequest, "Mcp-Session-Id header is required after initialization", parsed?.id ?? null);
|
|
390
|
+
}
|
|
391
|
+
const toolCall = calledToolName(parsed?.body);
|
|
392
|
+
if (toolCall && options.adapters.getToolByName) {
|
|
393
|
+
const tool = options.adapters.getToolByName(toolCall.name);
|
|
394
|
+
if (tool && !hasRequiredMcpScopes(auth.token.scopes, tool.requiredScopes)) {
|
|
395
|
+
await options.adapters.recordMcpAuditEvent({
|
|
396
|
+
workspaceId: auth.token.workspaceId,
|
|
397
|
+
userId: auth.token.userId,
|
|
398
|
+
clientId: auth.token.clientId,
|
|
399
|
+
accessTokenId: auth.token.id,
|
|
400
|
+
sourceKind: auth.token.sourceKind,
|
|
401
|
+
activityKind: tool.annotations?.readOnlyHint ? "read" : "write",
|
|
402
|
+
toolName: tool.name,
|
|
403
|
+
outcome: "rejected",
|
|
404
|
+
metadata: { reason: "insufficient_scope", requiredScopes: tool.requiredScopes }
|
|
405
|
+
});
|
|
406
|
+
return jsonRpcErrorResponse(403, ErrorCode.InvalidRequest, `insufficient_scope: requires ${tool.requiredScopes.join(" ")}`, toolCall.id);
|
|
407
|
+
}
|
|
408
|
+
if (tool?.annotations?.readOnlyHint) {
|
|
409
|
+
await options.adapters.recordMcpAuditEvent({
|
|
410
|
+
workspaceId: auth.token.workspaceId,
|
|
411
|
+
userId: auth.token.userId,
|
|
412
|
+
clientId: auth.token.clientId,
|
|
413
|
+
accessTokenId: auth.token.id,
|
|
414
|
+
sourceKind: auth.token.sourceKind,
|
|
415
|
+
activityKind: "read",
|
|
416
|
+
toolName: tool.name,
|
|
417
|
+
outcome: "matched",
|
|
418
|
+
metadata: { method: "tools/call" }
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (sessionId) {
|
|
423
|
+
const active = activeSessions.get(sessionId);
|
|
424
|
+
const sessionToken = sessionTokens.get(sessionId);
|
|
425
|
+
if (!active || !sessionToken)
|
|
426
|
+
return jsonRpcErrorResponse(404, ErrorCode.InvalidRequest, "MCP session not found", parsed?.id ?? null);
|
|
427
|
+
if (!tokenMatchesSession(active.context, auth.token, sessionToken))
|
|
428
|
+
return bearerUnauthorized(config, "Bearer token does not match this MCP session");
|
|
429
|
+
const refreshed = options.adapters.refreshMcpContext?.(active.context, auth.token);
|
|
430
|
+
if (refreshed)
|
|
431
|
+
active.context = refreshed;
|
|
432
|
+
sessionTokens.set(sessionId, auth.token);
|
|
433
|
+
await options.adapters.touchMcpSession(sessionId, { accessTokenId: auth.token.id, scopes: [...auth.token.scopes] });
|
|
434
|
+
const response = await active.transport.handleRequest(c.req.raw, { authInfo: authInfo(auth.rawToken, auth.token, config) });
|
|
435
|
+
if (c.req.raw.method === "DELETE") {
|
|
436
|
+
activeSessions.delete(sessionId);
|
|
437
|
+
sessionTokens.delete(sessionId);
|
|
438
|
+
await options.adapters.closeMcpSession(sessionId);
|
|
439
|
+
}
|
|
440
|
+
return response;
|
|
441
|
+
}
|
|
442
|
+
let context;
|
|
443
|
+
try {
|
|
444
|
+
context = await options.adapters.createMcpContext({ token: auth.token, config });
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (!(error instanceof McpContextUnauthorizedError))
|
|
448
|
+
throw error;
|
|
449
|
+
return bearerUnauthorized(config, error.message, "invalid_token");
|
|
450
|
+
}
|
|
451
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
452
|
+
sessionIdGenerator: options.sessionIdGenerator,
|
|
453
|
+
enableJsonResponse: true,
|
|
454
|
+
onsessioninitialized: (newSessionId) => {
|
|
455
|
+
void options.adapters.trackMcpSession({
|
|
456
|
+
id: newSessionId,
|
|
457
|
+
accessTokenId: auth.token.id,
|
|
458
|
+
clientId: auth.token.clientId,
|
|
459
|
+
workspaceId: auth.token.workspaceId,
|
|
460
|
+
userId: auth.token.userId,
|
|
461
|
+
scopes: [...auth.token.scopes],
|
|
462
|
+
protocolVersion: c.req.header("mcp-protocol-version") ?? config.latestProtocolVersion
|
|
463
|
+
});
|
|
464
|
+
activeSessions.set(newSessionId, { transport, context });
|
|
465
|
+
sessionTokens.set(newSessionId, auth.token);
|
|
466
|
+
},
|
|
467
|
+
onsessionclosed: (closedSessionId) => {
|
|
468
|
+
activeSessions.delete(closedSessionId);
|
|
469
|
+
sessionTokens.delete(closedSessionId);
|
|
470
|
+
void options.adapters.closeMcpSession(closedSessionId);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
const server = options.adapters.createMcpServer(context);
|
|
474
|
+
await server.connect(transport);
|
|
475
|
+
return transport.handleRequest(c.req.raw, { authInfo: authInfo(auth.rawToken, auth.token, config) });
|
|
476
|
+
}
|
|
477
|
+
/** The host-absolute address RFC 8414 §3.1 derives from an issuer identifier. */
|
|
478
|
+
function authorizationServerMetadataAddress(issuer) {
|
|
479
|
+
return new URL(authorizationServerMetadataUrlForIssuer(trimTrailingSlash(issuer))).pathname;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Where to register authorization-server metadata on this app, so that it answers at the
|
|
483
|
+
* address a client derives from the issuer.
|
|
484
|
+
*
|
|
485
|
+
* Derived rather than configured. Every other route can be advertised wherever it is served,
|
|
486
|
+
* but this one is pinned by RFC 8414 to a location computed from the `issuer` in the document
|
|
487
|
+
* itself, so a mount the product chooses independently is a mount that can disagree with the
|
|
488
|
+
* metadata — which is the whole failure this change exists to fix. Taking the mount out of the
|
|
489
|
+
* product's hands is what turns "serve it in the right place" from an instruction into a
|
|
490
|
+
* property of the router.
|
|
491
|
+
*
|
|
492
|
+
* The issuer is read once here, from `resolveConfig` with no request URL. Only its *path* is
|
|
493
|
+
* used, and the router requires that path not to vary by request: a deployment answering on
|
|
494
|
+
* several hosts publishes a different issuer per host but the same path under each. That is a
|
|
495
|
+
* property of the product's resolver rather than of the router, so it is not assumed — the
|
|
496
|
+
* metadata handler re-derives the address per request and refuses on disagreement, which is
|
|
497
|
+
* what `authorizationServerMetadataAddress` exists for.
|
|
498
|
+
*/
|
|
499
|
+
function authorizationServerMetadataMountPath(options) {
|
|
500
|
+
const issuer = trimTrailingSlash(options.resolveConfig().appBaseUrl);
|
|
501
|
+
const required = authorizationServerMetadataAddress(issuer);
|
|
502
|
+
const basePath = trimTrailingSlash(options.mounts.basePath ?? "");
|
|
503
|
+
if (!basePath)
|
|
504
|
+
return required;
|
|
505
|
+
if (required === basePath || required.startsWith(`${basePath}/`))
|
|
506
|
+
return required.slice(basePath.length);
|
|
507
|
+
// Refused rather than mounted somewhere that cannot be reached: an app mounted under a
|
|
508
|
+
// prefix that the derived address does not fall inside has no way to serve it, so the
|
|
509
|
+
// deployment needs a second registration at the host root. Better to say so at boot than
|
|
510
|
+
// to publish an `authorization_servers` entry that answers nothing.
|
|
511
|
+
throw new Error(`Authorization-server metadata for issuer ${issuer} must be served at ${required}, which is outside this app's basePath ${basePath}. Register the MCP routes on an app mounted at the host root, or set basePath to a prefix that contains ${required}.`);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Register OAuth discovery, dynamic client registration, authorization-code
|
|
515
|
+
* flow, token revocation, and Streamable HTTP MCP routes on a Hono app.
|
|
516
|
+
*
|
|
517
|
+
* The function is intentionally adapter-driven: it does not import product
|
|
518
|
+
* repositories, auth systems, or tool handlers. Consumers mount these routes
|
|
519
|
+
* inside their own API surface and supply product-specific adapters.
|
|
520
|
+
*/
|
|
521
|
+
export function registerMcpHonoRoutes(app, options) {
|
|
522
|
+
const activeSessions = new Map();
|
|
523
|
+
const sessionTokens = new Map();
|
|
524
|
+
const authorizationServerMetadataMount = authorizationServerMetadataMountPath(options);
|
|
525
|
+
app.get(options.mounts.protectedResourceMetadata, (c) => {
|
|
526
|
+
const config = options.resolveConfig(c.req.url);
|
|
527
|
+
return configuredResponse(buildMcpProtectedResourceMetadata({
|
|
528
|
+
appBaseUrl: config.appBaseUrl,
|
|
529
|
+
resourceUri: config.resourceUri,
|
|
530
|
+
supportedScopes: config.supportedScopes,
|
|
531
|
+
routes: config.routes
|
|
532
|
+
}));
|
|
533
|
+
});
|
|
534
|
+
app.get(`${options.mounts.protectedResourceMetadata}/*`, (c) => {
|
|
535
|
+
const config = options.resolveConfig(c.req.url);
|
|
536
|
+
return configuredResponse(buildMcpProtectedResourceMetadata({
|
|
537
|
+
appBaseUrl: config.appBaseUrl,
|
|
538
|
+
resourceUri: config.resourceUri,
|
|
539
|
+
supportedScopes: config.supportedScopes,
|
|
540
|
+
routes: config.routes
|
|
541
|
+
}));
|
|
542
|
+
});
|
|
543
|
+
app.get(authorizationServerMetadataMount, (c) => {
|
|
544
|
+
const config = options.resolveConfig(c.req.url);
|
|
545
|
+
// The mount was computed once, at registration, from the issuer this resolver returns with
|
|
546
|
+
// no request URL. A resolver whose issuer *path* varies by request — per-tenant prefixes,
|
|
547
|
+
// the case RFC 8414 §3.1 path insertion exists for — makes that one mount wrong for every
|
|
548
|
+
// tenant but one, and the document served here would name an issuer no client could have
|
|
549
|
+
// derived this address from. Serving it anyway is the original failure one level up, so it
|
|
550
|
+
// is refused instead, loudly enough to be found.
|
|
551
|
+
const derived = authorizationServerMetadataAddress(config.appBaseUrl);
|
|
552
|
+
const registered = `${trimTrailingSlash(options.mounts.basePath ?? "")}${authorizationServerMetadataMount}`;
|
|
553
|
+
if (derived !== registered) {
|
|
554
|
+
reportServerFault(options.adapters, `[saas-mcp] authorization-server metadata is registered at ${registered}, but issuer ${trimTrailingSlash(config.appBaseUrl)} derives ${derived}. resolveConfig must return an issuer whose path does not vary by request.`, new Error("mcp_authorization_server_metadata_mount_mismatch"));
|
|
555
|
+
return oauthError("server_error", "Authorization server metadata is not served at its issuer-derived address", 500);
|
|
556
|
+
}
|
|
557
|
+
return configuredResponse(buildMcpAuthorizationServerMetadata({
|
|
558
|
+
appBaseUrl: config.appBaseUrl,
|
|
559
|
+
resourceUri: config.resourceUri,
|
|
560
|
+
supportedScopes: config.supportedScopes,
|
|
561
|
+
routes: config.routes
|
|
562
|
+
}));
|
|
563
|
+
});
|
|
564
|
+
app.get(options.mounts.authorize, async (c) => {
|
|
565
|
+
const config = options.resolveConfig(c.req.url);
|
|
566
|
+
const request = await validateAuthorizationRequest(c, {}, config, options.adapters);
|
|
567
|
+
if (request instanceof Response)
|
|
568
|
+
return request;
|
|
569
|
+
return consentRedirect(config, request);
|
|
570
|
+
});
|
|
571
|
+
app.post(options.mounts.authorize, async (c) => {
|
|
572
|
+
const config = options.resolveConfig(c.req.url);
|
|
573
|
+
const body = await readBody(c);
|
|
574
|
+
const request = await validateAuthorizationRequest(c, body, config, options.adapters);
|
|
575
|
+
if (request instanceof Response)
|
|
576
|
+
return request;
|
|
577
|
+
const consentAction = queryOrBody(c, body, "consent_action") ?? queryOrBody(c, body, "decision") ?? queryOrBody(c, body, "action");
|
|
578
|
+
if (consentAction === "cancel" || consentAction === "deny")
|
|
579
|
+
return redirectWithError(request.redirectUri, { error: "access_denied", description: "The user declined to authorize this client", state: request.state });
|
|
580
|
+
if (consentAction !== "approve")
|
|
581
|
+
return consentRedirect(config, request);
|
|
582
|
+
const rawCode = randomMcpToken(config.tokenPrefixes?.authorizationCode ?? "mcp_code");
|
|
583
|
+
await options.adapters.createOAuthAuthorizationCode({
|
|
584
|
+
codeHash: hashMcpToken(rawCode),
|
|
585
|
+
clientId: request.client.id,
|
|
586
|
+
redirectUri: request.redirectUri,
|
|
587
|
+
workspaceId: request.workspaceId,
|
|
588
|
+
userId: request.userId,
|
|
589
|
+
scopes: request.scopes,
|
|
590
|
+
resource: request.resource,
|
|
591
|
+
codeChallenge: request.codeChallenge,
|
|
592
|
+
codeChallengeMethod: "S256",
|
|
593
|
+
expiresAt: addSeconds(new Date(), config.authorizationCodeTtlSeconds)
|
|
594
|
+
});
|
|
595
|
+
const redirect = new URL(request.redirectUri);
|
|
596
|
+
redirect.searchParams.set("code", rawCode);
|
|
597
|
+
if (request.state)
|
|
598
|
+
redirect.searchParams.set("state", request.state);
|
|
599
|
+
return Response.redirect(redirect, 302);
|
|
600
|
+
});
|
|
601
|
+
app.post(options.mounts.registration, async (c) => {
|
|
602
|
+
const config = options.resolveConfig(c.req.url);
|
|
603
|
+
const body = await readBody(c);
|
|
604
|
+
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((uri) => typeof uri === "string") : [];
|
|
605
|
+
if (redirectUris.length === 0)
|
|
606
|
+
return oauthError("invalid_client_metadata", "redirect_uris is required");
|
|
607
|
+
const rawScope = typeof body.scope === "string" ? body.scope : undefined;
|
|
608
|
+
const rawScopes = Array.isArray(body.scopes) ? body.scopes.filter((scope) => typeof scope === "string") : undefined;
|
|
609
|
+
const scopes = normalizeMcpScopes(rawScopes ?? rawScope, config.supportedScopes);
|
|
610
|
+
const client = await options.adapters.registerOAuthClient({
|
|
611
|
+
name: typeof body.client_name === "string" ? body.client_name : "MCP client",
|
|
612
|
+
displayName: typeof body.client_name === "string" ? body.client_name : undefined,
|
|
613
|
+
redirectUris,
|
|
614
|
+
scopes: scopes.length > 0 ? scopes : [...config.supportedScopes]
|
|
615
|
+
});
|
|
616
|
+
return json({
|
|
617
|
+
client_id: client.id,
|
|
618
|
+
client_name: client.name,
|
|
619
|
+
redirect_uris: client.redirectUris,
|
|
620
|
+
scope: formatMcpScopeString(client.scopes),
|
|
621
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
622
|
+
response_types: ["code"],
|
|
623
|
+
token_endpoint_auth_method: "none",
|
|
624
|
+
client_id_issued_at: Math.trunc(Date.parse(client.createdAt) / 1000)
|
|
625
|
+
}, 201, { "cache-control": "no-store" });
|
|
626
|
+
});
|
|
627
|
+
app.post(options.mounts.token, async (c) => {
|
|
628
|
+
const config = options.resolveConfig(c.req.url);
|
|
629
|
+
const body = await readBody(c);
|
|
630
|
+
const grantType = body.grant_type;
|
|
631
|
+
const clientId = clientIdFromRequest(c, body);
|
|
632
|
+
const client = await options.adapters.getOAuthClient(clientId);
|
|
633
|
+
if (!client)
|
|
634
|
+
return oauthError("invalid_client", "OAuth client not found", 401);
|
|
635
|
+
if (grantType === "authorization_code") {
|
|
636
|
+
const redirectUri = validateRedirectUri(client.redirectUris, queryOrBody(c, body, "redirect_uri"));
|
|
637
|
+
const resource = requestedResource(c, body, config);
|
|
638
|
+
const code = queryOrBody(c, body, "code");
|
|
639
|
+
const codeVerifier = queryOrBody(c, body, "code_verifier");
|
|
640
|
+
if (!code || !codeVerifier)
|
|
641
|
+
return oauthError("invalid_request", "code and code_verifier are required");
|
|
642
|
+
const grant = await options.adapters.consumeOAuthAuthorizationCode({ codeHash: hashMcpToken(code), clientId, redirectUri, resource });
|
|
643
|
+
if (!grant)
|
|
644
|
+
return oauthError("invalid_grant", "Authorization code is expired, reused, or invalid");
|
|
645
|
+
if (!verifyPkceS256(codeVerifier, grant.codeChallenge))
|
|
646
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
647
|
+
if (!(await options.adapters.hasWorkspaceMembership({ workspaceId: grant.workspaceId, userId: grant.userId })))
|
|
648
|
+
return oauthError("invalid_grant", "Workspace membership required");
|
|
649
|
+
const accessToken = randomMcpToken(config.tokenPrefixes?.accessToken ?? "mcp_at");
|
|
650
|
+
const refreshToken = randomMcpToken(config.tokenPrefixes?.refreshToken ?? "mcp_rt");
|
|
651
|
+
await options.adapters.issueOAuthToken({
|
|
652
|
+
tokenHash: hashMcpToken(accessToken),
|
|
653
|
+
kind: "access",
|
|
654
|
+
grantId: grant.id,
|
|
655
|
+
clientId,
|
|
656
|
+
workspaceId: grant.workspaceId,
|
|
657
|
+
userId: grant.userId,
|
|
658
|
+
scopes: [...grant.scopes],
|
|
659
|
+
resource,
|
|
660
|
+
expiresAt: addSeconds(new Date(), config.accessTokenTtlSeconds)
|
|
661
|
+
});
|
|
662
|
+
await options.adapters.issueOAuthToken({
|
|
663
|
+
tokenHash: hashMcpToken(refreshToken),
|
|
664
|
+
kind: "refresh",
|
|
665
|
+
grantId: grant.id,
|
|
666
|
+
clientId,
|
|
667
|
+
workspaceId: grant.workspaceId,
|
|
668
|
+
userId: grant.userId,
|
|
669
|
+
scopes: [...grant.scopes],
|
|
670
|
+
resource,
|
|
671
|
+
expiresAt: addSeconds(new Date(), config.refreshTokenTtlSeconds)
|
|
672
|
+
});
|
|
673
|
+
return tokenResponse({
|
|
674
|
+
access_token: accessToken,
|
|
675
|
+
token_type: "Bearer",
|
|
676
|
+
expires_in: config.accessTokenTtlSeconds,
|
|
677
|
+
refresh_token: refreshToken,
|
|
678
|
+
scope: formatMcpScopeString(grant.scopes),
|
|
679
|
+
resource
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (grantType === "refresh_token") {
|
|
683
|
+
const resource = requestedResource(c, body, config);
|
|
684
|
+
const refreshToken = queryOrBody(c, body, "refresh_token");
|
|
685
|
+
if (!refreshToken)
|
|
686
|
+
return oauthError("invalid_request", "refresh_token is required");
|
|
687
|
+
const tokenRecord = await options.adapters.validateOAuthToken({ tokenHash: hashMcpToken(refreshToken), kind: "refresh", resource });
|
|
688
|
+
if (!tokenRecord || tokenRecord.clientId !== clientId)
|
|
689
|
+
return oauthError("invalid_grant", "Refresh token is expired, revoked, or invalid");
|
|
690
|
+
const accessToken = randomMcpToken(config.tokenPrefixes?.accessToken ?? "mcp_at");
|
|
691
|
+
await options.adapters.issueOAuthToken({
|
|
692
|
+
tokenHash: hashMcpToken(accessToken),
|
|
693
|
+
kind: "access",
|
|
694
|
+
grantId: tokenRecord.grantId,
|
|
695
|
+
clientId,
|
|
696
|
+
workspaceId: tokenRecord.workspaceId,
|
|
697
|
+
userId: tokenRecord.userId,
|
|
698
|
+
scopes: [...tokenRecord.scopes],
|
|
699
|
+
resource,
|
|
700
|
+
expiresAt: addSeconds(new Date(), config.accessTokenTtlSeconds)
|
|
701
|
+
});
|
|
702
|
+
return tokenResponse({
|
|
703
|
+
access_token: accessToken,
|
|
704
|
+
token_type: "Bearer",
|
|
705
|
+
expires_in: config.accessTokenTtlSeconds,
|
|
706
|
+
scope: formatMcpScopeString(tokenRecord.scopes),
|
|
707
|
+
resource
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
return oauthError("unsupported_grant_type", "Only authorization_code and refresh_token grants are supported");
|
|
711
|
+
});
|
|
712
|
+
app.post(options.mounts.revocation, async (c) => {
|
|
713
|
+
const config = options.resolveConfig(c.req.url);
|
|
714
|
+
const body = await readBody(c);
|
|
715
|
+
const tokenValue = queryOrBody(c, body, "token");
|
|
716
|
+
if (!tokenValue)
|
|
717
|
+
return new Response(null, { status: 200 });
|
|
718
|
+
const tokenHash = hashMcpToken(tokenValue);
|
|
719
|
+
const tokenRecord = await options.adapters.getOAuthTokenByHash({ tokenHash, resource: config.resourceUri });
|
|
720
|
+
const clientId = queryOrBody(c, body, "client_id");
|
|
721
|
+
if (tokenRecord && clientId && tokenRecord.clientId !== clientId)
|
|
722
|
+
return oauthError("invalid_client", "client_id does not match token owner", 401);
|
|
723
|
+
if (tokenRecord && (await options.adapters.revokeOAuthToken(tokenHash, { revokedReason: "oauth_revocation_endpoint" }))) {
|
|
724
|
+
await options.adapters.recordMcpAuditEvent({
|
|
725
|
+
workspaceId: tokenRecord.workspaceId,
|
|
726
|
+
userId: tokenRecord.userId,
|
|
727
|
+
clientId: tokenRecord.clientId,
|
|
728
|
+
accessTokenId: tokenRecord.kind === "access" ? tokenRecord.id : null,
|
|
729
|
+
sourceKind: tokenRecord.sourceKind,
|
|
730
|
+
activityKind: "management",
|
|
731
|
+
toolName: "oauth.revoke",
|
|
732
|
+
outcome: "revoked",
|
|
733
|
+
metadata: { tokenKind: tokenRecord.kind, grantId: tokenRecord.grantId, tokenName: tokenRecord.displayName }
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return new Response(null, { status: 200 });
|
|
737
|
+
});
|
|
738
|
+
app.all(options.mounts.mcp, async (c) => handleMcpRequest(c, options, activeSessions, sessionTokens));
|
|
739
|
+
}
|