@forwardreach/saas-mcp 0.1.0
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 +7 -0
- 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 +256 -0
- package/dist/core/types.js +1 -0
- package/dist/core/urls.d.ts +69 -0
- package/dist/core/urls.js +85 -0
- package/dist/hono/index.d.ts +1 -0
- package/dist/hono/index.js +1 -0
- package/dist/hono/routes.d.ts +212 -0
- package/dist/hono/routes.js +535 -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 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2
|
+
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata, 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
|
+
function json(data, status = 200, headers = {}) {
|
|
7
|
+
return new Response(JSON.stringify(data), {
|
|
8
|
+
status,
|
|
9
|
+
headers: {
|
|
10
|
+
"content-type": "application/json",
|
|
11
|
+
...headers
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function configuredResponse(data, status = 200) {
|
|
16
|
+
return json(data, status, { "cache-control": "no-store", "Access-Control-Allow-Origin": "*" });
|
|
17
|
+
}
|
|
18
|
+
function tokenResponse(data) {
|
|
19
|
+
return json(data, 200, { "cache-control": "no-store", pragma: "no-cache" });
|
|
20
|
+
}
|
|
21
|
+
function oauthError(error, description, status = 400) {
|
|
22
|
+
return json({ error, error_description: description }, status, { "cache-control": "no-store", pragma: "no-cache" });
|
|
23
|
+
}
|
|
24
|
+
function jsonRpcErrorResponse(status, code, message, id = null, headers = {}) {
|
|
25
|
+
return json({
|
|
26
|
+
jsonrpc: "2.0",
|
|
27
|
+
error: { code, message },
|
|
28
|
+
id
|
|
29
|
+
}, status, headers);
|
|
30
|
+
}
|
|
31
|
+
function bearerUnauthorized(config, message = "Missing or invalid bearer token") {
|
|
32
|
+
return jsonRpcErrorResponse(401, ErrorCode.InvalidRequest, message, null, {
|
|
33
|
+
"WWW-Authenticate": buildMcpBearerChallenge({
|
|
34
|
+
appBaseUrl: config.appBaseUrl,
|
|
35
|
+
protectedResourceMetadataPath: config.routes.protectedResourceMetadataPath,
|
|
36
|
+
scopes: config.supportedScopes
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function formValue(value) {
|
|
41
|
+
return typeof value === "string" ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
async function readBody(c) {
|
|
44
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
45
|
+
if (contentType.includes("application/json")) {
|
|
46
|
+
return ((await c.req.json().catch(() => ({}))) ?? {});
|
|
47
|
+
}
|
|
48
|
+
const form = await c.req.parseBody().catch(() => ({}));
|
|
49
|
+
return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, formValue(value)]));
|
|
50
|
+
}
|
|
51
|
+
function queryOrBody(c, body, key) {
|
|
52
|
+
const fromQuery = c.req.query(key);
|
|
53
|
+
if (fromQuery !== undefined)
|
|
54
|
+
return fromQuery;
|
|
55
|
+
const fromBody = body[key];
|
|
56
|
+
return typeof fromBody === "string" ? fromBody : undefined;
|
|
57
|
+
}
|
|
58
|
+
function clientIdFromRequest(c, body) {
|
|
59
|
+
const clientId = queryOrBody(c, body, "client_id");
|
|
60
|
+
if (!clientId)
|
|
61
|
+
throw new Error("client_id is required");
|
|
62
|
+
return clientId;
|
|
63
|
+
}
|
|
64
|
+
function validateRedirectUri(clientRedirectUris, redirectUri) {
|
|
65
|
+
if (!redirectUri || !clientRedirectUris.includes(redirectUri))
|
|
66
|
+
throw new Error("redirect_uri is invalid for this OAuth client");
|
|
67
|
+
return redirectUri;
|
|
68
|
+
}
|
|
69
|
+
function validateResource(resource, config) {
|
|
70
|
+
const resolved = resource ?? config.resourceUri;
|
|
71
|
+
if (resolved !== config.resourceUri)
|
|
72
|
+
throw new Error("resource is invalid for this MCP server");
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
function requestedResource(c, body, config) {
|
|
76
|
+
return validateResource(queryOrBody(c, body, "resource"), config);
|
|
77
|
+
}
|
|
78
|
+
function redirectWithError(redirectUri, error, state) {
|
|
79
|
+
const url = new URL(redirectUri);
|
|
80
|
+
url.searchParams.set("error", error);
|
|
81
|
+
if (state)
|
|
82
|
+
url.searchParams.set("state", state);
|
|
83
|
+
return Response.redirect(url, 302);
|
|
84
|
+
}
|
|
85
|
+
function authorizationRequestError(redirectUri, error, description, state, status = 400) {
|
|
86
|
+
return redirectUri ? redirectWithError(redirectUri, error, state) : oauthError(error, description, status);
|
|
87
|
+
}
|
|
88
|
+
function consentRedirect(config, request) {
|
|
89
|
+
const consentPath = config.routes.consentPagePath ?? "/oauth/consent";
|
|
90
|
+
const url = new URL(consentPath, config.appBaseUrl);
|
|
91
|
+
url.pathname = consentPath;
|
|
92
|
+
url.searchParams.set("client_id", request.client.id);
|
|
93
|
+
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
94
|
+
url.searchParams.set("response_type", "code");
|
|
95
|
+
url.searchParams.set("code_challenge", request.codeChallenge);
|
|
96
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
97
|
+
url.searchParams.set("scope", formatMcpScopeString(request.scopes));
|
|
98
|
+
url.searchParams.set("resource", request.resource);
|
|
99
|
+
url.searchParams.set("workspace_id", request.workspaceId);
|
|
100
|
+
url.searchParams.set("user_id", request.userId);
|
|
101
|
+
if (request.state)
|
|
102
|
+
url.searchParams.set("state", request.state);
|
|
103
|
+
return Response.redirect(url, 302);
|
|
104
|
+
}
|
|
105
|
+
function forwardedProto(c) {
|
|
106
|
+
const forwardedProtoHeader = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
|
|
107
|
+
if (forwardedProtoHeader)
|
|
108
|
+
return forwardedProtoHeader;
|
|
109
|
+
const forwardedHeader = c.req.header("forwarded")?.split(",")[0];
|
|
110
|
+
const protoPair = forwardedHeader
|
|
111
|
+
?.split(";")
|
|
112
|
+
.map((part) => part.trim())
|
|
113
|
+
.find((part) => part.toLowerCase().startsWith("proto="));
|
|
114
|
+
return protoPair?.slice("proto=".length).replace(/^"|"$/g, "").toLowerCase() ?? null;
|
|
115
|
+
}
|
|
116
|
+
async function parseMcpBody(request) {
|
|
117
|
+
if (request.method !== "POST")
|
|
118
|
+
return null;
|
|
119
|
+
const body = await request.clone().json().catch(() => null);
|
|
120
|
+
const messages = Array.isArray(body) ? body : body ? [body] : [];
|
|
121
|
+
const isInitialize = messages.some((message) => typeof message === "object" && message && message.method === "initialize");
|
|
122
|
+
const requestMessage = messages.find((message) => typeof message === "object" && message && "id" in message);
|
|
123
|
+
return { body, isInitialize, id: requestMessage?.id ?? null };
|
|
124
|
+
}
|
|
125
|
+
function calledToolName(parsedBody) {
|
|
126
|
+
const messages = Array.isArray(parsedBody) ? parsedBody : parsedBody ? [parsedBody] : [];
|
|
127
|
+
for (const message of messages) {
|
|
128
|
+
if (!message || typeof message !== "object")
|
|
129
|
+
continue;
|
|
130
|
+
const candidate = message;
|
|
131
|
+
if (candidate.method === "tools/call" && typeof candidate.params?.name === "string")
|
|
132
|
+
return { name: candidate.params.name, id: candidate.id ?? null };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function sameScopes(left, right) {
|
|
137
|
+
return left.length === right.length && left.every((scope) => right.includes(scope));
|
|
138
|
+
}
|
|
139
|
+
function tokenMatchesSession(_context, token, sessionToken) {
|
|
140
|
+
return (sessionToken.grantId === token.grantId &&
|
|
141
|
+
sessionToken.clientId === token.clientId &&
|
|
142
|
+
sessionToken.workspaceId === token.workspaceId &&
|
|
143
|
+
sessionToken.userId === token.userId &&
|
|
144
|
+
sessionToken.sourceKind === token.sourceKind &&
|
|
145
|
+
sameScopes(sessionToken.scopes, token.scopes));
|
|
146
|
+
}
|
|
147
|
+
async function authenticateMcpRequest(c, config, adapters) {
|
|
148
|
+
const rawToken = parseBearerToken(c.req.raw);
|
|
149
|
+
if (!rawToken)
|
|
150
|
+
return bearerUnauthorized(config);
|
|
151
|
+
const token = await adapters.validateOAuthToken({
|
|
152
|
+
tokenHash: hashMcpToken(rawToken),
|
|
153
|
+
kind: "access",
|
|
154
|
+
resource: config.resourceUri,
|
|
155
|
+
recordLastUsed: true
|
|
156
|
+
});
|
|
157
|
+
if (!token)
|
|
158
|
+
return bearerUnauthorized(config, "Bearer token is expired, revoked, malformed, or issued for another resource");
|
|
159
|
+
return { rawToken, token };
|
|
160
|
+
}
|
|
161
|
+
function authInfo(rawToken, token, config) {
|
|
162
|
+
return {
|
|
163
|
+
token: rawToken,
|
|
164
|
+
clientId: token.clientId,
|
|
165
|
+
scopes: [...token.scopes],
|
|
166
|
+
expiresAt: Math.trunc(Date.parse(token.expiresAt) / 1000),
|
|
167
|
+
resource: new URL(config.resourceUri)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async function validateAuthorizationRequest(c, body, config, adapters) {
|
|
171
|
+
let clientId;
|
|
172
|
+
try {
|
|
173
|
+
clientId = clientIdFromRequest(c, body);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return oauthError("invalid_request", "client_id is required");
|
|
177
|
+
}
|
|
178
|
+
const client = await adapters.getOAuthClient(clientId);
|
|
179
|
+
if (!client)
|
|
180
|
+
return oauthError("invalid_client", "OAuth client not found", 401);
|
|
181
|
+
let redirectUri;
|
|
182
|
+
try {
|
|
183
|
+
redirectUri = validateRedirectUri(client.redirectUris, queryOrBody(c, body, "redirect_uri"));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return oauthError("invalid_request", "redirect_uri is invalid for this OAuth client");
|
|
187
|
+
}
|
|
188
|
+
const state = queryOrBody(c, body, "state");
|
|
189
|
+
if (queryOrBody(c, body, "response_type") !== "code")
|
|
190
|
+
return authorizationRequestError(redirectUri, "unsupported_response_type", "Only response_type=code is supported", state);
|
|
191
|
+
let resource;
|
|
192
|
+
try {
|
|
193
|
+
resource = requestedResource(c, body, config);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return authorizationRequestError(redirectUri, "invalid_request", "resource is invalid for this MCP server", state);
|
|
197
|
+
}
|
|
198
|
+
const codeChallenge = queryOrBody(c, body, "code_challenge");
|
|
199
|
+
const codeChallengeMethod = queryOrBody(c, body, "code_challenge_method");
|
|
200
|
+
if (!codeChallenge || codeChallengeMethod !== "S256")
|
|
201
|
+
return authorizationRequestError(redirectUri, "invalid_request", "PKCE S256 code challenge is required", state);
|
|
202
|
+
const rawScope = queryOrBody(c, body, "scope");
|
|
203
|
+
const allowedScopes = client.scopes.length > 0 ? client.scopes : config.supportedScopes;
|
|
204
|
+
if (includesUnsupportedMcpScope(rawScope, allowedScopes))
|
|
205
|
+
return authorizationRequestError(redirectUri, "invalid_scope", "Requested scope is not supported for this OAuth client", state);
|
|
206
|
+
const scopes = requestedMcpScopes(rawScope, allowedScopes, config.defaultScopes);
|
|
207
|
+
try {
|
|
208
|
+
const identity = await adapters.resolveConsentIdentity({
|
|
209
|
+
headers: c.req.raw.headers,
|
|
210
|
+
workspaceId: queryOrBody(c, body, "workspace_id"),
|
|
211
|
+
userId: queryOrBody(c, body, "user_id")
|
|
212
|
+
});
|
|
213
|
+
return { client, redirectUri, state, resource, codeChallenge, scopes, workspaceId: identity.workspaceId, userId: identity.userId };
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
return authorizationRequestError(redirectUri, "invalid_request", error instanceof Error ? error.message : "Workspace could not be resolved", state);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function handleMcpRequest(c, options, activeSessions, sessionTokens) {
|
|
220
|
+
const config = options.resolveConfig(c.req.url);
|
|
221
|
+
const envelope = validateMcpHttpEnvelope({
|
|
222
|
+
url: new URL(c.req.url),
|
|
223
|
+
method: c.req.method,
|
|
224
|
+
headers: c.req.raw.headers,
|
|
225
|
+
requireHttps: config.requireHttps,
|
|
226
|
+
allowedHosts: config.allowedHosts,
|
|
227
|
+
allowedOrigins: config.allowedOrigins,
|
|
228
|
+
supportedProtocolVersions: config.supportedProtocolVersions,
|
|
229
|
+
forwardedProto: forwardedProto(c)
|
|
230
|
+
});
|
|
231
|
+
if (!envelope.ok) {
|
|
232
|
+
if (envelope.reason === "query_token")
|
|
233
|
+
return bearerUnauthorized(config, envelope.message);
|
|
234
|
+
return jsonRpcErrorResponse(envelope.status, ErrorCode.InvalidRequest, envelope.message);
|
|
235
|
+
}
|
|
236
|
+
const auth = await authenticateMcpRequest(c, config, options.adapters);
|
|
237
|
+
if (auth instanceof Response)
|
|
238
|
+
return auth;
|
|
239
|
+
const parsed = await parseMcpBody(c.req.raw);
|
|
240
|
+
const sessionId = c.req.header("mcp-session-id") ?? undefined;
|
|
241
|
+
if (!parsed?.isInitialize && !sessionId) {
|
|
242
|
+
return jsonRpcErrorResponse(400, ErrorCode.InvalidRequest, "Mcp-Session-Id header is required after initialization", parsed?.id ?? null);
|
|
243
|
+
}
|
|
244
|
+
const toolCall = calledToolName(parsed?.body);
|
|
245
|
+
if (toolCall && options.adapters.getToolByName) {
|
|
246
|
+
const tool = options.adapters.getToolByName(toolCall.name);
|
|
247
|
+
if (tool && !hasRequiredMcpScopes(auth.token.scopes, tool.requiredScopes)) {
|
|
248
|
+
await options.adapters.recordMcpAuditEvent({
|
|
249
|
+
workspaceId: auth.token.workspaceId,
|
|
250
|
+
userId: auth.token.userId,
|
|
251
|
+
clientId: auth.token.clientId,
|
|
252
|
+
accessTokenId: auth.token.id,
|
|
253
|
+
sourceKind: auth.token.sourceKind,
|
|
254
|
+
activityKind: tool.annotations?.readOnlyHint ? "read" : "write",
|
|
255
|
+
toolName: tool.name,
|
|
256
|
+
outcome: "rejected",
|
|
257
|
+
metadata: { reason: "insufficient_scope", requiredScopes: tool.requiredScopes }
|
|
258
|
+
});
|
|
259
|
+
return jsonRpcErrorResponse(403, ErrorCode.InvalidRequest, `insufficient_scope: requires ${tool.requiredScopes.join(" ")}`, toolCall.id);
|
|
260
|
+
}
|
|
261
|
+
if (tool?.annotations?.readOnlyHint) {
|
|
262
|
+
await options.adapters.recordMcpAuditEvent({
|
|
263
|
+
workspaceId: auth.token.workspaceId,
|
|
264
|
+
userId: auth.token.userId,
|
|
265
|
+
clientId: auth.token.clientId,
|
|
266
|
+
accessTokenId: auth.token.id,
|
|
267
|
+
sourceKind: auth.token.sourceKind,
|
|
268
|
+
activityKind: "read",
|
|
269
|
+
toolName: tool.name,
|
|
270
|
+
outcome: "matched",
|
|
271
|
+
metadata: { method: "tools/call" }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (sessionId) {
|
|
276
|
+
const active = activeSessions.get(sessionId);
|
|
277
|
+
const sessionToken = sessionTokens.get(sessionId);
|
|
278
|
+
if (!active || !sessionToken)
|
|
279
|
+
return jsonRpcErrorResponse(404, ErrorCode.InvalidRequest, "MCP session not found", parsed?.id ?? null);
|
|
280
|
+
if (!tokenMatchesSession(active.context, auth.token, sessionToken))
|
|
281
|
+
return bearerUnauthorized(config, "Bearer token does not match this MCP session");
|
|
282
|
+
const refreshed = options.adapters.refreshMcpContext?.(active.context, auth.token);
|
|
283
|
+
if (refreshed)
|
|
284
|
+
active.context = refreshed;
|
|
285
|
+
sessionTokens.set(sessionId, auth.token);
|
|
286
|
+
await options.adapters.touchMcpSession(sessionId, { accessTokenId: auth.token.id, scopes: [...auth.token.scopes] });
|
|
287
|
+
const response = await active.transport.handleRequest(c.req.raw, { authInfo: authInfo(auth.rawToken, auth.token, config) });
|
|
288
|
+
if (c.req.raw.method === "DELETE") {
|
|
289
|
+
activeSessions.delete(sessionId);
|
|
290
|
+
sessionTokens.delete(sessionId);
|
|
291
|
+
await options.adapters.closeMcpSession(sessionId);
|
|
292
|
+
}
|
|
293
|
+
return response;
|
|
294
|
+
}
|
|
295
|
+
const context = options.adapters.createMcpContext({ token: auth.token, config });
|
|
296
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
297
|
+
sessionIdGenerator: options.sessionIdGenerator,
|
|
298
|
+
enableJsonResponse: true,
|
|
299
|
+
onsessioninitialized: (newSessionId) => {
|
|
300
|
+
void options.adapters.trackMcpSession({
|
|
301
|
+
id: newSessionId,
|
|
302
|
+
accessTokenId: auth.token.id,
|
|
303
|
+
clientId: auth.token.clientId,
|
|
304
|
+
workspaceId: auth.token.workspaceId,
|
|
305
|
+
userId: auth.token.userId,
|
|
306
|
+
scopes: [...auth.token.scopes],
|
|
307
|
+
protocolVersion: c.req.header("mcp-protocol-version") ?? config.latestProtocolVersion
|
|
308
|
+
});
|
|
309
|
+
activeSessions.set(newSessionId, { transport, context });
|
|
310
|
+
sessionTokens.set(newSessionId, auth.token);
|
|
311
|
+
},
|
|
312
|
+
onsessionclosed: (closedSessionId) => {
|
|
313
|
+
activeSessions.delete(closedSessionId);
|
|
314
|
+
sessionTokens.delete(closedSessionId);
|
|
315
|
+
void options.adapters.closeMcpSession(closedSessionId);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
const server = options.adapters.createMcpServer(context);
|
|
319
|
+
await server.connect(transport);
|
|
320
|
+
return transport.handleRequest(c.req.raw, { authInfo: authInfo(auth.rawToken, auth.token, config) });
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Register OAuth discovery, dynamic client registration, authorization-code
|
|
324
|
+
* flow, token revocation, and Streamable HTTP MCP routes on a Hono app.
|
|
325
|
+
*
|
|
326
|
+
* The function is intentionally adapter-driven: it does not import product
|
|
327
|
+
* repositories, auth systems, or tool handlers. Consumers mount these routes
|
|
328
|
+
* inside their own API surface and supply product-specific adapters.
|
|
329
|
+
*/
|
|
330
|
+
export function registerMcpHonoRoutes(app, options) {
|
|
331
|
+
const activeSessions = new Map();
|
|
332
|
+
const sessionTokens = new Map();
|
|
333
|
+
app.get(options.mounts.protectedResourceMetadata, (c) => {
|
|
334
|
+
const config = options.resolveConfig(c.req.url);
|
|
335
|
+
return configuredResponse(buildMcpProtectedResourceMetadata({
|
|
336
|
+
appBaseUrl: config.appBaseUrl,
|
|
337
|
+
resourceUri: config.resourceUri,
|
|
338
|
+
supportedScopes: config.supportedScopes,
|
|
339
|
+
routes: config.routes
|
|
340
|
+
}));
|
|
341
|
+
});
|
|
342
|
+
app.get(`${options.mounts.protectedResourceMetadata}/*`, (c) => {
|
|
343
|
+
const config = options.resolveConfig(c.req.url);
|
|
344
|
+
return configuredResponse(buildMcpProtectedResourceMetadata({
|
|
345
|
+
appBaseUrl: config.appBaseUrl,
|
|
346
|
+
resourceUri: config.resourceUri,
|
|
347
|
+
supportedScopes: config.supportedScopes,
|
|
348
|
+
routes: config.routes
|
|
349
|
+
}));
|
|
350
|
+
});
|
|
351
|
+
app.get(options.mounts.authorizationServerMetadata, (c) => {
|
|
352
|
+
const config = options.resolveConfig(c.req.url);
|
|
353
|
+
return configuredResponse(buildMcpAuthorizationServerMetadata({
|
|
354
|
+
appBaseUrl: config.appBaseUrl,
|
|
355
|
+
resourceUri: config.resourceUri,
|
|
356
|
+
supportedScopes: config.supportedScopes,
|
|
357
|
+
routes: config.routes
|
|
358
|
+
}));
|
|
359
|
+
});
|
|
360
|
+
app.get(options.mounts.authorize, async (c) => {
|
|
361
|
+
const config = options.resolveConfig(c.req.url);
|
|
362
|
+
const request = await validateAuthorizationRequest(c, {}, config, options.adapters);
|
|
363
|
+
if (request instanceof Response)
|
|
364
|
+
return request;
|
|
365
|
+
return consentRedirect(config, request);
|
|
366
|
+
});
|
|
367
|
+
app.post(options.mounts.authorize, async (c) => {
|
|
368
|
+
const config = options.resolveConfig(c.req.url);
|
|
369
|
+
const body = await readBody(c);
|
|
370
|
+
const request = await validateAuthorizationRequest(c, body, config, options.adapters);
|
|
371
|
+
if (request instanceof Response)
|
|
372
|
+
return request;
|
|
373
|
+
const consentAction = queryOrBody(c, body, "consent_action") ?? queryOrBody(c, body, "decision") ?? queryOrBody(c, body, "action");
|
|
374
|
+
if (consentAction === "cancel" || consentAction === "deny")
|
|
375
|
+
return redirectWithError(request.redirectUri, "access_denied", request.state);
|
|
376
|
+
if (consentAction !== "approve")
|
|
377
|
+
return consentRedirect(config, request);
|
|
378
|
+
const rawCode = randomMcpToken(config.tokenPrefixes?.authorizationCode ?? "mcp_code");
|
|
379
|
+
await options.adapters.createOAuthAuthorizationCode({
|
|
380
|
+
codeHash: hashMcpToken(rawCode),
|
|
381
|
+
clientId: request.client.id,
|
|
382
|
+
redirectUri: request.redirectUri,
|
|
383
|
+
workspaceId: request.workspaceId,
|
|
384
|
+
userId: request.userId,
|
|
385
|
+
scopes: request.scopes,
|
|
386
|
+
resource: request.resource,
|
|
387
|
+
codeChallenge: request.codeChallenge,
|
|
388
|
+
codeChallengeMethod: "S256",
|
|
389
|
+
expiresAt: addSeconds(new Date(), config.authorizationCodeTtlSeconds)
|
|
390
|
+
});
|
|
391
|
+
const redirect = new URL(request.redirectUri);
|
|
392
|
+
redirect.searchParams.set("code", rawCode);
|
|
393
|
+
if (request.state)
|
|
394
|
+
redirect.searchParams.set("state", request.state);
|
|
395
|
+
return Response.redirect(redirect, 302);
|
|
396
|
+
});
|
|
397
|
+
app.post(options.mounts.registration, async (c) => {
|
|
398
|
+
const config = options.resolveConfig(c.req.url);
|
|
399
|
+
const body = await readBody(c);
|
|
400
|
+
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((uri) => typeof uri === "string") : [];
|
|
401
|
+
if (redirectUris.length === 0)
|
|
402
|
+
return oauthError("invalid_client_metadata", "redirect_uris is required");
|
|
403
|
+
const rawScope = typeof body.scope === "string" ? body.scope : undefined;
|
|
404
|
+
const rawScopes = Array.isArray(body.scopes) ? body.scopes.filter((scope) => typeof scope === "string") : undefined;
|
|
405
|
+
const scopes = normalizeMcpScopes(rawScopes ?? rawScope, config.supportedScopes);
|
|
406
|
+
const client = await options.adapters.registerOAuthClient({
|
|
407
|
+
name: typeof body.client_name === "string" ? body.client_name : "MCP client",
|
|
408
|
+
displayName: typeof body.client_name === "string" ? body.client_name : undefined,
|
|
409
|
+
redirectUris,
|
|
410
|
+
scopes: scopes.length > 0 ? scopes : [...config.supportedScopes]
|
|
411
|
+
});
|
|
412
|
+
return json({
|
|
413
|
+
client_id: client.id,
|
|
414
|
+
client_name: client.name,
|
|
415
|
+
redirect_uris: client.redirectUris,
|
|
416
|
+
scope: formatMcpScopeString(client.scopes),
|
|
417
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
418
|
+
response_types: ["code"],
|
|
419
|
+
token_endpoint_auth_method: "none",
|
|
420
|
+
client_id_issued_at: Math.trunc(Date.parse(client.createdAt) / 1000)
|
|
421
|
+
}, 201, { "cache-control": "no-store" });
|
|
422
|
+
});
|
|
423
|
+
app.post(options.mounts.token, async (c) => {
|
|
424
|
+
const config = options.resolveConfig(c.req.url);
|
|
425
|
+
const body = await readBody(c);
|
|
426
|
+
const grantType = body.grant_type;
|
|
427
|
+
const clientId = clientIdFromRequest(c, body);
|
|
428
|
+
const client = await options.adapters.getOAuthClient(clientId);
|
|
429
|
+
if (!client)
|
|
430
|
+
return oauthError("invalid_client", "OAuth client not found", 401);
|
|
431
|
+
if (grantType === "authorization_code") {
|
|
432
|
+
const redirectUri = validateRedirectUri(client.redirectUris, queryOrBody(c, body, "redirect_uri"));
|
|
433
|
+
const resource = requestedResource(c, body, config);
|
|
434
|
+
const code = queryOrBody(c, body, "code");
|
|
435
|
+
const codeVerifier = queryOrBody(c, body, "code_verifier");
|
|
436
|
+
if (!code || !codeVerifier)
|
|
437
|
+
return oauthError("invalid_request", "code and code_verifier are required");
|
|
438
|
+
const grant = await options.adapters.consumeOAuthAuthorizationCode({ codeHash: hashMcpToken(code), clientId, redirectUri, resource });
|
|
439
|
+
if (!grant)
|
|
440
|
+
return oauthError("invalid_grant", "Authorization code is expired, reused, or invalid");
|
|
441
|
+
if (!verifyPkceS256(codeVerifier, grant.codeChallenge))
|
|
442
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
443
|
+
if (!(await options.adapters.hasWorkspaceMembership({ workspaceId: grant.workspaceId, userId: grant.userId })))
|
|
444
|
+
return oauthError("invalid_grant", "Workspace membership required");
|
|
445
|
+
const accessToken = randomMcpToken(config.tokenPrefixes?.accessToken ?? "mcp_at");
|
|
446
|
+
const refreshToken = randomMcpToken(config.tokenPrefixes?.refreshToken ?? "mcp_rt");
|
|
447
|
+
await options.adapters.issueOAuthToken({
|
|
448
|
+
tokenHash: hashMcpToken(accessToken),
|
|
449
|
+
kind: "access",
|
|
450
|
+
grantId: grant.id,
|
|
451
|
+
clientId,
|
|
452
|
+
workspaceId: grant.workspaceId,
|
|
453
|
+
userId: grant.userId,
|
|
454
|
+
scopes: [...grant.scopes],
|
|
455
|
+
resource,
|
|
456
|
+
expiresAt: addSeconds(new Date(), config.accessTokenTtlSeconds)
|
|
457
|
+
});
|
|
458
|
+
await options.adapters.issueOAuthToken({
|
|
459
|
+
tokenHash: hashMcpToken(refreshToken),
|
|
460
|
+
kind: "refresh",
|
|
461
|
+
grantId: grant.id,
|
|
462
|
+
clientId,
|
|
463
|
+
workspaceId: grant.workspaceId,
|
|
464
|
+
userId: grant.userId,
|
|
465
|
+
scopes: [...grant.scopes],
|
|
466
|
+
resource,
|
|
467
|
+
expiresAt: addSeconds(new Date(), config.refreshTokenTtlSeconds)
|
|
468
|
+
});
|
|
469
|
+
return tokenResponse({
|
|
470
|
+
access_token: accessToken,
|
|
471
|
+
token_type: "Bearer",
|
|
472
|
+
expires_in: config.accessTokenTtlSeconds,
|
|
473
|
+
refresh_token: refreshToken,
|
|
474
|
+
scope: formatMcpScopeString(grant.scopes),
|
|
475
|
+
resource
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (grantType === "refresh_token") {
|
|
479
|
+
const resource = requestedResource(c, body, config);
|
|
480
|
+
const refreshToken = queryOrBody(c, body, "refresh_token");
|
|
481
|
+
if (!refreshToken)
|
|
482
|
+
return oauthError("invalid_request", "refresh_token is required");
|
|
483
|
+
const tokenRecord = await options.adapters.validateOAuthToken({ tokenHash: hashMcpToken(refreshToken), kind: "refresh", resource });
|
|
484
|
+
if (!tokenRecord || tokenRecord.clientId !== clientId)
|
|
485
|
+
return oauthError("invalid_grant", "Refresh token is expired, revoked, or invalid");
|
|
486
|
+
const accessToken = randomMcpToken(config.tokenPrefixes?.accessToken ?? "mcp_at");
|
|
487
|
+
await options.adapters.issueOAuthToken({
|
|
488
|
+
tokenHash: hashMcpToken(accessToken),
|
|
489
|
+
kind: "access",
|
|
490
|
+
grantId: tokenRecord.grantId,
|
|
491
|
+
clientId,
|
|
492
|
+
workspaceId: tokenRecord.workspaceId,
|
|
493
|
+
userId: tokenRecord.userId,
|
|
494
|
+
scopes: [...tokenRecord.scopes],
|
|
495
|
+
resource,
|
|
496
|
+
expiresAt: addSeconds(new Date(), config.accessTokenTtlSeconds)
|
|
497
|
+
});
|
|
498
|
+
return tokenResponse({
|
|
499
|
+
access_token: accessToken,
|
|
500
|
+
token_type: "Bearer",
|
|
501
|
+
expires_in: config.accessTokenTtlSeconds,
|
|
502
|
+
scope: formatMcpScopeString(tokenRecord.scopes),
|
|
503
|
+
resource
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return oauthError("unsupported_grant_type", "Only authorization_code and refresh_token grants are supported");
|
|
507
|
+
});
|
|
508
|
+
app.post(options.mounts.revocation, async (c) => {
|
|
509
|
+
const config = options.resolveConfig(c.req.url);
|
|
510
|
+
const body = await readBody(c);
|
|
511
|
+
const tokenValue = queryOrBody(c, body, "token");
|
|
512
|
+
if (!tokenValue)
|
|
513
|
+
return new Response(null, { status: 200 });
|
|
514
|
+
const tokenHash = hashMcpToken(tokenValue);
|
|
515
|
+
const tokenRecord = await options.adapters.getOAuthTokenByHash({ tokenHash, resource: config.resourceUri });
|
|
516
|
+
const clientId = queryOrBody(c, body, "client_id");
|
|
517
|
+
if (tokenRecord && clientId && tokenRecord.clientId !== clientId)
|
|
518
|
+
return oauthError("invalid_client", "client_id does not match token owner", 401);
|
|
519
|
+
if (tokenRecord && (await options.adapters.revokeOAuthToken(tokenHash, { revokedByUserId: tokenRecord.userId, revokedReason: "oauth_revocation_endpoint" }))) {
|
|
520
|
+
await options.adapters.recordMcpAuditEvent({
|
|
521
|
+
workspaceId: tokenRecord.workspaceId,
|
|
522
|
+
userId: tokenRecord.userId,
|
|
523
|
+
clientId: tokenRecord.clientId,
|
|
524
|
+
accessTokenId: tokenRecord.kind === "access" ? tokenRecord.id : null,
|
|
525
|
+
sourceKind: tokenRecord.sourceKind,
|
|
526
|
+
activityKind: "management",
|
|
527
|
+
toolName: "oauth.revoke",
|
|
528
|
+
outcome: "revoked",
|
|
529
|
+
metadata: { tokenKind: tokenRecord.kind, grantId: tokenRecord.grantId, tokenName: tokenRecord.displayName }
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return new Response(null, { status: 200 });
|
|
533
|
+
});
|
|
534
|
+
app.all(options.mounts.mcp, async (c) => handleMcpRequest(c, options, activeSessions, sessionTokens));
|
|
535
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core/index.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { McpHumanIdentityContext, McpUser, McpWorkspace, McpWorkspaceMembership } from "../core/types.js";
|
|
2
|
+
/** Package-local, BetterAuth-compatible user shape consumed by MCP identity helpers. */
|
|
3
|
+
export interface BetterAuthUserLike {
|
|
4
|
+
id: string;
|
|
5
|
+
email?: string | null;
|
|
6
|
+
emailVerified?: boolean | null;
|
|
7
|
+
name?: string | null;
|
|
8
|
+
displayName?: string | null;
|
|
9
|
+
image?: string | null;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** Package-local, BetterAuth-compatible session shape used for fresh-auth checks. */
|
|
13
|
+
export interface BetterAuthSessionLike {
|
|
14
|
+
id?: string;
|
|
15
|
+
userId?: string;
|
|
16
|
+
createdAt?: Date | string | null;
|
|
17
|
+
updatedAt?: Date | string | null;
|
|
18
|
+
expiresAt?: Date | string | null;
|
|
19
|
+
fresh?: boolean | null;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
/** Package-local shape returned by a product's BetterAuth session resolver adapter. */
|
|
23
|
+
export interface BetterAuthSessionResultLike {
|
|
24
|
+
user: BetterAuthUserLike;
|
|
25
|
+
session?: BetterAuthSessionLike | null;
|
|
26
|
+
}
|
|
27
|
+
/** Package-local JWT/OIDC claims shape used for app-token identity normalization. */
|
|
28
|
+
export interface BetterAuthClaimsLike {
|
|
29
|
+
iss?: string;
|
|
30
|
+
sub?: string;
|
|
31
|
+
aud?: string | string[];
|
|
32
|
+
email?: string;
|
|
33
|
+
email_verified?: boolean;
|
|
34
|
+
name?: string;
|
|
35
|
+
picture?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
/** Normalized MCP human identity resolved from product-adapted BetterAuth session or JWT data. */
|
|
39
|
+
export interface BetterAuthIdentityAdapterResult {
|
|
40
|
+
user: McpUser;
|
|
41
|
+
sessionFresh: boolean;
|
|
42
|
+
rawSession?: BetterAuthSessionLike | null;
|
|
43
|
+
rawClaims?: BetterAuthClaimsLike | null;
|
|
44
|
+
}
|
|
45
|
+
/** Product function that resolves a BetterAuth session from request headers. */
|
|
46
|
+
export type BetterAuthSessionResolver = (headers: Headers) => Promise<BetterAuthSessionResultLike | null> | BetterAuthSessionResultLike | null;
|
|
47
|
+
/** Product function that verifies an app JWT and returns claims. */
|
|
48
|
+
export type BetterAuthJwtVerifier = (token: string) => Promise<BetterAuthClaimsLike | null> | BetterAuthClaimsLike | null;
|
|
49
|
+
/**
|
|
50
|
+
* Optional BetterAuth integration hooks for resolving the human app user.
|
|
51
|
+
*
|
|
52
|
+
* BetterAuth proves the signed-in human for consent or management actions. MCP
|
|
53
|
+
* client authorization still uses separate OAuth/developer tokens.
|
|
54
|
+
*/
|
|
55
|
+
export interface BetterAuthMcpIdentityAdapter {
|
|
56
|
+
resolveSession?: BetterAuthSessionResolver;
|
|
57
|
+
verifyJwt?: BetterAuthJwtVerifier;
|
|
58
|
+
providerKey?: string;
|
|
59
|
+
issuer?: string;
|
|
60
|
+
freshSessionMaxAgeMs?: number;
|
|
61
|
+
}
|
|
62
|
+
/** Return whether a BetterAuth session satisfies a fresh-auth age window. */
|
|
63
|
+
export declare function isFreshBetterAuthSession(session: BetterAuthSessionLike | null | undefined, maxAgeMs?: number, now?: number): boolean;
|
|
64
|
+
/** Normalize BetterAuth session data into the shared MCP user shape. */
|
|
65
|
+
export declare function normalizeBetterAuthSessionIdentity(input: {
|
|
66
|
+
result: BetterAuthSessionResultLike;
|
|
67
|
+
providerKey?: string;
|
|
68
|
+
issuer?: string;
|
|
69
|
+
freshSessionMaxAgeMs?: number;
|
|
70
|
+
}): BetterAuthIdentityAdapterResult;
|
|
71
|
+
/** Normalize verified JWT claims into the shared MCP user shape. */
|
|
72
|
+
export declare function normalizeBetterAuthJwtIdentity(input: {
|
|
73
|
+
claims: BetterAuthClaimsLike;
|
|
74
|
+
providerKey?: string;
|
|
75
|
+
issuer?: string;
|
|
76
|
+
}): BetterAuthIdentityAdapterResult;
|
|
77
|
+
/** Resolve human identity from a BetterAuth session first, then optional JWT. */
|
|
78
|
+
export declare function resolveBetterAuthMcpIdentity(headers: Headers, adapter: BetterAuthMcpIdentityAdapter): Promise<BetterAuthIdentityAdapterResult | null>;
|
|
79
|
+
/** Combine normalized identity with product workspace membership context. */
|
|
80
|
+
export declare function createMcpHumanIdentityContext(input: {
|
|
81
|
+
identity: BetterAuthIdentityAdapterResult;
|
|
82
|
+
workspace: McpWorkspace;
|
|
83
|
+
membership: McpWorkspaceMembership;
|
|
84
|
+
}): McpHumanIdentityContext;
|