@decocms/runtime 1.2.4 → 1.2.6

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 ADDED
@@ -0,0 +1,770 @@
1
+ # @decocms/runtime
2
+
3
+ A TypeScript framework for building MCP (Model Context Protocol) servers with first-class support for tools, prompts, resources, OAuth authentication, and event-driven architectures.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @decocms/runtime
9
+ ```
10
+
11
+ Or with npm:
12
+
13
+ ```bash
14
+ npm install @decocms/runtime
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ Create a simple MCP server with tools:
20
+
21
+ ```typescript
22
+ import { withRuntime, createTool } from "@decocms/runtime";
23
+ import { z } from "zod";
24
+
25
+ // Define a tool
26
+ const greetTool = createTool({
27
+ id: "greet",
28
+ description: "Greets a user by name",
29
+ inputSchema: z.object({
30
+ name: z.string().describe("Name of the person to greet"),
31
+ }),
32
+ outputSchema: z.object({
33
+ message: z.string(),
34
+ }),
35
+ execute: async ({ context }) => {
36
+ return { message: `Hello, ${context.name}!` };
37
+ },
38
+ });
39
+
40
+ // Create the MCP server
41
+ export default withRuntime({
42
+ tools: [() => greetTool],
43
+ });
44
+ ```
45
+
46
+ The server automatically exposes an MCP endpoint at `/mcp` that handles all MCP protocol requests.
47
+
48
+ ## Core Concepts
49
+
50
+ ### withRuntime
51
+
52
+ The `withRuntime` function is the main entry point for creating an MCP server. It accepts configuration options and returns a fetch handler compatible with Cloudflare Workers, Bun, and other web standard runtimes.
53
+
54
+ ```typescript
55
+ import { withRuntime } from "@decocms/runtime";
56
+
57
+ export default withRuntime({
58
+ // Tools exposed to LLMs
59
+ tools: [...],
60
+
61
+ // Prompts for guided interactions
62
+ prompts: [...],
63
+
64
+ // Resources for data access
65
+ resources: [...],
66
+
67
+ // Optional: Custom fetch handler for non-MCP routes
68
+ fetch: async (req, env, ctx) => {
69
+ // Handle custom routes
70
+ return new Response("Custom response");
71
+ },
72
+
73
+ // Optional: CORS configuration
74
+ cors: {
75
+ origin: "*",
76
+ credentials: true,
77
+ },
78
+
79
+ // Optional: OAuth configuration
80
+ oauth: { ... },
81
+
82
+ // Optional: Configuration state
83
+ configuration: { ... },
84
+
85
+ // Optional: Event handlers
86
+ events: { ... },
87
+
88
+ // Optional: Hook before request processing
89
+ before: async (env) => {
90
+ // Initialize resources
91
+ },
92
+ });
93
+ ```
94
+
95
+ ## Tools
96
+
97
+ Tools are functions that LLMs can invoke to perform actions or retrieve data.
98
+
99
+ ### Creating a Tool
100
+
101
+ ```typescript
102
+ import { createTool } from "@decocms/runtime";
103
+ import { z } from "zod";
104
+
105
+ const calculateTool = createTool({
106
+ id: "calculate",
107
+ description: "Performs basic arithmetic operations",
108
+ inputSchema: z.object({
109
+ operation: z.enum(["add", "subtract", "multiply", "divide"]),
110
+ a: z.number(),
111
+ b: z.number(),
112
+ }),
113
+ outputSchema: z.object({
114
+ result: z.number(),
115
+ }),
116
+ execute: async ({ context }) => {
117
+ const { operation, a, b } = context;
118
+ let result: number;
119
+
120
+ switch (operation) {
121
+ case "add": result = a + b; break;
122
+ case "subtract": result = a - b; break;
123
+ case "multiply": result = a * b; break;
124
+ case "divide": result = a / b; break;
125
+ }
126
+
127
+ return { result };
128
+ },
129
+ });
130
+ ```
131
+
132
+ ### Private Tools (Authentication Required)
133
+
134
+ Use `createPrivateTool` for tools that require user authentication:
135
+
136
+ ```typescript
137
+ import { createPrivateTool } from "@decocms/runtime";
138
+
139
+ const getUserDataTool = createPrivateTool({
140
+ id: "getUserData",
141
+ description: "Retrieves the current user's data",
142
+ inputSchema: z.object({}),
143
+ outputSchema: z.object({
144
+ userId: z.string(),
145
+ email: z.string(),
146
+ }),
147
+ execute: async ({ runtimeContext }) => {
148
+ // ensureAuthenticated is called automatically
149
+ const user = runtimeContext.env.MESH_REQUEST_CONTEXT.ensureAuthenticated();
150
+ return { userId: user.id, email: user.email };
151
+ },
152
+ });
153
+ ```
154
+
155
+ ### Streamable Tools
156
+
157
+ For tools that return streaming responses:
158
+
159
+ ```typescript
160
+ import { createStreamableTool } from "@decocms/runtime";
161
+
162
+ const streamDataTool = createStreamableTool({
163
+ id: "streamData",
164
+ description: "Streams data as a response",
165
+ inputSchema: z.object({
166
+ query: z.string(),
167
+ }),
168
+ streamable: true,
169
+ execute: async ({ context }) => {
170
+ // Return a streaming Response
171
+ const stream = new ReadableStream({
172
+ async start(controller) {
173
+ controller.enqueue(new TextEncoder().encode("Chunk 1\n"));
174
+ controller.enqueue(new TextEncoder().encode("Chunk 2\n"));
175
+ controller.close();
176
+ },
177
+ });
178
+
179
+ return new Response(stream, {
180
+ headers: { "Content-Type": "text/plain" },
181
+ });
182
+ },
183
+ });
184
+ ```
185
+
186
+ ### Registering Tools
187
+
188
+ Tools can be registered in multiple ways:
189
+
190
+ ```typescript
191
+ export default withRuntime({
192
+ // Option 1: Array of tool factories
193
+ tools: [
194
+ () => greetTool,
195
+ () => calculateTool,
196
+ (env) => createDynamicTool(env),
197
+ ],
198
+
199
+ // Option 2: Single function returning array
200
+ tools: async (env) => {
201
+ return [greetTool, calculateTool];
202
+ },
203
+ });
204
+ ```
205
+
206
+ ## Prompts
207
+
208
+ Prompts define guided interactions with predefined templates.
209
+
210
+ ### Creating a Prompt
211
+
212
+ ```typescript
213
+ import { createPrompt, createPublicPrompt } from "@decocms/runtime";
214
+ import { z } from "zod";
215
+
216
+ const codeReviewPrompt = createPrompt({
217
+ name: "code-review",
218
+ title: "Code Review",
219
+ description: "Provides a structured code review",
220
+ argsSchema: {
221
+ code: z.string().describe("The code to review"),
222
+ language: z.string().optional().describe("Programming language"),
223
+ },
224
+ execute: async ({ args }) => {
225
+ return {
226
+ messages: [
227
+ {
228
+ role: "user",
229
+ content: {
230
+ type: "text",
231
+ text: `Please review this ${args.language ?? "code"}:\n\n${args.code}`,
232
+ },
233
+ },
234
+ ],
235
+ };
236
+ },
237
+ });
238
+
239
+ // Public prompt (no auth required)
240
+ const welcomePrompt = createPublicPrompt({
241
+ name: "welcome",
242
+ title: "Welcome Message",
243
+ description: "Generates a welcome message",
244
+ execute: async () => {
245
+ return {
246
+ messages: [
247
+ {
248
+ role: "user",
249
+ content: { type: "text", text: "Welcome to our MCP server!" },
250
+ },
251
+ ],
252
+ };
253
+ },
254
+ });
255
+ ```
256
+
257
+ ## Resources
258
+
259
+ Resources expose data that LLMs can read.
260
+
261
+ ### Creating a Resource
262
+
263
+ ```typescript
264
+ import { createResource, createPublicResource } from "@decocms/runtime";
265
+
266
+ const configResource = createResource({
267
+ uri: "config://app",
268
+ name: "App Configuration",
269
+ description: "Current application configuration",
270
+ mimeType: "application/json",
271
+ read: async ({ runtimeContext }) => {
272
+ const config = await loadConfig();
273
+ return {
274
+ uri: "config://app",
275
+ mimeType: "application/json",
276
+ text: JSON.stringify(config, null, 2),
277
+ };
278
+ },
279
+ });
280
+
281
+ // URI templates for dynamic resources
282
+ const fileResource = createPublicResource({
283
+ uri: "file://{path}",
284
+ name: "File Reader",
285
+ description: "Reads file contents",
286
+ read: async ({ uri }) => {
287
+ const path = uri.pathname;
288
+ const content = await readFile(path);
289
+ return {
290
+ uri: uri.toString(),
291
+ mimeType: "text/plain",
292
+ text: content,
293
+ };
294
+ },
295
+ });
296
+ ```
297
+
298
+ ## OAuth Authentication
299
+
300
+ Enable OAuth for protected MCP endpoints:
301
+
302
+ ```typescript
303
+ import { withRuntime, type OAuthConfig } from "@decocms/runtime";
304
+
305
+ const oauthConfig: OAuthConfig = {
306
+ mode: "PKCE",
307
+
308
+ // External OAuth provider URL
309
+ authorizationServer: "https://your-oauth-provider.com",
310
+
311
+ // Generate authorization URL
312
+ authorizationUrl: (callbackUrl) => {
313
+ const url = new URL("https://your-oauth-provider.com/authorize");
314
+ url.searchParams.set("client_id", "your-client-id");
315
+ url.searchParams.set("redirect_uri", callbackUrl);
316
+ url.searchParams.set("response_type", "code");
317
+ url.searchParams.set("scope", "openid profile email");
318
+ return url.toString();
319
+ },
320
+
321
+ // Exchange authorization code for tokens
322
+ exchangeCode: async ({ code, redirect_uri }) => {
323
+ const response = await fetch("https://your-oauth-provider.com/token", {
324
+ method: "POST",
325
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
326
+ body: new URLSearchParams({
327
+ grant_type: "authorization_code",
328
+ code,
329
+ redirect_uri: redirect_uri!,
330
+ client_id: "your-client-id",
331
+ client_secret: "your-client-secret",
332
+ }),
333
+ });
334
+ return response.json();
335
+ },
336
+
337
+ // Optional: Refresh token support
338
+ refreshToken: async (refreshToken) => {
339
+ const response = await fetch("https://your-oauth-provider.com/token", {
340
+ method: "POST",
341
+ body: new URLSearchParams({
342
+ grant_type: "refresh_token",
343
+ refresh_token: refreshToken,
344
+ }),
345
+ });
346
+ return response.json();
347
+ },
348
+ };
349
+
350
+ export default withRuntime({
351
+ oauth: oauthConfig,
352
+ tools: [...],
353
+ });
354
+ ```
355
+
356
+ OAuth endpoints are automatically exposed:
357
+ - `/.well-known/oauth-protected-resource` - Resource metadata
358
+ - `/.well-known/oauth-authorization-server` - Server metadata
359
+ - `/authorize` - Authorization endpoint
360
+ - `/oauth/callback` - OAuth callback
361
+ - `/token` - Token endpoint
362
+ - `/register` - Dynamic client registration
363
+
364
+ ## Configuration State
365
+
366
+ Define typed configuration state that persists across requests:
367
+
368
+ ```typescript
369
+ import { withRuntime, BindingOf } from "@decocms/runtime";
370
+ import { z } from "zod";
371
+
372
+ // Define your state schema
373
+ const stateSchema = z.object({
374
+ apiKey: z.string(),
375
+ maxTokens: z.number().default(1000),
376
+ // Bindings reference other MCP connections
377
+ database: BindingOf<MyRegistry, "@deco/database">("@deco/database"),
378
+ });
379
+
380
+ export default withRuntime({
381
+ configuration: {
382
+ state: stateSchema,
383
+ scopes: ["API_KEY::read", "DATABASE::query"],
384
+
385
+ // Called when configuration changes
386
+ onChange: async (env, { state, scopes }) => {
387
+ console.log("Configuration updated:", state);
388
+ },
389
+ },
390
+
391
+ tools: [
392
+ (env) => createTool({
393
+ id: "query",
394
+ inputSchema: z.object({ sql: z.string() }),
395
+ execute: async ({ runtimeContext }) => {
396
+ // Access resolved bindings from state
397
+ const { database } = runtimeContext.env.MESH_REQUEST_CONTEXT.state;
398
+ return database.QUERY({ sql: context.sql });
399
+ },
400
+ }),
401
+ ],
402
+ });
403
+ ```
404
+
405
+ ## Event Handlers
406
+
407
+ Subscribe to events from other MCP connections:
408
+
409
+ ```typescript
410
+ import { withRuntime, SELF } from "@decocms/runtime";
411
+
412
+ export default withRuntime({
413
+ configuration: {
414
+ state: z.object({
415
+ database: BindingOf<Registry, "@deco/database">("@deco/database"),
416
+ }),
417
+ },
418
+
419
+ events: {
420
+ // Subscribe to events from the database binding
421
+ database: {
422
+ // Per-event handlers
423
+ "record.created": async ({ events }, env) => {
424
+ for (const event of events) {
425
+ console.log("New record:", event.data);
426
+ }
427
+ return { success: true };
428
+ },
429
+ "record.deleted": async ({ events }, env) => {
430
+ return { success: true };
431
+ },
432
+ },
433
+
434
+ // Subscribe to events from self (this connection)
435
+ SELF: {
436
+ "order.completed": async ({ events }, env) => {
437
+ return { success: true };
438
+ },
439
+ },
440
+ },
441
+ });
442
+
443
+ // Or use batch handlers for multiple event types
444
+ export default withRuntime({
445
+ events: {
446
+ handler: async ({ events }, env) => {
447
+ // Process all events in batch
448
+ return { success: true };
449
+ },
450
+ events: [
451
+ "SELF::order.created",
452
+ "database::record.updated",
453
+ ],
454
+ },
455
+ });
456
+ ```
457
+
458
+ ## Bindings
459
+
460
+ Bindings define standardized interfaces that MCP servers can implement.
461
+
462
+ ### Using Existing Bindings
463
+
464
+ ```typescript
465
+ import { impl, WellKnownBindings } from "@decocms/runtime/bindings";
466
+
467
+ // Implement a channel binding
468
+ const channelTools = impl(WellKnownBindings.Channel, [
469
+ {
470
+ description: "Join a channel",
471
+ handler: async ({ workspace, channelId }) => {
472
+ // Implementation
473
+ return { success: true, channelId };
474
+ },
475
+ },
476
+ {
477
+ description: "Leave a channel",
478
+ handler: async ({ channelId }) => {
479
+ return { success: true };
480
+ },
481
+ },
482
+ ]);
483
+
484
+ export default withRuntime({
485
+ tools: [() => channelTools].flat(),
486
+ });
487
+ ```
488
+
489
+ ### Creating Custom Bindings
490
+
491
+ ```typescript
492
+ import { z } from "zod";
493
+ import type { Binder } from "@decocms/runtime/bindings";
494
+
495
+ // Define your binding schema
496
+ export const MY_BINDING = [
497
+ {
498
+ name: "MY_TOOL_ACTION" as const,
499
+ inputSchema: z.object({
500
+ param: z.string(),
501
+ }),
502
+ outputSchema: z.object({
503
+ result: z.string(),
504
+ }),
505
+ },
506
+ {
507
+ name: "MY_TOOL_QUERY" as const,
508
+ inputSchema: z.object({}),
509
+ outputSchema: z.object({
510
+ items: z.array(z.string()),
511
+ }),
512
+ opt: true, // Optional tool
513
+ },
514
+ ] as const satisfies Binder;
515
+ ```
516
+
517
+ ## Custom Fetch Handler
518
+
519
+ Handle non-MCP routes alongside your MCP server:
520
+
521
+ ```typescript
522
+ export default withRuntime({
523
+ tools: [...],
524
+
525
+ fetch: async (req, env, ctx) => {
526
+ const url = new URL(req.url);
527
+
528
+ if (url.pathname === "/api/health") {
529
+ return new Response(JSON.stringify({ status: "ok" }), {
530
+ headers: { "Content-Type": "application/json" },
531
+ });
532
+ }
533
+
534
+ if (url.pathname.startsWith("/api/")) {
535
+ // Handle API routes
536
+ return handleApiRoutes(req, env);
537
+ }
538
+
539
+ return new Response("Not Found", { status: 404 });
540
+ },
541
+ });
542
+ ```
543
+
544
+ ## CORS Configuration
545
+
546
+ Configure CORS for browser-based MCP clients:
547
+
548
+ ```typescript
549
+ export default withRuntime({
550
+ cors: {
551
+ origin: ["https://example.com", "http://localhost:3000"],
552
+ credentials: true,
553
+ allowMethods: ["GET", "POST", "OPTIONS"],
554
+ allowHeaders: ["Content-Type", "Authorization", "mcp-protocol-version"],
555
+ },
556
+
557
+ // Or disable CORS entirely
558
+ cors: false,
559
+ });
560
+ ```
561
+
562
+ ## Accessing Request Context
563
+
564
+ Access request context within tools:
565
+
566
+ ```typescript
567
+ const myTool = createTool({
568
+ id: "contextDemo",
569
+ inputSchema: z.object({}),
570
+ execute: async ({ runtimeContext }) => {
571
+ const { env, req } = runtimeContext;
572
+
573
+ // Access MESH_REQUEST_CONTEXT for auth and bindings
574
+ const ctx = env.MESH_REQUEST_CONTEXT;
575
+
576
+ // Get authenticated user (throws if not authenticated)
577
+ const user = ctx.ensureAuthenticated();
578
+
579
+ // Access resolved binding state
580
+ const state = ctx.state;
581
+
582
+ // Get connection info
583
+ const { connectionId, organizationId, meshUrl } = ctx;
584
+
585
+ // Access original request
586
+ const userAgent = req?.headers.get("user-agent");
587
+
588
+ return { userId: user.id };
589
+ },
590
+ });
591
+ ```
592
+
593
+ ## Complete Example
594
+
595
+ Here's a complete example of an MCP server with tools, prompts, resources, and OAuth:
596
+
597
+ ```typescript
598
+ import {
599
+ withRuntime,
600
+ createTool,
601
+ createPrivateTool,
602
+ createPrompt,
603
+ createResource,
604
+ } from "@decocms/runtime";
605
+ import { z } from "zod";
606
+
607
+ // Public tool - no auth required
608
+ const echoTool = createTool({
609
+ id: "echo",
610
+ description: "Echoes the input message",
611
+ inputSchema: z.object({
612
+ message: z.string(),
613
+ }),
614
+ outputSchema: z.object({
615
+ echo: z.string(),
616
+ }),
617
+ execute: async ({ context }) => {
618
+ return { echo: context.message };
619
+ },
620
+ });
621
+
622
+ // Private tool - requires authentication
623
+ const getProfileTool = createPrivateTool({
624
+ id: "getProfile",
625
+ description: "Gets the current user's profile",
626
+ inputSchema: z.object({}),
627
+ outputSchema: z.object({
628
+ id: z.string(),
629
+ email: z.string(),
630
+ name: z.string(),
631
+ }),
632
+ execute: async ({ runtimeContext }) => {
633
+ const user = runtimeContext.env.MESH_REQUEST_CONTEXT.ensureAuthenticated();
634
+ return {
635
+ id: user.id,
636
+ email: user.email,
637
+ name: user.user_metadata.full_name,
638
+ };
639
+ },
640
+ });
641
+
642
+ // Prompt template
643
+ const analyzePrompt = createPrompt({
644
+ name: "analyze",
645
+ title: "Analyze Data",
646
+ description: "Analyzes provided data and returns insights",
647
+ argsSchema: {
648
+ data: z.string().describe("JSON data to analyze"),
649
+ },
650
+ execute: async ({ args }) => ({
651
+ messages: [
652
+ {
653
+ role: "user",
654
+ content: {
655
+ type: "text",
656
+ text: `Analyze this data and provide insights:\n\n${args.data}`,
657
+ },
658
+ },
659
+ ],
660
+ }),
661
+ });
662
+
663
+ // Resource
664
+ const statusResource = createResource({
665
+ uri: "status://server",
666
+ name: "Server Status",
667
+ description: "Current server status and metrics",
668
+ mimeType: "application/json",
669
+ read: async () => ({
670
+ uri: "status://server",
671
+ mimeType: "application/json",
672
+ text: JSON.stringify({
673
+ status: "healthy",
674
+ uptime: process.uptime(),
675
+ timestamp: new Date().toISOString(),
676
+ }),
677
+ }),
678
+ });
679
+
680
+ // Export the MCP server
681
+ export default withRuntime({
682
+ tools: [
683
+ () => echoTool,
684
+ () => getProfileTool,
685
+ ],
686
+ prompts: [
687
+ () => analyzePrompt,
688
+ ],
689
+ resources: [
690
+ () => statusResource,
691
+ ],
692
+ cors: {
693
+ origin: "*",
694
+ credentials: true,
695
+ },
696
+ });
697
+ ```
698
+
699
+ ## API Reference
700
+
701
+ ### Types
702
+
703
+ - `Tool<TSchemaIn, TSchemaOut>` - Tool definition with typed input/output
704
+ - `StreamableTool<TSchemaIn>` - Tool that returns streaming Response
705
+ - `Prompt<TArgs>` - Prompt definition with typed arguments
706
+ - `Resource` - Resource definition
707
+ - `OAuthConfig` - OAuth configuration
708
+ - `CreateMCPServerOptions` - Full options for withRuntime
709
+ - `RequestContext` - Request context with auth and bindings
710
+ - `AppContext` - Runtime context passed to execute functions
711
+
712
+ ### Functions
713
+
714
+ - `withRuntime(options)` - Create an MCP server
715
+ - `createTool(opts)` - Create a public tool
716
+ - `createPrivateTool(opts)` - Create an authenticated tool
717
+ - `createStreamableTool(opts)` - Create a streaming tool
718
+ - `createPrompt(opts)` - Create an authenticated prompt
719
+ - `createPublicPrompt(opts)` - Create a public prompt
720
+ - `createResource(opts)` - Create an authenticated resource
721
+ - `createPublicResource(opts)` - Create a public resource
722
+ - `BindingOf(name)` - Create a binding reference schema
723
+
724
+ ### Exports
725
+
726
+ ```typescript
727
+ // Main entry
728
+ import { withRuntime, createTool, ... } from "@decocms/runtime";
729
+
730
+ // Bindings utilities
731
+ import { impl, WellKnownBindings, ... } from "@decocms/runtime/bindings";
732
+
733
+ // MCP client utilities
734
+ import { createMCPFetchStub, MCPClient } from "@decocms/runtime/client";
735
+
736
+ // Proxy utilities
737
+ import { ... } from "@decocms/runtime/proxy";
738
+
739
+ // Tool utilities
740
+ import { ... } from "@decocms/runtime/tools";
741
+ ```
742
+
743
+ ## Deployment
744
+
745
+ The server works with any Web Standard runtime:
746
+
747
+ **Cloudflare Workers:**
748
+ ```typescript
749
+ export default withRuntime({ tools: [...] });
750
+ ```
751
+
752
+ **Bun:**
753
+ ```typescript
754
+ const server = withRuntime({ tools: [...] });
755
+ Bun.serve({
756
+ port: 3000,
757
+ fetch: server.fetch,
758
+ });
759
+ ```
760
+
761
+ **Node.js (with adapter):**
762
+ ```typescript
763
+ import { serve } from "@hono/node-server";
764
+ const server = withRuntime({ tools: [...] });
765
+ serve({ fetch: server.fetch, port: 3000 });
766
+ ```
767
+
768
+ ## License
769
+
770
+ See the root LICENSE.md file in the repository.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@cloudflare/workers-types": "^4.20250617.0",
11
- "@decocms/bindings": "^1.1.1",
12
- "@modelcontextprotocol/sdk": "1.25.2",
11
+ "@decocms/bindings": "^1.0.7",
12
+ "@modelcontextprotocol/sdk": "1.25.3",
13
13
  "@ai-sdk/provider": "^3.0.0",
14
14
  "hono": "^4.10.7",
15
15
  "jose": "^6.0.11",
package/src/oauth.ts CHANGED
@@ -56,11 +56,16 @@ interface PendingAuthState {
56
56
  clientState?: string;
57
57
  codeChallenge?: string;
58
58
  codeChallengeMethod?: string;
59
+ /** The clean callback URL used for OAuth (without state param) - used in token exchange */
60
+ oauthCallbackUri?: string;
59
61
  }
60
62
 
61
63
  interface CodePayload {
62
64
  accessToken: string;
63
65
  tokenType: string;
66
+ refreshToken?: string;
67
+ expiresIn?: number;
68
+ scope?: string;
64
69
  codeChallenge?: string;
65
70
  codeChallengeMethod?: string;
66
71
  }
@@ -154,17 +159,22 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
154
159
  );
155
160
  }
156
161
 
157
- // Encode pending auth state
162
+ // Build callback URL pointing to our internal callback (without state yet)
163
+ const callbackUrl = forceHttps(new URL(`${url.origin}/oauth/callback`));
164
+ // Store the clean callback URL for token exchange
165
+ const oauthCallbackUri = callbackUrl.toString();
166
+
167
+ // Encode pending auth state (including the clean callback URL)
158
168
  const pendingState: PendingAuthState = {
159
169
  redirectUri,
160
170
  clientState: clientState ?? undefined,
161
171
  codeChallenge: codeChallenge ?? undefined,
162
172
  codeChallengeMethod: codeChallengeMethod ?? undefined,
173
+ oauthCallbackUri,
163
174
  };
164
175
  const encodedState = encodeState(pendingState);
165
176
 
166
- // Build callback URL pointing to our internal callback
167
- const callbackUrl = forceHttps(new URL(`${url.origin}/oauth/callback`));
177
+ // Add state to callback URL
168
178
  callbackUrl.searchParams.set("state", encodedState);
169
179
 
170
180
  // Get the external authorization URL from the config
@@ -217,14 +227,26 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
217
227
  }
218
228
 
219
229
  try {
230
+ // Use the clean redirect_uri from the state (same URL used in authorization request)
231
+ // This ensures the exact same URL is used for token exchange
232
+ const cleanRedirectUri =
233
+ pending.oauthCallbackUri ??
234
+ forceHttps(new URL(`${url.origin}/oauth/callback`)).toString();
235
+
220
236
  // Exchange code with external provider
221
- const oauthParams: OAuthParams = { code };
237
+ const oauthParams: OAuthParams = {
238
+ code,
239
+ redirect_uri: cleanRedirectUri,
240
+ };
222
241
  const tokenResponse = await oauth.exchangeCode(oauthParams);
223
242
 
224
243
  // Encode the token in our own code (stateless)
225
244
  const codePayload: CodePayload = {
226
245
  accessToken: tokenResponse.access_token,
227
246
  tokenType: tokenResponse.token_type,
247
+ refreshToken: tokenResponse.refresh_token,
248
+ expiresIn: tokenResponse.expires_in,
249
+ scope: tokenResponse.scope,
228
250
  codeChallenge: pending.codeChallenge,
229
251
  codeChallengeMethod: pending.codeChallengeMethod,
230
252
  };
@@ -257,6 +279,7 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
257
279
 
258
280
  /**
259
281
  * Handle token exchange - decodes our code to get the actual token
282
+ * Supports both authorization_code and refresh_token grant types
260
283
  * Stateless: token is encoded in the code
261
284
  */
262
285
  const handleToken = async (req: Request): Promise<Response> => {
@@ -271,13 +294,63 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
271
294
  body = await req.json();
272
295
  }
273
296
 
274
- const { code, code_verifier, grant_type } = body;
297
+ const { code, code_verifier, grant_type, refresh_token } = body;
298
+
299
+ // Handle refresh_token grant type
300
+ if (grant_type === "refresh_token") {
301
+ if (!refresh_token) {
302
+ return Response.json(
303
+ {
304
+ error: "invalid_request",
305
+ error_description: "refresh_token is required",
306
+ },
307
+ { status: 400 },
308
+ );
309
+ }
310
+
311
+ if (!oauth.refreshToken) {
312
+ return Response.json(
313
+ {
314
+ error: "unsupported_grant_type",
315
+ error_description: "refresh_token grant not supported",
316
+ },
317
+ { status: 400 },
318
+ );
319
+ }
320
+
321
+ // Call the external provider to refresh the token
322
+ const newTokenResponse = await oauth.refreshToken(refresh_token);
323
+
324
+ const tokenResponse: Record<string, unknown> = {
325
+ access_token: newTokenResponse.access_token,
326
+ token_type: newTokenResponse.token_type,
327
+ };
328
+
329
+ if (newTokenResponse.refresh_token) {
330
+ tokenResponse.refresh_token = newTokenResponse.refresh_token;
331
+ }
332
+ if (newTokenResponse.expires_in !== undefined) {
333
+ tokenResponse.expires_in = newTokenResponse.expires_in;
334
+ }
335
+ if (newTokenResponse.scope) {
336
+ tokenResponse.scope = newTokenResponse.scope;
337
+ }
338
+
339
+ return Response.json(tokenResponse, {
340
+ headers: {
341
+ "Cache-Control": "no-store",
342
+ Pragma: "no-cache",
343
+ },
344
+ });
345
+ }
275
346
 
347
+ // Handle authorization_code grant type
276
348
  if (grant_type !== "authorization_code") {
277
349
  return Response.json(
278
350
  {
279
351
  error: "unsupported_grant_type",
280
- error_description: "Only authorization_code supported",
352
+ error_description:
353
+ "Only authorization_code and refresh_token supported",
281
354
  },
282
355
  { status: 400 },
283
356
  );
@@ -339,19 +412,29 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
339
412
  }
340
413
  }
341
414
 
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
- },
415
+ // Return the actual token with all fields
416
+ const tokenResponse: Record<string, unknown> = {
417
+ access_token: payload.accessToken,
418
+ token_type: payload.tokenType,
419
+ };
420
+
421
+ // Include optional fields if present
422
+ if (payload.refreshToken) {
423
+ tokenResponse.refresh_token = payload.refreshToken;
424
+ }
425
+ if (payload.expiresIn !== undefined) {
426
+ tokenResponse.expires_in = payload.expiresIn;
427
+ }
428
+ if (payload.scope) {
429
+ tokenResponse.scope = payload.scope;
430
+ }
431
+
432
+ return Response.json(tokenResponse, {
433
+ headers: {
434
+ "Cache-Control": "no-store",
435
+ Pragma: "no-cache",
353
436
  },
354
- );
437
+ });
355
438
  } catch (err) {
356
439
  console.error("Token exchange error:", err);
357
440
  return Response.json(
package/src/tools.ts CHANGED
@@ -349,6 +349,11 @@ export interface OAuthParams {
349
349
  code: string;
350
350
  code_verifier?: string;
351
351
  code_challenge_method?: "S256" | "plain";
352
+ /**
353
+ * The redirect_uri used in the authorization request (without state/extra params).
354
+ * This is the clean callback URL that should be used for token exchange.
355
+ */
356
+ redirect_uri?: string;
352
357
  }
353
358
 
354
359
  export interface OAuthTokenResponse {
@@ -398,6 +403,11 @@ export interface OAuthConfig {
398
403
  * Called when the OAuth callback is received with a code
399
404
  */
400
405
  exchangeCode: (oauthParams: OAuthParams) => Promise<OAuthTokenResponse>;
406
+ /**
407
+ * Refreshes the access token using a refresh token
408
+ * Called when the client requests a new access token with grant_type=refresh_token
409
+ */
410
+ refreshToken?: (refreshToken: string) => Promise<OAuthTokenResponse>;
401
411
  /**
402
412
  * Optional: persistence for dynamic client registration (RFC7591)
403
413
  * If not provided, clients are accepted without validation
@@ -850,16 +860,46 @@ export const createMCPServer = <
850
860
  await server.connect(transport);
851
861
 
852
862
  try {
853
- return await transport.handleRequest(req);
854
- } finally {
855
- // CRITICAL: Close transport to prevent memory leaks
856
- // Without this, ReadableStream/WritableStream controllers accumulate
857
- // causing thousands of stream objects to be retained in memory
863
+ const response = await transport.handleRequest(req);
864
+
865
+ // Check if this is a streaming response (SSE or streamable tool)
866
+ // SSE responses have text/event-stream content-type
867
+ // Note: response.body is always non-null for all HTTP responses, so we can't use it to detect streaming
868
+ const contentType = response.headers.get("content-type");
869
+ const isStreaming =
870
+ contentType?.includes("text/event-stream") ||
871
+ contentType?.includes("application/json-rpc");
872
+
873
+ // Only close transport for non-streaming responses
874
+ if (!isStreaming) {
875
+ console.debug(
876
+ "[MCP Transport] Closing transport for non-streaming response",
877
+ );
878
+ try {
879
+ await transport.close?.();
880
+ } catch {
881
+ // Ignore close errors
882
+ }
883
+ } else {
884
+ console.debug(
885
+ "[MCP Transport] Keeping transport open for streaming response (Content-Type: %s)",
886
+ contentType,
887
+ );
888
+ }
889
+
890
+ return response;
891
+ } catch (error) {
892
+ // On error, always try to close transport to prevent leaks
893
+ console.debug(
894
+ "[MCP Transport] Closing transport due to error:",
895
+ error instanceof Error ? error.message : error,
896
+ );
858
897
  try {
859
898
  await transport.close?.();
860
899
  } catch {
861
- // Ignore close errors - transport may already be closed
900
+ // Ignore close errors
862
901
  }
902
+ throw error;
863
903
  }
864
904
  };
865
905