@decocms/runtime 1.0.0-alpha.22 → 1.0.0-alpha.24

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/package.json CHANGED
@@ -1,24 +1,22 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.22",
3
+ "version": "1.0.0-alpha.24",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@cloudflare/workers-types": "^4.20250617.0",
7
7
  "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5",
8
- "@decocms/bindings": "1.0.1-alpha.12",
8
+ "@decocms/bindings": "1.0.1-alpha.14",
9
9
  "@modelcontextprotocol/sdk": "1.20.2",
10
10
  "@ai-sdk/provider": "^2.0.0",
11
11
  "hono": "^4.10.7",
12
12
  "jose": "^6.0.11",
13
13
  "zod": "^3.25.76",
14
- "zod-from-json-schema": "^0.0.5",
15
14
  "zod-to-json-schema": "3.25.0"
16
15
  },
17
16
  "exports": {
18
17
  ".": "./src/index.ts",
19
18
  "./proxy": "./src/proxy.ts",
20
19
  "./client": "./src/client.ts",
21
- "./mcp-client": "./src/mcp-client.ts",
22
20
  "./bindings": "./src/bindings/index.ts",
23
21
  "./asset-server": "./src/asset-server/index.ts",
24
22
  "./tools": "./src/tools.ts"
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { decodeJwt } from "jose";
3
3
  import type { z } from "zod";
4
4
  import { createContractBinding, createIntegrationBinding } from "./bindings.ts";
5
5
  import { type CORSOptions, handlePreflight, withCORS } from "./cors.ts";
6
+ import { createOAuthHandlers } from "./oauth.ts";
6
7
  import { State } from "./state.ts";
7
8
  import {
8
9
  createMCPServer,
@@ -82,6 +83,7 @@ export interface RequestContext<TSchema extends z.ZodTypeAny = any> {
82
83
  state: z.infer<TSchema>;
83
84
  token: string;
84
85
  meshUrl: string;
86
+ authorization?: string | null;
85
87
  ensureAuthenticated: (options?: {
86
88
  workspaceHint?: string;
87
89
  }) => User | undefined;
@@ -158,14 +160,19 @@ export const withBindings = <TEnv>({
158
160
  tokenOrContext,
159
161
  url,
160
162
  bindings: inlineBindings,
163
+ authToken,
161
164
  }: {
162
165
  env: TEnv;
163
166
  server: MCPServer<TEnv, any>;
167
+ // token is x-mesh-token
164
168
  tokenOrContext?: string | RequestContext;
169
+ // authToken is the authorization header
170
+ authToken?: string | null;
165
171
  url?: string;
166
172
  bindings?: Binding[];
167
173
  }): TEnv => {
168
174
  const env = _env as DefaultEnv<any>;
175
+ const authorization = authToken ? authToken.split(" ")[1] : undefined;
169
176
 
170
177
  let context;
171
178
  if (typeof tokenOrContext === "string") {
@@ -179,6 +186,7 @@ export const withBindings = <TEnv>({
179
186
  }) ?? {};
180
187
 
181
188
  context = {
189
+ authorization,
182
190
  state: decoded.state ?? metadata.state,
183
191
  token: tokenOrContext,
184
192
  meshUrl: (decoded.meshUrl as string) ?? metadata.meshUrl,
@@ -196,6 +204,7 @@ export const withBindings = <TEnv>({
196
204
  connectionId?: string;
197
205
  }) ?? {};
198
206
  const appName = decoded.appName as string | undefined;
207
+ context.authorization ??= authorization;
199
208
  context.callerApp = appName;
200
209
  context.connectionId ??=
201
210
  (decoded.connectionId as string) ?? metadata.connectionId;
@@ -203,6 +212,7 @@ export const withBindings = <TEnv>({
203
212
  } else {
204
213
  context = {
205
214
  state: {},
215
+ authorization,
206
216
  token: undefined,
207
217
  meshUrl: undefined,
208
218
  connectionId: undefined,
@@ -233,6 +243,8 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
233
243
  ) => {
234
244
  const server = createMCPServer<TEnv, TSchema>(userFns);
235
245
  const corsOptions = userFns.cors;
246
+ const oauth = userFns.oauth;
247
+ const oauthHandlers = oauth ? createOAuthHandlers(oauth) : null;
236
248
 
237
249
  const fetcher = async (
238
250
  req: Request,
@@ -240,7 +252,53 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
240
252
  ctx: any,
241
253
  ) => {
242
254
  const url = new URL(req.url);
255
+
256
+ // OAuth routes (when configured)
257
+ if (oauthHandlers) {
258
+ // Protected resource metadata (RFC9728) - both paths MUST be supported
259
+ if (
260
+ url.pathname === "/.well-known/oauth-protected-resource" ||
261
+ url.pathname === "/mcp/.well-known/oauth-protected-resource"
262
+ ) {
263
+ return oauthHandlers.handleProtectedResourceMetadata(req);
264
+ }
265
+
266
+ // Authorization server metadata (RFC8414)
267
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
268
+ return oauthHandlers.handleAuthorizationServerMetadata(req);
269
+ }
270
+
271
+ // Authorization endpoint - redirects to external OAuth provider
272
+ if (url.pathname === "/authorize") {
273
+ return oauthHandlers.handleAuthorize(req);
274
+ }
275
+
276
+ // OAuth callback - receives code from external OAuth provider
277
+ if (url.pathname === "/oauth/callback") {
278
+ return oauthHandlers.handleOAuthCallback(req);
279
+ }
280
+
281
+ // Token endpoint - exchanges code for tokens
282
+ if (url.pathname === "/token" && req.method === "POST") {
283
+ return oauthHandlers.handleToken(req);
284
+ }
285
+
286
+ // Dynamic client registration (RFC7591)
287
+ if (
288
+ (url.pathname === "/register" || url.pathname === "/mcp/register") &&
289
+ req.method === "POST"
290
+ ) {
291
+ return oauthHandlers.handleClientRegistration(req);
292
+ }
293
+ }
294
+
295
+ // MCP endpoint
243
296
  if (url.pathname === "/mcp") {
297
+ // If OAuth is configured, require authentication
298
+ if (oauthHandlers && !oauthHandlers.hasAuth(req)) {
299
+ return oauthHandlers.createUnauthorizedResponse(req);
300
+ }
301
+
244
302
  return server.fetch(req, env, ctx);
245
303
  }
246
304
 
@@ -273,7 +331,7 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
273
331
  };
274
332
 
275
333
  return {
276
- fetch: async (req: Request, env: TEnv & DefaultEnv<TSchema>, ctx: any) => {
334
+ fetch: async (req: Request, env: TEnv & DefaultEnv<TSchema>, ctx?: any) => {
277
335
  // Handle CORS preflight (OPTIONS) requests
278
336
  if (corsOptions !== false && req.method === "OPTIONS") {
279
337
  const options = corsOptions ?? {};
@@ -281,6 +339,7 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
281
339
  }
282
340
 
283
341
  const bindings = withBindings({
342
+ authToken: req.headers.get("authorization") ?? null,
284
343
  env,
285
344
  server,
286
345
  bindings: userFns.bindings,
package/src/oauth.ts ADDED
@@ -0,0 +1,490 @@
1
+ import type { OAuthClient, OAuthConfig, OAuthParams } from "./tools.ts";
2
+
3
+ /**
4
+ * Generate a cryptographically secure random token
5
+ */
6
+ function generateRandomToken(length = 32): string {
7
+ const chars =
8
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
9
+ const array = new Uint8Array(length);
10
+ crypto.getRandomValues(array);
11
+ return Array.from(array, (byte) => chars[byte % chars.length]).join("");
12
+ }
13
+
14
+ /**
15
+ * Validate redirect URI format per OAuth 2.1
16
+ */
17
+ function isValidRedirectUri(uri: string): boolean {
18
+ try {
19
+ const url = new URL(uri);
20
+ return (
21
+ url.protocol === "https:" ||
22
+ url.hostname === "localhost" ||
23
+ url.hostname === "127.0.0.1" ||
24
+ // Allow custom schemes for native apps (e.g., cursor://, vscode://)
25
+ !url.protocol.startsWith("http")
26
+ );
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Encode data as base64url JSON
34
+ */
35
+ function encodeState<T>(data: T): string {
36
+ return btoa(JSON.stringify(data))
37
+ .replace(/\+/g, "-")
38
+ .replace(/\//g, "_")
39
+ .replace(/=+$/, "");
40
+ }
41
+
42
+ /**
43
+ * Decode base64url JSON data
44
+ */
45
+ function decodeState<T>(encoded: string): T | null {
46
+ try {
47
+ const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
48
+ return JSON.parse(atob(base64)) as T;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ interface PendingAuthState {
55
+ redirectUri: string;
56
+ clientState?: string;
57
+ codeChallenge?: string;
58
+ codeChallengeMethod?: string;
59
+ }
60
+
61
+ interface CodePayload {
62
+ accessToken: string;
63
+ tokenType: string;
64
+ codeChallenge?: string;
65
+ codeChallengeMethod?: string;
66
+ }
67
+
68
+ /**
69
+ * Create OAuth endpoint handlers for MCP servers
70
+ * The MCP server acts as an OAuth Authorization Server proxy
71
+ * Stateless implementation - no persistence required
72
+ * Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
73
+ */
74
+ export function createOAuthHandlers(oauth: OAuthConfig) {
75
+ /**
76
+ * Build OAuth 2.0 Protected Resource Metadata (RFC9728)
77
+ * Points to THIS server as the authorization server
78
+ */
79
+ const handleProtectedResourceMetadata = (req: Request): Response => {
80
+ const url = new URL(req.url);
81
+ const resourceUrl = `${url.origin}/mcp`;
82
+
83
+ return Response.json({
84
+ resource: resourceUrl,
85
+ // Point to ourselves - we are the authorization server proxy
86
+ authorization_servers: [url.origin],
87
+ scopes_supported: ["*"],
88
+ bearer_methods_supported: ["header"],
89
+ resource_signing_alg_values_supported: ["RS256", "none"],
90
+ });
91
+ };
92
+
93
+ /**
94
+ * Build OAuth 2.0 Authorization Server Metadata (RFC8414)
95
+ * Exposes our endpoints for authorization, token exchange, and registration
96
+ */
97
+ const handleAuthorizationServerMetadata = (req: Request): Response => {
98
+ const url = new URL(req.url);
99
+ const baseUrl = url.origin;
100
+
101
+ return Response.json({
102
+ issuer: baseUrl,
103
+ authorization_endpoint: `${baseUrl}/authorize`,
104
+ token_endpoint: `${baseUrl}/token`,
105
+ registration_endpoint: `${baseUrl}/register`,
106
+ scopes_supported: ["*"],
107
+ response_types_supported: ["code"],
108
+ response_modes_supported: ["query"],
109
+ grant_types_supported: ["authorization_code", "refresh_token"],
110
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
111
+ code_challenge_methods_supported: ["S256", "plain"],
112
+ });
113
+ };
114
+
115
+ /**
116
+ * Handle authorization request - redirects to external OAuth provider
117
+ * Stateless: encodes all needed info in the state parameter
118
+ */
119
+ const handleAuthorize = (req: Request): Response => {
120
+ const url = new URL(req.url);
121
+ const redirectUri = url.searchParams.get("redirect_uri");
122
+ const responseType = url.searchParams.get("response_type");
123
+ const clientState = url.searchParams.get("state");
124
+ const codeChallenge = url.searchParams.get("code_challenge");
125
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
126
+
127
+ // Validate required params
128
+ if (!redirectUri) {
129
+ return Response.json(
130
+ {
131
+ error: "invalid_request",
132
+ error_description: "redirect_uri required",
133
+ },
134
+ { status: 400 },
135
+ );
136
+ }
137
+
138
+ if (responseType !== "code") {
139
+ return Response.json(
140
+ {
141
+ error: "unsupported_response_type",
142
+ error_description: "Only 'code' is supported",
143
+ },
144
+ { status: 400 },
145
+ );
146
+ }
147
+
148
+ // Encode pending auth state
149
+ const pendingState: PendingAuthState = {
150
+ redirectUri,
151
+ clientState: clientState ?? undefined,
152
+ codeChallenge: codeChallenge ?? undefined,
153
+ codeChallengeMethod: codeChallengeMethod ?? undefined,
154
+ };
155
+ const encodedState = encodeState(pendingState);
156
+
157
+ // Build callback URL pointing to our internal callback
158
+ const callbackUrl = new URL(`${url.origin}/oauth/callback`);
159
+ callbackUrl.searchParams.set("state", encodedState);
160
+
161
+ // Get the external authorization URL from the config
162
+ const externalAuthUrl = oauth.authorizationUrl(callbackUrl.toString());
163
+
164
+ // Redirect to external OAuth provider
165
+ return Response.redirect(externalAuthUrl, 302);
166
+ };
167
+
168
+ /**
169
+ * Handle OAuth callback from external provider
170
+ * Stateless: decodes state to get redirect info, encodes token in code
171
+ */
172
+ const handleOAuthCallback = async (req: Request): Promise<Response> => {
173
+ const url = new URL(req.url);
174
+ const code = url.searchParams.get("code");
175
+ const encodedState = url.searchParams.get("state");
176
+ const error = url.searchParams.get("error");
177
+
178
+ // Decode state
179
+ const pending = encodedState
180
+ ? decodeState<PendingAuthState>(encodedState)
181
+ : null;
182
+
183
+ if (error) {
184
+ const errorDescription =
185
+ url.searchParams.get("error_description") ?? "Authorization failed";
186
+ if (pending?.redirectUri) {
187
+ const redirectUrl = new URL(pending.redirectUri);
188
+ redirectUrl.searchParams.set("error", error);
189
+ redirectUrl.searchParams.set("error_description", errorDescription);
190
+ if (pending.clientState)
191
+ redirectUrl.searchParams.set("state", pending.clientState);
192
+ return Response.redirect(redirectUrl.toString(), 302);
193
+ }
194
+ return Response.json(
195
+ { error, error_description: errorDescription },
196
+ { status: 400 },
197
+ );
198
+ }
199
+
200
+ if (!code || !pending) {
201
+ return Response.json(
202
+ {
203
+ error: "invalid_request",
204
+ error_description: "Missing code or state",
205
+ },
206
+ { status: 400 },
207
+ );
208
+ }
209
+
210
+ try {
211
+ // Exchange code with external provider
212
+ const oauthParams: OAuthParams = { code };
213
+ const tokenResponse = await oauth.exchangeCode(oauthParams);
214
+
215
+ // Encode the token in our own code (stateless)
216
+ const codePayload: CodePayload = {
217
+ accessToken: tokenResponse.access_token,
218
+ tokenType: tokenResponse.token_type,
219
+ codeChallenge: pending.codeChallenge,
220
+ codeChallengeMethod: pending.codeChallengeMethod,
221
+ };
222
+ const ourCode = encodeState(codePayload);
223
+
224
+ // Redirect back to client with our code
225
+ const redirectUrl = new URL(pending.redirectUri);
226
+ redirectUrl.searchParams.set("code", ourCode);
227
+ if (pending.clientState) {
228
+ redirectUrl.searchParams.set("state", pending.clientState);
229
+ }
230
+
231
+ return Response.redirect(redirectUrl.toString(), 302);
232
+ } catch (err) {
233
+ console.error("OAuth callback error:", err);
234
+
235
+ // Redirect back to client with error
236
+ const redirectUrl = new URL(pending.redirectUri);
237
+ redirectUrl.searchParams.set("error", "server_error");
238
+ redirectUrl.searchParams.set(
239
+ "error_description",
240
+ "Failed to exchange authorization code",
241
+ );
242
+ if (pending.clientState)
243
+ redirectUrl.searchParams.set("state", pending.clientState);
244
+
245
+ return Response.redirect(redirectUrl.toString(), 302);
246
+ }
247
+ };
248
+
249
+ /**
250
+ * Handle token exchange - decodes our code to get the actual token
251
+ * Stateless: token is encoded in the code
252
+ */
253
+ const handleToken = async (req: Request): Promise<Response> => {
254
+ try {
255
+ const contentType = req.headers.get("content-type") ?? "";
256
+ let body: Record<string, string>;
257
+
258
+ if (contentType.includes("application/x-www-form-urlencoded")) {
259
+ const formData = await req.formData();
260
+ body = Object.fromEntries(formData.entries()) as Record<string, string>;
261
+ } else {
262
+ body = await req.json();
263
+ }
264
+
265
+ const { code, code_verifier, grant_type } = body;
266
+
267
+ if (grant_type !== "authorization_code") {
268
+ return Response.json(
269
+ {
270
+ error: "unsupported_grant_type",
271
+ error_description: "Only authorization_code supported",
272
+ },
273
+ { status: 400 },
274
+ );
275
+ }
276
+
277
+ if (!code) {
278
+ return Response.json(
279
+ { error: "invalid_request", error_description: "code is required" },
280
+ { status: 400 },
281
+ );
282
+ }
283
+
284
+ // Decode the code to get the token
285
+ const payload = decodeState<CodePayload>(code);
286
+ if (!payload || !payload.accessToken) {
287
+ return Response.json(
288
+ {
289
+ error: "invalid_grant",
290
+ error_description: "Invalid or expired code",
291
+ },
292
+ { status: 400 },
293
+ );
294
+ }
295
+
296
+ // Verify PKCE if code challenge was provided
297
+ if (payload.codeChallenge) {
298
+ if (!code_verifier) {
299
+ return Response.json(
300
+ {
301
+ error: "invalid_grant",
302
+ error_description: "code_verifier required",
303
+ },
304
+ { status: 400 },
305
+ );
306
+ }
307
+
308
+ // Verify the code verifier
309
+ let computedChallenge: string;
310
+ if (payload.codeChallengeMethod === "S256") {
311
+ const encoder = new TextEncoder();
312
+ const data = encoder.encode(code_verifier);
313
+ const hash = await crypto.subtle.digest("SHA-256", data);
314
+ computedChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
315
+ .replace(/\+/g, "-")
316
+ .replace(/\//g, "_")
317
+ .replace(/=+$/, "");
318
+ } else {
319
+ computedChallenge = code_verifier;
320
+ }
321
+
322
+ if (computedChallenge !== payload.codeChallenge) {
323
+ return Response.json(
324
+ {
325
+ error: "invalid_grant",
326
+ error_description: "Invalid code_verifier",
327
+ },
328
+ { status: 400 },
329
+ );
330
+ }
331
+ }
332
+
333
+ // Return the actual token
334
+ return Response.json(
335
+ {
336
+ access_token: payload.accessToken,
337
+ token_type: payload.tokenType,
338
+ },
339
+ {
340
+ headers: {
341
+ "Cache-Control": "no-store",
342
+ Pragma: "no-cache",
343
+ },
344
+ },
345
+ );
346
+ } catch (err) {
347
+ console.error("Token exchange error:", err);
348
+ return Response.json(
349
+ {
350
+ error: "server_error",
351
+ error_description: "Failed to process token request",
352
+ },
353
+ { status: 500 },
354
+ );
355
+ }
356
+ };
357
+
358
+ /**
359
+ * Handle dynamic client registration (RFC7591)
360
+ * Stateless: just generates a client_id and returns it, no storage needed
361
+ */
362
+ const handleClientRegistration = async (req: Request): Promise<Response> => {
363
+ try {
364
+ const body = (await req.json()) as {
365
+ redirect_uris?: string[];
366
+ client_name?: string;
367
+ grant_types?: string[];
368
+ response_types?: string[];
369
+ token_endpoint_auth_method?: string;
370
+ scope?: string;
371
+ client_uri?: string;
372
+ };
373
+
374
+ // Validate redirect URIs
375
+ if (!body.redirect_uris || body.redirect_uris.length === 0) {
376
+ return Response.json(
377
+ {
378
+ error: "invalid_redirect_uri",
379
+ error_description: "At least one redirect_uri is required",
380
+ },
381
+ { status: 400 },
382
+ );
383
+ }
384
+
385
+ for (const uri of body.redirect_uris) {
386
+ if (!isValidRedirectUri(uri)) {
387
+ return Response.json(
388
+ {
389
+ error: "invalid_redirect_uri",
390
+ error_description: `Invalid redirect URI: ${uri}`,
391
+ },
392
+ { status: 400 },
393
+ );
394
+ }
395
+ }
396
+
397
+ const clientId = generateRandomToken(32);
398
+ const clientSecret =
399
+ body.token_endpoint_auth_method !== "none"
400
+ ? generateRandomToken(32)
401
+ : undefined;
402
+ const now = Math.floor(Date.now() / 1000);
403
+
404
+ const client: OAuthClient = {
405
+ client_id: clientId,
406
+ client_secret: clientSecret,
407
+ client_name: body.client_name,
408
+ redirect_uris: body.redirect_uris,
409
+ grant_types: body.grant_types ?? ["authorization_code"],
410
+ response_types: body.response_types ?? ["code"],
411
+ token_endpoint_auth_method:
412
+ body.token_endpoint_auth_method ?? "client_secret_post",
413
+ scope: body.scope,
414
+ client_id_issued_at: now,
415
+ client_secret_expires_at: 0,
416
+ };
417
+
418
+ // Save client if persistence is provided
419
+ if (oauth.persistence) {
420
+ await oauth.persistence.saveClient(client);
421
+ }
422
+
423
+ return new Response(JSON.stringify(client), {
424
+ status: 201,
425
+ headers: {
426
+ "Content-Type": "application/json",
427
+ "Cache-Control": "no-store",
428
+ Pragma: "no-cache",
429
+ },
430
+ });
431
+ } catch (err) {
432
+ console.error("Client registration error:", err);
433
+ return Response.json(
434
+ {
435
+ error: "invalid_client_metadata",
436
+ error_description: "Invalid client registration request",
437
+ },
438
+ { status: 400 },
439
+ );
440
+ }
441
+ };
442
+
443
+ /**
444
+ * Return 401 with WWW-Authenticate header for unauthenticated MCP requests
445
+ * Per MCP spec: MUST include resource_metadata URL
446
+ */
447
+ const createUnauthorizedResponse = (req: Request): Response => {
448
+ const url = new URL(req.url);
449
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
450
+ const wwwAuthenticateValue = `Bearer resource_metadata="${resourceMetadataUrl}", scope="*"`;
451
+
452
+ return Response.json(
453
+ {
454
+ jsonrpc: "2.0",
455
+ error: {
456
+ code: -32000,
457
+ message: "Unauthorized: Authentication required",
458
+ },
459
+ id: null,
460
+ },
461
+ {
462
+ status: 401,
463
+ headers: {
464
+ "WWW-Authenticate": wwwAuthenticateValue,
465
+ "Access-Control-Expose-Headers": "WWW-Authenticate",
466
+ },
467
+ },
468
+ );
469
+ };
470
+
471
+ /**
472
+ * Check if request has authentication token
473
+ */
474
+ const hasAuth = (req: Request): boolean => {
475
+ const authHeader = req.headers.get("Authorization");
476
+ const meshToken = req.headers.get("x-mesh-token");
477
+ return !!(authHeader || meshToken);
478
+ };
479
+
480
+ return {
481
+ handleProtectedResourceMetadata,
482
+ handleAuthorizationServerMetadata,
483
+ handleAuthorize,
484
+ handleOAuthCallback,
485
+ handleToken,
486
+ handleClientRegistration,
487
+ createUnauthorizedResponse,
488
+ hasAuth,
489
+ };
490
+ }
package/src/proxy.ts CHANGED
@@ -1,157 +1 @@
1
- /* oxlint-disable no-explicit-any */
2
- import { convertJsonSchemaToZod } from "zod-from-json-schema";
3
- import { MCPConnection } from "./connection.ts";
4
- import { createServerClient } from "./mcp-client.ts";
5
- import type { CreateStubAPIOptions } from "./mcp.ts";
6
-
7
- const safeParse = (content: string) => {
8
- try {
9
- return JSON.parse(content as string);
10
- } catch {
11
- return content;
12
- }
13
- };
14
-
15
- const toolsMap = new Map<
16
- string,
17
- Promise<
18
- Array<{
19
- name: string;
20
- inputSchema: any;
21
- outputSchema?: any;
22
- description: string;
23
- }>
24
- >
25
- >();
26
-
27
- /**
28
- * The base fetcher used to fetch the MCP from API.
29
- */
30
- export function createMCPClientProxy<T extends Record<string, unknown>>(
31
- options: CreateStubAPIOptions,
32
- ): T {
33
- const connection: MCPConnection = options.connection;
34
-
35
- return new Proxy<T>({} as T, {
36
- get(_, name) {
37
- if (name === "toJSON") {
38
- return null;
39
- }
40
- if (typeof name !== "string") {
41
- throw new Error("Name must be a string");
42
- }
43
- async function callToolFn(args: unknown) {
44
- const debugId = options?.debugId?.();
45
- const extraHeaders = debugId
46
- ? { "x-trace-debug-id": debugId }
47
- : undefined;
48
-
49
- // Create a connection with the tool name in the URL path for better logging
50
- // Only modify connections that have a URL property (HTTP, SSE, Websocket)
51
- // Use automatic detection based on URL, with optional override
52
-
53
- const { client, callStreamableTool } = await createServerClient(
54
- { connection },
55
- undefined,
56
- extraHeaders,
57
- );
58
-
59
- if (options?.streamable?.[String(name)]) {
60
- return callStreamableTool(String(name), args);
61
- }
62
-
63
- const { structuredContent, isError, content } = await client.callTool(
64
- {
65
- name: String(name),
66
- arguments: args as Record<string, unknown>,
67
- },
68
- undefined,
69
- {
70
- timeout: 3000000,
71
- },
72
- );
73
-
74
- if (isError) {
75
- // @ts-expect-error - content is not typed
76
- const maybeErrorMessage = content?.[0]?.text;
77
- const error =
78
- typeof maybeErrorMessage === "string"
79
- ? safeParse(maybeErrorMessage)
80
- : null;
81
-
82
- const throwableError =
83
- error?.code && typeof options?.getErrorByStatusCode === "function"
84
- ? options.getErrorByStatusCode(
85
- error.code,
86
- error.message,
87
- error.traceId,
88
- )
89
- : null;
90
-
91
- if (throwableError) {
92
- throw throwableError;
93
- }
94
-
95
- throw new Error(
96
- `Tool ${String(name)} returned an error: ${JSON.stringify(
97
- structuredContent ?? content,
98
- )}`,
99
- );
100
- }
101
- return structuredContent;
102
- }
103
-
104
- const listToolsFn = async () => {
105
- const { client } = await createServerClient({ connection });
106
- const { tools } = await client.listTools();
107
-
108
- return tools as {
109
- name: string;
110
- inputSchema: any;
111
- outputSchema?: any;
112
- description: string;
113
- }[];
114
- };
115
-
116
- async function listToolsOnce() {
117
- const conn = connection;
118
- const key = JSON.stringify(conn);
119
-
120
- try {
121
- if (!toolsMap.has(key)) {
122
- toolsMap.set(key, listToolsFn());
123
- }
124
-
125
- return await toolsMap.get(key)!;
126
- } catch (error) {
127
- console.error("Failed to list tools", error);
128
-
129
- toolsMap.delete(key);
130
- return;
131
- }
132
- }
133
- callToolFn.asTool = async () => {
134
- const tools = (await listToolsOnce()) ?? [];
135
- const tool = tools.find((t) => t.name === name);
136
- if (!tool) {
137
- throw new Error(`Tool ${name} not found`);
138
- }
139
-
140
- return {
141
- ...tool,
142
- id: tool.name,
143
- inputSchema: tool.inputSchema
144
- ? convertJsonSchemaToZod(tool.inputSchema)
145
- : undefined,
146
- outputSchema: tool.outputSchema
147
- ? convertJsonSchemaToZod(tool.outputSchema)
148
- : undefined,
149
- execute: (input: any) => {
150
- return callToolFn(input.context);
151
- },
152
- };
153
- };
154
- return callToolFn;
155
- },
156
- });
157
- }
1
+ export * from "@decocms/bindings/client";
package/src/tools.ts CHANGED
@@ -149,11 +149,76 @@ export interface OnChangeCallback<TSchema extends z.ZodTypeAny = never> {
149
149
  state: z.infer<TSchema>;
150
150
  scopes: string[];
151
151
  }
152
+
153
+ export interface OAuthParams {
154
+ code: string;
155
+ code_verifier?: string;
156
+ code_challenge_method?: "S256" | "plain";
157
+ }
158
+
159
+ export interface OAuthTokenResponse {
160
+ access_token: string;
161
+ token_type: string;
162
+ expires_in?: number;
163
+ refresh_token?: string;
164
+ scope?: string;
165
+ [key: string]: unknown;
166
+ }
167
+
168
+ /**
169
+ * OAuth client for dynamic client registration (RFC7591)
170
+ */
171
+ export interface OAuthClient {
172
+ client_id: string;
173
+ client_secret?: string;
174
+ client_name?: string;
175
+ redirect_uris: string[];
176
+ grant_types?: string[];
177
+ response_types?: string[];
178
+ token_endpoint_auth_method?: string;
179
+ scope?: string;
180
+ client_id_issued_at?: number;
181
+ client_secret_expires_at?: number;
182
+ }
183
+
184
+ /**
185
+ * OAuth configuration for MCP servers implementing PKCE flow
186
+ * Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
187
+ */
188
+ export interface OAuthConfig {
189
+ mode: "PKCE";
190
+ /**
191
+ * The external authorization server URL (e.g., "https://openrouter.ai")
192
+ * Used in protected resource metadata to indicate where clients should authenticate
193
+ */
194
+ authorizationServer: string;
195
+ /**
196
+ * Generates the authorization URL where users should be redirected
197
+ * @param callbackUrl - The URL the OAuth provider will redirect back to with the code
198
+ * @returns The full authorization URL to redirect the user to
199
+ */
200
+ authorizationUrl: (callbackUrl: string) => string;
201
+ /**
202
+ * Exchanges the authorization code for access tokens
203
+ * Called when the OAuth callback is received with a code
204
+ */
205
+ exchangeCode: (oauthParams: OAuthParams) => Promise<OAuthTokenResponse>;
206
+ /**
207
+ * Optional: persistence for dynamic client registration (RFC7591)
208
+ * If not provided, clients are accepted without validation
209
+ */
210
+ persistence?: {
211
+ getClient: (clientId: string) => Promise<OAuthClient | null>;
212
+ saveClient: (client: OAuthClient) => Promise<void>;
213
+ };
214
+ }
215
+
152
216
  export interface CreateMCPServerOptions<
153
217
  Env = unknown,
154
218
  TSchema extends z.ZodTypeAny = never,
155
219
  > {
156
220
  before?: (env: Env & DefaultEnv<TSchema>) => Promise<void> | void;
221
+ oauth?: OAuthConfig;
157
222
  configuration?: {
158
223
  onChange?: (
159
224
  env: Env & DefaultEnv<TSchema>,
@@ -1 +0,0 @@
1
- export { HTTPClientTransport } from "@decocms/bindings/client";
package/src/mcp-client.ts DELETED
@@ -1,139 +0,0 @@
1
- import {
2
- Client as BaseClient,
3
- ClientOptions,
4
- } from "@modelcontextprotocol/sdk/client/index.js";
5
- import {
6
- SSEClientTransport,
7
- SSEClientTransportOptions,
8
- } from "@modelcontextprotocol/sdk/client/sse.js";
9
- import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
10
- import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
11
- import {
12
- Implementation,
13
- ListToolsRequest,
14
- ListToolsResultSchema,
15
- } from "@modelcontextprotocol/sdk/types.js";
16
- import { MCPConnection } from "./connection.ts";
17
- import { HTTPClientTransport } from "./http-client-transport.ts";
18
-
19
- /**
20
- * WARNNING: This is a hack to prevent schema compilation errors.
21
- * More info at: https://github.com/modelcontextprotocol/typescript-sdk/issues/923
22
- *
23
- * Make sure to keep this updated with the right version of the SDK.
24
- * https://github.com/modelcontextprotocol/typescript-sdk/blob/bf817939917277a4c59f2e19e7b44b8dd7ff140c/src/client/index.ts#L480
25
- */
26
- class Client extends BaseClient {
27
- constructor(_clientInfo: Implementation, options?: ClientOptions) {
28
- super(_clientInfo, options);
29
- }
30
-
31
- override async listTools(
32
- params?: ListToolsRequest["params"],
33
- options?: RequestOptions,
34
- ) {
35
- const result = await this.request(
36
- { method: "tools/list", params },
37
- ListToolsResultSchema,
38
- options,
39
- );
40
-
41
- return result;
42
- }
43
- }
44
-
45
- export interface ServerClient {
46
- client: Client;
47
- callStreamableTool: (tool: string, args: unknown) => Promise<Response>;
48
- }
49
- export const createServerClient = async (
50
- mcpServer: { connection: MCPConnection; name?: string },
51
- signal?: AbortSignal,
52
- extraHeaders?: Record<string, string>,
53
- ): Promise<ServerClient> => {
54
- const transport = createTransport(mcpServer.connection, signal, extraHeaders);
55
-
56
- if (!transport) {
57
- throw new Error("Unknown MCP connection type");
58
- }
59
-
60
- const client = new Client({
61
- name: mcpServer?.name ?? "MCP Client",
62
- version: "1.0.0",
63
- });
64
-
65
- await client.connect(transport);
66
-
67
- return {
68
- client,
69
- callStreamableTool: (tool, args) => {
70
- if (mcpServer.connection.type !== "HTTP") {
71
- throw new Error("HTTP connection required");
72
- }
73
- return fetch(mcpServer.connection.url + `/call-tool/${tool}`, {
74
- method: "POST",
75
- redirect: "manual",
76
- body: JSON.stringify(args),
77
- headers: {
78
- ...extraHeaders,
79
- Authorization: `Bearer ${mcpServer.connection.token}`,
80
- },
81
- });
82
- },
83
- };
84
- };
85
-
86
- export const createTransport = (
87
- connection: MCPConnection,
88
- signal?: AbortSignal,
89
- extraHeaders?: Record<string, string>,
90
- ) => {
91
- if (connection.type === "Websocket") {
92
- return new WebSocketClientTransport(new URL(connection.url));
93
- }
94
-
95
- if (connection.type !== "SSE" && connection.type !== "HTTP") {
96
- return null;
97
- }
98
-
99
- const authHeaders: Record<string, string> = connection.token
100
- ? { authorization: `Bearer ${connection.token}` }
101
- : {};
102
-
103
- const headers: Record<string, string> = {
104
- ...authHeaders,
105
- ...(extraHeaders ?? {}),
106
- ...("headers" in connection ? connection.headers || {} : {}),
107
- };
108
-
109
- if (connection.type === "SSE") {
110
- const config: SSEClientTransportOptions = {
111
- requestInit: { headers, signal },
112
- };
113
-
114
- if (connection.token) {
115
- config.eventSourceInit = {
116
- fetch: (req, init) => {
117
- return fetch(req, {
118
- ...init,
119
- headers: {
120
- ...headers,
121
- Accept: "text/event-stream",
122
- },
123
- signal,
124
- });
125
- },
126
- };
127
- }
128
-
129
- return new SSEClientTransport(new URL(connection.url), config);
130
- }
131
- return new HTTPClientTransport(new URL(connection.url), {
132
- requestInit: {
133
- headers,
134
- signal,
135
- // @ts-ignore - this is a valid option for fetch
136
- credentials: "include",
137
- },
138
- });
139
- };