@decocms/runtime 1.0.0-alpha.5 → 1.0.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/src/mcp.ts CHANGED
@@ -1,170 +1,11 @@
1
1
  /* oxlint-disable no-explicit-any */
2
- import { z } from "zod";
3
- import type { MCPConnection } from "./connection.ts";
4
- import { createMCPClientProxy } from "./proxy.ts";
5
2
  import type { ToolBinder } from "@decocms/bindings";
6
-
7
- export interface FetchOptions extends RequestInit {
8
- path?: string;
9
- segments?: string[];
10
- }
11
-
12
- const Timings = z.object({
13
- sql_duration_ms: z.number().optional(),
14
- });
15
-
16
- const Meta = z.object({
17
- changed_db: z.boolean().optional(),
18
- changes: z.number().optional(),
19
- duration: z.number().optional(),
20
- last_row_id: z.number().optional(),
21
- rows_read: z.number().optional(),
22
- rows_written: z.number().optional(),
23
- served_by_primary: z.boolean().optional(),
24
- served_by_region: z
25
- .enum(["WNAM", "ENAM", "WEUR", "EEUR", "APAC", "OC"])
26
- .optional(),
27
- size_after: z.number().optional(),
28
- timings: Timings.optional(),
29
- });
30
-
31
- const QueryResult = z.object({
32
- meta: Meta.optional(),
33
- results: z.array(z.unknown()).optional(),
34
- success: z.boolean().optional(),
35
- });
36
-
37
- export type QueryResult = z.infer<typeof QueryResult>;
38
-
39
- const workspaceTools = [
40
- {
41
- name: "INTEGRATIONS_GET" as const,
42
- inputSchema: z.object({
43
- id: z.string(),
44
- }),
45
- outputSchema: z.object({
46
- connection: z.object({}),
47
- }),
48
- },
49
- {
50
- name: "DATABASES_RUN_SQL" as const,
51
- inputSchema: z.object({
52
- sql: z.string().describe("The SQL query to run"),
53
- params: z
54
- .array(z.string())
55
- .describe("The parameters to pass to the SQL query"),
56
- }),
57
- outputSchema: z.object({
58
- result: z.array(QueryResult),
59
- }),
60
- },
61
- ] satisfies ToolBinder<string, unknown, object>[];
62
-
63
- // Default fetcher instance with API_SERVER_URL and API_HEADERS
64
- const global = createMCPFetchStub<[]>({});
65
- export const MCPClient = new Proxy(
66
- {} as typeof global & {
67
- forWorkspace: (
68
- workspace: string,
69
- token?: string,
70
- decoCmsApiUrl?: string,
71
- ) => MCPClientFetchStub<typeof workspaceTools>;
72
- forConnection: <TDefinition extends readonly ToolBinder[]>(
73
- connection: MCPConnectionProvider,
74
- decoCmsApiUrl?: string,
75
- ) => MCPClientFetchStub<TDefinition>;
76
- },
77
- {
78
- get(_, name) {
79
- if (name === "toJSON") {
80
- return null;
81
- }
82
-
83
- if (name === "forWorkspace") {
84
- return (workspace: string, token?: string, decoCmsApiUrl?: string) =>
85
- createMCPFetchStub<[]>({
86
- workspace,
87
- token,
88
- decoCmsApiUrl,
89
- });
90
- }
91
- if (name === "forConnection") {
92
- return <TDefinition extends readonly ToolBinder[]>(
93
- connection: MCPConnectionProvider,
94
- decoCmsApiUrl?: string,
95
- ) =>
96
- createMCPFetchStub<TDefinition>({
97
- connection,
98
- decoCmsApiUrl,
99
- });
100
- }
101
- return global[name as keyof typeof global];
102
- },
103
- },
104
- );
3
+ export {
4
+ createMCPFetchStub,
5
+ MCPClient,
6
+ type CreateStubAPIOptions,
7
+ type MCPClientFetchStub,
8
+ type MCPClientStub,
9
+ } from "@decocms/bindings/client"; // Default fetcher instance with API_SERVER_URL and API_HEADERS
105
10
 
106
11
  export type { ToolBinder };
107
-
108
- export const isStreamableToolBinder = (
109
- toolBinder: ToolBinder,
110
- ): toolBinder is ToolBinder<string, any, any, true> => {
111
- return toolBinder.streamable === true;
112
- };
113
- export type MCPClientStub<TDefinition extends readonly ToolBinder[]> = {
114
- [K in TDefinition[number] as K["name"]]: K extends ToolBinder<
115
- string,
116
- infer TInput,
117
- infer TReturn
118
- >
119
- ? (params: TInput, init?: RequestInit) => Promise<TReturn>
120
- : never;
121
- };
122
-
123
- export type MCPClientFetchStub<TDefinition extends readonly ToolBinder[]> = {
124
- [K in TDefinition[number] as K["name"]]: K["streamable"] extends true
125
- ? K extends ToolBinder<string, infer TInput, any, true>
126
- ? (params: TInput, init?: RequestInit) => Promise<Response>
127
- : never
128
- : K extends ToolBinder<string, infer TInput, infer TReturn, any>
129
- ? (params: TInput, init?: RequestInit) => Promise<Awaited<TReturn>>
130
- : never;
131
- };
132
-
133
- export type MCPConnectionProvider = MCPConnection;
134
-
135
- export interface MCPClientRaw {
136
- callTool: (tool: string, args: unknown) => Promise<unknown>;
137
- listTools: () => Promise<
138
- {
139
- name: string;
140
- inputSchema: any;
141
- outputSchema?: any;
142
- description: string;
143
- }[]
144
- >;
145
- }
146
- export type JSONSchemaToZodConverter = (jsonSchema: any) => z.ZodTypeAny;
147
- export interface CreateStubAPIOptions {
148
- mcpPath?: string;
149
- decoCmsApiUrl?: string;
150
- workspace?: string;
151
- token?: string;
152
- connection?: MCPConnectionProvider;
153
- streamable?: Record<string, boolean>;
154
- debugId?: () => string;
155
- getErrorByStatusCode?: (
156
- statusCode: number,
157
- message?: string,
158
- traceId?: string,
159
- errorObject?: unknown,
160
- ) => Error;
161
- supportsToolName?: boolean;
162
- }
163
-
164
- export function createMCPFetchStub<TDefinition extends readonly ToolBinder[]>(
165
- options?: CreateStubAPIOptions,
166
- ): MCPClientFetchStub<TDefinition> {
167
- return createMCPClientProxy<MCPClientFetchStub<TDefinition>>({
168
- ...(options ?? {}),
169
- });
170
- }
package/src/oauth.ts ADDED
@@ -0,0 +1,495 @@
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
+ const forceHttps = (url: URL) => {
69
+ const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
70
+ if (!isLocal) {
71
+ // force http if not local
72
+ url.protocol = "https:";
73
+ }
74
+ return url;
75
+ };
76
+
77
+ /**
78
+ * Create OAuth endpoint handlers for MCP servers
79
+ * The MCP server acts as an OAuth Authorization Server proxy
80
+ * Stateless implementation - no persistence required
81
+ * Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
82
+ */
83
+ export function createOAuthHandlers(oauth: OAuthConfig) {
84
+ /**
85
+ * Build OAuth 2.0 Protected Resource Metadata (RFC9728)
86
+ * Points to THIS server as the authorization server
87
+ */
88
+ const handleProtectedResourceMetadata = (req: Request): Response => {
89
+ const url = forceHttps(new URL(req.url));
90
+ const resourceUrl = `${url.origin}/mcp`;
91
+
92
+ return Response.json({
93
+ resource: resourceUrl,
94
+ // Point to ourselves - we are the authorization server proxy
95
+ authorization_servers: [url.origin],
96
+ scopes_supported: ["*"],
97
+ bearer_methods_supported: ["header"],
98
+ resource_signing_alg_values_supported: ["RS256", "none"],
99
+ });
100
+ };
101
+
102
+ /**
103
+ * Build OAuth 2.0 Authorization Server Metadata (RFC8414)
104
+ * Exposes our endpoints for authorization, token exchange, and registration
105
+ */
106
+ const handleAuthorizationServerMetadata = (req: Request): Response => {
107
+ const url = forceHttps(new URL(req.url));
108
+ const baseUrl = url.origin;
109
+
110
+ return Response.json({
111
+ issuer: baseUrl,
112
+ authorization_endpoint: `${baseUrl}/authorize`,
113
+ token_endpoint: `${baseUrl}/token`,
114
+ registration_endpoint: `${baseUrl}/register`,
115
+ scopes_supported: ["*"],
116
+ response_types_supported: ["code"],
117
+ response_modes_supported: ["query"],
118
+ grant_types_supported: ["authorization_code", "refresh_token"],
119
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
120
+ code_challenge_methods_supported: ["S256", "plain"],
121
+ });
122
+ };
123
+
124
+ /**
125
+ * Handle authorization request - redirects to external OAuth provider
126
+ * Stateless: encodes all needed info in the state parameter
127
+ */
128
+ const handleAuthorize = (req: Request): Response => {
129
+ const url = forceHttps(new URL(req.url));
130
+ const redirectUri = url.searchParams.get("redirect_uri");
131
+ const responseType = url.searchParams.get("response_type");
132
+ const clientState = url.searchParams.get("state");
133
+ const codeChallenge = url.searchParams.get("code_challenge");
134
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
135
+
136
+ // Validate required params
137
+ if (!redirectUri) {
138
+ return Response.json(
139
+ {
140
+ error: "invalid_request",
141
+ error_description: "redirect_uri required",
142
+ },
143
+ { status: 400 },
144
+ );
145
+ }
146
+
147
+ if (responseType !== "code") {
148
+ return Response.json(
149
+ {
150
+ error: "unsupported_response_type",
151
+ error_description: "Only 'code' is supported",
152
+ },
153
+ { status: 400 },
154
+ );
155
+ }
156
+
157
+ // Encode pending auth state
158
+ const pendingState: PendingAuthState = {
159
+ redirectUri,
160
+ clientState: clientState ?? undefined,
161
+ codeChallenge: codeChallenge ?? undefined,
162
+ codeChallengeMethod: codeChallengeMethod ?? undefined,
163
+ };
164
+ const encodedState = encodeState(pendingState);
165
+
166
+ // Build callback URL pointing to our internal callback
167
+ const callbackUrl = forceHttps(new URL(`${url.origin}/oauth/callback`));
168
+ callbackUrl.searchParams.set("state", encodedState);
169
+
170
+ // Get the external authorization URL from the config
171
+ const externalAuthUrl = oauth.authorizationUrl(callbackUrl.toString());
172
+
173
+ // Redirect to external OAuth provider
174
+ return Response.redirect(externalAuthUrl, 302);
175
+ };
176
+
177
+ /**
178
+ * Handle OAuth callback from external provider
179
+ * Stateless: decodes state to get redirect info, encodes token in code
180
+ */
181
+ const handleOAuthCallback = async (req: Request): Promise<Response> => {
182
+ const url = forceHttps(new URL(req.url));
183
+ const code = url.searchParams.get("code");
184
+ const encodedState = url.searchParams.get("state");
185
+ const error = url.searchParams.get("error");
186
+
187
+ // Decode state
188
+ const pending = encodedState
189
+ ? decodeState<PendingAuthState>(encodedState)
190
+ : null;
191
+
192
+ if (error) {
193
+ const errorDescription =
194
+ url.searchParams.get("error_description") ?? "Authorization failed";
195
+ if (pending?.redirectUri) {
196
+ const redirectUrl = forceHttps(new URL(pending.redirectUri));
197
+ redirectUrl.searchParams.set("error", error);
198
+ redirectUrl.searchParams.set("error_description", errorDescription);
199
+ if (pending.clientState)
200
+ redirectUrl.searchParams.set("state", pending.clientState);
201
+ return Response.redirect(redirectUrl.toString(), 302);
202
+ }
203
+ return Response.json(
204
+ { error, error_description: errorDescription },
205
+ { status: 400 },
206
+ );
207
+ }
208
+
209
+ if (!code || !pending) {
210
+ return Response.json(
211
+ {
212
+ error: "invalid_request",
213
+ error_description: "Missing code or state",
214
+ },
215
+ { status: 400 },
216
+ );
217
+ }
218
+
219
+ try {
220
+ // Exchange code with external provider
221
+ const oauthParams: OAuthParams = { code };
222
+ const tokenResponse = await oauth.exchangeCode(oauthParams);
223
+
224
+ // Encode the token in our own code (stateless)
225
+ const codePayload: CodePayload = {
226
+ accessToken: tokenResponse.access_token,
227
+ tokenType: tokenResponse.token_type,
228
+ codeChallenge: pending.codeChallenge,
229
+ codeChallengeMethod: pending.codeChallengeMethod,
230
+ };
231
+ const ourCode = encodeState(codePayload);
232
+
233
+ // Redirect back to client with our code
234
+ const redirectUrl = forceHttps(new URL(pending.redirectUri));
235
+ redirectUrl.searchParams.set("code", ourCode);
236
+ if (pending.clientState) {
237
+ redirectUrl.searchParams.set("state", pending.clientState);
238
+ }
239
+
240
+ return Response.redirect(redirectUrl.toString(), 302);
241
+ } catch (err) {
242
+ console.error("OAuth callback error:", err);
243
+
244
+ // Redirect back to client with error
245
+ const redirectUrl = forceHttps(new URL(pending.redirectUri));
246
+ redirectUrl.searchParams.set("error", "server_error");
247
+ redirectUrl.searchParams.set(
248
+ "error_description",
249
+ "Failed to exchange authorization code",
250
+ );
251
+ if (pending.clientState)
252
+ redirectUrl.searchParams.set("state", pending.clientState);
253
+
254
+ return Response.redirect(redirectUrl.toString(), 302);
255
+ }
256
+ };
257
+
258
+ /**
259
+ * Handle token exchange - decodes our code to get the actual token
260
+ * Stateless: token is encoded in the code
261
+ */
262
+ const handleToken = async (req: Request): Promise<Response> => {
263
+ try {
264
+ const contentType = req.headers.get("content-type") ?? "";
265
+ let body: Record<string, string>;
266
+
267
+ if (contentType.includes("application/x-www-form-urlencoded")) {
268
+ const formData = await req.formData();
269
+ body = Object.fromEntries(formData.entries()) as Record<string, string>;
270
+ } else {
271
+ body = await req.json();
272
+ }
273
+
274
+ const { code, code_verifier, grant_type } = body;
275
+
276
+ if (grant_type !== "authorization_code") {
277
+ return Response.json(
278
+ {
279
+ error: "unsupported_grant_type",
280
+ error_description: "Only authorization_code supported",
281
+ },
282
+ { status: 400 },
283
+ );
284
+ }
285
+
286
+ if (!code) {
287
+ return Response.json(
288
+ { error: "invalid_request", error_description: "code is required" },
289
+ { status: 400 },
290
+ );
291
+ }
292
+
293
+ // Decode the code to get the token
294
+ const payload = decodeState<CodePayload>(code);
295
+ if (!payload || !payload.accessToken) {
296
+ return Response.json(
297
+ {
298
+ error: "invalid_grant",
299
+ error_description: "Invalid or expired code",
300
+ },
301
+ { status: 400 },
302
+ );
303
+ }
304
+
305
+ // Verify PKCE if code challenge was provided
306
+ if (payload.codeChallenge) {
307
+ if (!code_verifier) {
308
+ return Response.json(
309
+ {
310
+ error: "invalid_grant",
311
+ error_description: "code_verifier required",
312
+ },
313
+ { status: 400 },
314
+ );
315
+ }
316
+
317
+ // Verify the code verifier
318
+ let computedChallenge: string;
319
+ if (payload.codeChallengeMethod === "S256") {
320
+ const encoder = new TextEncoder();
321
+ const data = encoder.encode(code_verifier);
322
+ const hash = await crypto.subtle.digest("SHA-256", data);
323
+ computedChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
324
+ .replace(/\+/g, "-")
325
+ .replace(/\//g, "_")
326
+ .replace(/=+$/, "");
327
+ } else {
328
+ computedChallenge = code_verifier;
329
+ }
330
+
331
+ if (computedChallenge !== payload.codeChallenge) {
332
+ return Response.json(
333
+ {
334
+ error: "invalid_grant",
335
+ error_description: "Invalid code_verifier",
336
+ },
337
+ { status: 400 },
338
+ );
339
+ }
340
+ }
341
+
342
+ // Return the actual token
343
+ return Response.json(
344
+ {
345
+ access_token: payload.accessToken,
346
+ token_type: payload.tokenType,
347
+ },
348
+ {
349
+ headers: {
350
+ "Cache-Control": "no-store",
351
+ Pragma: "no-cache",
352
+ },
353
+ },
354
+ );
355
+ } catch (err) {
356
+ console.error("Token exchange error:", err);
357
+ return Response.json(
358
+ {
359
+ error: "server_error",
360
+ error_description: "Failed to process token request",
361
+ },
362
+ { status: 500 },
363
+ );
364
+ }
365
+ };
366
+
367
+ /**
368
+ * Handle dynamic client registration (RFC7591)
369
+ * Stateless: just generates a client_id and returns it, no storage needed
370
+ */
371
+ const handleClientRegistration = async (req: Request): Promise<Response> => {
372
+ try {
373
+ const body = (await req.json()) as {
374
+ redirect_uris?: string[];
375
+ client_name?: string;
376
+ grant_types?: string[];
377
+ response_types?: string[];
378
+ token_endpoint_auth_method?: string;
379
+ scope?: string;
380
+ client_uri?: string;
381
+ };
382
+
383
+ // Validate redirect URIs
384
+ if (!body.redirect_uris || body.redirect_uris.length === 0) {
385
+ return Response.json(
386
+ {
387
+ error: "invalid_redirect_uri",
388
+ error_description: "At least one redirect_uri is required",
389
+ },
390
+ { status: 400 },
391
+ );
392
+ }
393
+
394
+ for (const uri of body.redirect_uris) {
395
+ if (!isValidRedirectUri(uri)) {
396
+ return Response.json(
397
+ {
398
+ error: "invalid_redirect_uri",
399
+ error_description: `Invalid redirect URI: ${uri}`,
400
+ },
401
+ { status: 400 },
402
+ );
403
+ }
404
+ }
405
+
406
+ const clientId = generateRandomToken(32);
407
+ const clientSecret =
408
+ body.token_endpoint_auth_method !== "none"
409
+ ? generateRandomToken(32)
410
+ : undefined;
411
+ const now = Math.floor(Date.now() / 1000);
412
+
413
+ const client: OAuthClient = {
414
+ client_id: clientId,
415
+ client_secret: clientSecret,
416
+ client_name: body.client_name,
417
+ redirect_uris: body.redirect_uris,
418
+ grant_types: body.grant_types ?? ["authorization_code"],
419
+ response_types: body.response_types ?? ["code"],
420
+ token_endpoint_auth_method:
421
+ body.token_endpoint_auth_method ?? "client_secret_post",
422
+ scope: body.scope,
423
+ client_id_issued_at: now,
424
+ client_secret_expires_at: 0,
425
+ };
426
+
427
+ // Save client if persistence is provided
428
+ if (oauth.persistence) {
429
+ await oauth.persistence.saveClient(client);
430
+ }
431
+
432
+ return new Response(JSON.stringify(client), {
433
+ status: 201,
434
+ headers: {
435
+ "Content-Type": "application/json",
436
+ "Cache-Control": "no-store",
437
+ Pragma: "no-cache",
438
+ },
439
+ });
440
+ } catch (err) {
441
+ console.error("Client registration error:", err);
442
+ return Response.json(
443
+ {
444
+ error: "invalid_client_metadata",
445
+ error_description: "Invalid client registration request",
446
+ },
447
+ { status: 400 },
448
+ );
449
+ }
450
+ };
451
+
452
+ /**
453
+ * Return 401 with WWW-Authenticate header for unauthenticated MCP requests
454
+ * Per MCP spec: MUST include resource_metadata URL
455
+ */
456
+ const createUnauthorizedResponse = (req: Request): Response => {
457
+ const url = forceHttps(new URL(req.url));
458
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
459
+ const wwwAuthenticateValue = `Bearer resource_metadata="${resourceMetadataUrl}", scope="*"`;
460
+
461
+ return Response.json(
462
+ {
463
+ jsonrpc: "2.0",
464
+ error: {
465
+ code: -32000,
466
+ message: "Unauthorized: Authentication required",
467
+ },
468
+ id: null,
469
+ },
470
+ {
471
+ status: 401,
472
+ headers: {
473
+ "WWW-Authenticate": wwwAuthenticateValue,
474
+ "Access-Control-Expose-Headers": "WWW-Authenticate",
475
+ },
476
+ },
477
+ );
478
+ };
479
+
480
+ /**
481
+ * Check if request has authentication token
482
+ */
483
+ const hasAuth = (req: Request) => req.headers.has("Authorization");
484
+
485
+ return {
486
+ handleProtectedResourceMetadata,
487
+ handleAuthorizationServerMetadata,
488
+ handleAuthorize,
489
+ handleOAuthCallback,
490
+ handleToken,
491
+ handleClientRegistration,
492
+ createUnauthorizedResponse,
493
+ hasAuth,
494
+ };
495
+ }