@decocms/runtime 1.2.5 → 1.2.7
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 +8 -3
- package/scripts/generate-json-schema.ts +8 -3
- package/src/oauth.ts +50 -9
- package/src/tools.ts +34 -3
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.7",
|
|
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",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=24.0.0"
|
|
33
33
|
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/decocms/mesh.git",
|
|
37
|
+
"directory": "packages/runtime"
|
|
38
|
+
},
|
|
34
39
|
"publishConfig": {
|
|
35
40
|
"access": "public"
|
|
36
41
|
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// heavily inspired by https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/scripts/generate-json-schema.ts
|
|
2
2
|
import { writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { createGenerator } from "ts-json-schema-generator";
|
|
5
6
|
import type { Config, Schema } from "ts-json-schema-generator";
|
|
6
7
|
|
|
8
|
+
// Use standard ESM __dirname pattern for cross-runtime compatibility
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
7
11
|
const config: Config = {
|
|
8
|
-
path: join(
|
|
12
|
+
path: join(__dirname, "../src/wrangler.ts"),
|
|
13
|
+
tsconfig: join(__dirname, "../tsconfig.json"),
|
|
9
14
|
type: "WranglerConfig",
|
|
10
15
|
skipTypeCheck: true,
|
|
11
16
|
};
|
|
@@ -19,6 +24,6 @@ const schema = applyFormattingRules(
|
|
|
19
24
|
);
|
|
20
25
|
|
|
21
26
|
writeFileSync(
|
|
22
|
-
join(
|
|
27
|
+
join(__dirname, "../config-schema.json"),
|
|
23
28
|
JSON.stringify(schema, null, 2),
|
|
24
29
|
);
|
package/src/oauth.ts
CHANGED
|
@@ -70,6 +70,27 @@ interface CodePayload {
|
|
|
70
70
|
codeChallengeMethod?: string;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* OAuth 2.0/2.1 Token Request Parameters
|
|
75
|
+
* Per RFC 6749 (OAuth 2.0) and RFC 9207 (OAuth 2.1)
|
|
76
|
+
* All parameters are strings when properly formatted
|
|
77
|
+
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
|
78
|
+
*/
|
|
79
|
+
interface OAuthTokenRequestBody {
|
|
80
|
+
/** REQUIRED for authorization_code grant - The authorization code from callback */
|
|
81
|
+
code?: string;
|
|
82
|
+
/** OPTIONAL - PKCE code verifier (RFC 7636) */
|
|
83
|
+
code_verifier?: string;
|
|
84
|
+
/** REQUIRED - Grant type: "authorization_code" or "refresh_token" */
|
|
85
|
+
grant_type?: string;
|
|
86
|
+
/** REQUIRED for refresh_token grant - The refresh token */
|
|
87
|
+
refresh_token?: string;
|
|
88
|
+
/** OPTIONAL - Redirect URI used in authorization request */
|
|
89
|
+
redirect_uri?: string;
|
|
90
|
+
/** Additional provider-specific parameters */
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
const forceHttps = (url: URL) => {
|
|
74
95
|
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
75
96
|
if (!isLocal) {
|
|
@@ -285,24 +306,41 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
|
|
|
285
306
|
const handleToken = async (req: Request): Promise<Response> => {
|
|
286
307
|
try {
|
|
287
308
|
const contentType = req.headers.get("content-type") ?? "";
|
|
288
|
-
let body: Record<string,
|
|
309
|
+
let body: Record<string, unknown>;
|
|
289
310
|
|
|
290
311
|
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
291
312
|
const formData = await req.formData();
|
|
292
|
-
body = Object.fromEntries(formData.entries())
|
|
313
|
+
body = Object.fromEntries(formData.entries());
|
|
293
314
|
} else {
|
|
294
|
-
|
|
315
|
+
const jsonBody = await req.json();
|
|
316
|
+
if (
|
|
317
|
+
typeof jsonBody !== "object" ||
|
|
318
|
+
jsonBody === null ||
|
|
319
|
+
Array.isArray(jsonBody)
|
|
320
|
+
) {
|
|
321
|
+
return Response.json(
|
|
322
|
+
{
|
|
323
|
+
error: "invalid_request",
|
|
324
|
+
error_description: "Request body must be a JSON object",
|
|
325
|
+
},
|
|
326
|
+
{ status: 400 },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
body = jsonBody as Record<string, unknown>;
|
|
295
330
|
}
|
|
296
331
|
|
|
332
|
+
// Extract and validate OAuth parameters
|
|
333
|
+
// Per RFC 6749, all parameters should be strings, but we validate at runtime
|
|
297
334
|
const { code, code_verifier, grant_type, refresh_token } = body;
|
|
298
335
|
|
|
299
336
|
// Handle refresh_token grant type
|
|
300
337
|
if (grant_type === "refresh_token") {
|
|
301
|
-
if (!refresh_token) {
|
|
338
|
+
if (typeof refresh_token !== "string" || !refresh_token) {
|
|
302
339
|
return Response.json(
|
|
303
340
|
{
|
|
304
341
|
error: "invalid_request",
|
|
305
|
-
error_description:
|
|
342
|
+
error_description:
|
|
343
|
+
"refresh_token is required and must be a string",
|
|
306
344
|
},
|
|
307
345
|
{ status: 400 },
|
|
308
346
|
);
|
|
@@ -356,9 +394,12 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
|
|
|
356
394
|
);
|
|
357
395
|
}
|
|
358
396
|
|
|
359
|
-
if (!code) {
|
|
397
|
+
if (typeof code !== "string" || !code) {
|
|
360
398
|
return Response.json(
|
|
361
|
-
{
|
|
399
|
+
{
|
|
400
|
+
error: "invalid_request",
|
|
401
|
+
error_description: "code is required and must be a string",
|
|
402
|
+
},
|
|
362
403
|
{ status: 400 },
|
|
363
404
|
);
|
|
364
405
|
}
|
|
@@ -377,11 +418,11 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
|
|
|
377
418
|
|
|
378
419
|
// Verify PKCE if code challenge was provided
|
|
379
420
|
if (payload.codeChallenge) {
|
|
380
|
-
if (!code_verifier) {
|
|
421
|
+
if (typeof code_verifier !== "string" || !code_verifier) {
|
|
381
422
|
return Response.json(
|
|
382
423
|
{
|
|
383
424
|
error: "invalid_grant",
|
|
384
|
-
error_description: "code_verifier required",
|
|
425
|
+
error_description: "code_verifier required and must be a string",
|
|
385
426
|
},
|
|
386
427
|
{ status: 400 },
|
|
387
428
|
);
|
package/src/tools.ts
CHANGED
|
@@ -345,39 +345,70 @@ export interface OnChangeCallback<TState> {
|
|
|
345
345
|
scopes: string[];
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* OAuth 2.0 Token Exchange Parameters
|
|
350
|
+
* Parameters passed to exchangeCode() for token retrieval
|
|
351
|
+
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
|
352
|
+
*/
|
|
348
353
|
export interface OAuthParams {
|
|
354
|
+
/** REQUIRED - The authorization code received from the authorization server */
|
|
349
355
|
code: string;
|
|
356
|
+
/** OPTIONAL - PKCE code verifier (RFC 7636) */
|
|
350
357
|
code_verifier?: string;
|
|
358
|
+
/** OPTIONAL - Code challenge method: S256 (SHA-256) or plain */
|
|
351
359
|
code_challenge_method?: "S256" | "plain";
|
|
352
360
|
/**
|
|
353
|
-
* The redirect_uri used in the authorization request
|
|
354
|
-
*
|
|
361
|
+
* OPTIONAL - The redirect_uri used in the authorization request
|
|
362
|
+
* MUST be identical if included in the authorization request
|
|
355
363
|
*/
|
|
356
364
|
redirect_uri?: string;
|
|
357
365
|
}
|
|
358
366
|
|
|
367
|
+
/**
|
|
368
|
+
* OAuth 2.0 Token Response
|
|
369
|
+
* Response from the authorization server's token endpoint
|
|
370
|
+
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
|
|
371
|
+
*/
|
|
359
372
|
export interface OAuthTokenResponse {
|
|
373
|
+
/** REQUIRED - The access token issued by the authorization server */
|
|
360
374
|
access_token: string;
|
|
375
|
+
/** REQUIRED - Type of token (usually "Bearer" per RFC 6750) */
|
|
361
376
|
token_type: string;
|
|
377
|
+
/** RECOMMENDED - Lifetime in seconds of the access token */
|
|
362
378
|
expires_in?: number;
|
|
379
|
+
/** OPTIONAL - Used to obtain new access tokens (if applicable) */
|
|
363
380
|
refresh_token?: string;
|
|
381
|
+
/** OPTIONAL - Scope of the access token (if different from requested) */
|
|
364
382
|
scope?: string;
|
|
383
|
+
/** Additional provider-specific fields */
|
|
365
384
|
[key: string]: unknown;
|
|
366
385
|
}
|
|
367
386
|
|
|
368
387
|
/**
|
|
369
|
-
* OAuth
|
|
388
|
+
* OAuth 2.0 Client Metadata (Dynamic Client Registration)
|
|
389
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7591#section-2
|
|
390
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1
|
|
370
391
|
*/
|
|
371
392
|
export interface OAuthClient {
|
|
393
|
+
/** REQUIRED - OAuth 2.0 client identifier string */
|
|
372
394
|
client_id: string;
|
|
395
|
+
/** OPTIONAL - OAuth 2.0 client secret string (confidential clients) */
|
|
373
396
|
client_secret?: string;
|
|
397
|
+
/** OPTIONAL - Human-readable name of the client */
|
|
374
398
|
client_name?: string;
|
|
399
|
+
/** REQUIRED - Array of redirect URIs for use in redirect-based flows */
|
|
375
400
|
redirect_uris: string[];
|
|
401
|
+
/** OPTIONAL - Array of OAuth 2.0 grant types (e.g., "authorization_code", "refresh_token") */
|
|
376
402
|
grant_types?: string[];
|
|
403
|
+
/** OPTIONAL - Array of response types (e.g., "code", "token") */
|
|
377
404
|
response_types?: string[];
|
|
405
|
+
/** OPTIONAL - Authentication method for the token endpoint (e.g., "client_secret_basic", "none") */
|
|
378
406
|
token_endpoint_auth_method?: string;
|
|
407
|
+
/** OPTIONAL - Space-separated list of scope values */
|
|
379
408
|
scope?: string;
|
|
409
|
+
/** OPTIONAL - Time at which the client identifier was issued (Unix timestamp) */
|
|
380
410
|
client_id_issued_at?: number;
|
|
411
|
+
/** OPTIONAL - Time at which the client secret expires (Unix timestamp, 0 = never) */
|
|
381
412
|
client_secret_expires_at?: number;
|
|
382
413
|
}
|
|
383
414
|
|