@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 +770 -0
- package/package.json +3 -3
- package/src/oauth.ts +101 -18
- package/src/tools.ts +46 -6
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.
|
|
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.
|
|
12
|
-
"@modelcontextprotocol/sdk": "1.25.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 = {
|
|
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:
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
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
|
|
900
|
+
// Ignore close errors
|
|
862
901
|
}
|
|
902
|
+
throw error;
|
|
863
903
|
}
|
|
864
904
|
};
|
|
865
905
|
|