effect-cf 0.34.0 → 0.36.0
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/CHANGELOG.md +16 -0
- package/dist/ComputerWorkspace.d.mts +11 -3
- package/dist/ComputerWorkspace.mjs +31 -9
- package/dist/DurableObjectState-CpgAmFqo.d.mts +244 -0
- package/dist/{DurableObjectState-De4ZojhY.mjs → DurableObjectState-DCh4PZMN.mjs} +50 -76
- package/dist/Mcp.d.mts +122 -0
- package/dist/Mcp.mjs +270 -0
- package/dist/Vitest.d.mts +2 -2
- package/dist/Vitest.mjs +8 -10
- package/dist/{DurableObjectState-CrEunfQ-.d.mts → Worker-Dl6_Pijl.d.mts} +35 -269
- package/dist/Worker-XsGJsCJX.mjs +885 -0
- package/dist/WorkerContext-Yobnfw-K.mjs +60 -0
- package/dist/{WorkflowBinding-BumVQTuW.d.mts → WorkflowBinding-CvJT9hB5.d.mts} +1 -1
- package/dist/index.d.mts +13 -4
- package/dist/index.mjs +151 -1002
- package/package.json +12 -1
- package/src/AnalyticsEngine.ts +8 -1
- package/src/CloudflareOtlp.ts +6 -3
- package/src/ComputerWorkspace.ts +53 -26
- package/src/DurableObject.ts +8 -9
- package/src/DurableObjectNamespace.ts +33 -5
- package/src/DurableObjectRpcWebSocket.ts +2 -27
- package/src/DurableObjectState.ts +18 -20
- package/src/DurableObjectStorage.ts +53 -35
- package/src/DurableObjectWebSocket.ts +23 -41
- package/src/Mcp.ts +531 -0
- package/src/RpcDefinition.ts +5 -15
- package/src/ServiceBinding.ts +37 -5
- package/src/Vitest.ts +18 -15
- package/src/Worker.ts +107 -54
- package/src/WorkersAi.ts +21 -14
- package/src/Workflow.ts +45 -49
- package/src/internal/NativeCallback.ts +55 -0
- package/src/internal/WorkerContext.ts +5 -3
package/src/Mcp.ts
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMcpHandler,
|
|
3
|
+
hostHeaderValidationResponse,
|
|
4
|
+
localhostAllowedHostnames,
|
|
5
|
+
localhostAllowedOrigins,
|
|
6
|
+
McpServer,
|
|
7
|
+
originValidationResponse,
|
|
8
|
+
type CallToolResult,
|
|
9
|
+
type CreateMcpHandlerOptions,
|
|
10
|
+
type JSONObject,
|
|
11
|
+
type ServerOptions,
|
|
12
|
+
type StandardSchemaWithJSON,
|
|
13
|
+
} from "@modelcontextprotocol/server";
|
|
14
|
+
import {
|
|
15
|
+
Cause,
|
|
16
|
+
Context,
|
|
17
|
+
Effect,
|
|
18
|
+
Exit,
|
|
19
|
+
type JsonSchema,
|
|
20
|
+
Option,
|
|
21
|
+
Predicate,
|
|
22
|
+
Schema,
|
|
23
|
+
Sink,
|
|
24
|
+
Stream,
|
|
25
|
+
} from "effect";
|
|
26
|
+
import { AiError, Tool, type Toolkit } from "effect/unstable/ai";
|
|
27
|
+
|
|
28
|
+
import { NativeRequest } from "./Worker";
|
|
29
|
+
import { runNativeCallback } from "./internal/NativeCallback";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* CORS headers applied to MCP responses.
|
|
33
|
+
*/
|
|
34
|
+
export interface CorsOptions {
|
|
35
|
+
readonly origin?: string;
|
|
36
|
+
readonly methods?: string;
|
|
37
|
+
readonly headers?: string;
|
|
38
|
+
readonly maxAge?: number;
|
|
39
|
+
readonly exposeHeaders?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Options for {@link fromToolkit}.
|
|
44
|
+
*
|
|
45
|
+
* Extends the MCP SDK `createMcpHandler` options (`legacy`, `responseMode`,
|
|
46
|
+
* `onerror`, ...) with the server identity advertised to MCP clients and the
|
|
47
|
+
* Worker edge options (`route`, `corsOptions`, `allowedHostnames`,
|
|
48
|
+
* `allowedOriginHostnames`).
|
|
49
|
+
*/
|
|
50
|
+
export interface FromToolkitOptions extends CreateMcpHandlerOptions {
|
|
51
|
+
/** Server name advertised in the MCP implementation info. */
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/** Server version advertised in the MCP implementation info. */
|
|
54
|
+
readonly version: string;
|
|
55
|
+
/** Optional human-readable server title advertised to MCP clients. */
|
|
56
|
+
readonly title?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Options passed to the underlying MCP SDK `McpServer` (capabilities,
|
|
59
|
+
* instructions, ...).
|
|
60
|
+
*/
|
|
61
|
+
readonly serverOptions?: ServerOptions;
|
|
62
|
+
/** Pathname served by the MCP handler. @default "/mcp" */
|
|
63
|
+
readonly route?: string;
|
|
64
|
+
/** CORS headers applied to every MCP response. Pass `false` to disable. */
|
|
65
|
+
readonly corsOptions?: CorsOptions | false;
|
|
66
|
+
/**
|
|
67
|
+
* Restrict `Host` headers to these hostnames. Localhost and `workers.dev`
|
|
68
|
+
* endpoints receive matching defaults; custom domains rely on Cloudflare
|
|
69
|
+
* routing unless this option is set.
|
|
70
|
+
*/
|
|
71
|
+
readonly allowedHostnames?: ReadonlyArray<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Restrict present browser `Origin` headers to these hostnames. Requests
|
|
74
|
+
* without an Origin (including non-browser MCP clients) remain valid. The
|
|
75
|
+
* default includes localhost-class Origins, the endpoint's `workers.dev`
|
|
76
|
+
* hostname, and a concrete `corsOptions.origin` hostname. Pass `"*"` only
|
|
77
|
+
* when equivalent Origin validation runs in trusted middleware upstream.
|
|
78
|
+
*/
|
|
79
|
+
readonly allowedOriginHostnames?: ReadonlyArray<string> | "*";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Services required to execute the tools of a toolkit: the tool handlers plus
|
|
84
|
+
* every service those handlers and their parameter/result codecs need.
|
|
85
|
+
*/
|
|
86
|
+
export type ToolkitServices<Tools extends Record<string, Tool.Any>> =
|
|
87
|
+
| Tool.HandlersFor<Tools>
|
|
88
|
+
| Tool.HandlerServices<Tools[keyof Tools]>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Context required by the Effect returned from {@link fromToolkit}: the native
|
|
92
|
+
* request provided by `Worker.make` plus the toolkit services.
|
|
93
|
+
*/
|
|
94
|
+
export type FromToolkitContext<Tools extends Record<string, Tool.Any>> =
|
|
95
|
+
| NativeRequest
|
|
96
|
+
| ToolkitServices<Tools>;
|
|
97
|
+
|
|
98
|
+
const DEFAULT_CORS_OPTIONS: Required<CorsOptions> = {
|
|
99
|
+
origin: "*",
|
|
100
|
+
headers:
|
|
101
|
+
"Content-Type, Accept, Authorization, mcp-session-id, MCP-Protocol-Version, Mcp-Method, Mcp-Name",
|
|
102
|
+
methods: "GET, POST, DELETE, OPTIONS",
|
|
103
|
+
exposeHeaders: "mcp-session-id",
|
|
104
|
+
maxAge: 86400,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const corsHeaders = (options: CorsOptions): Headers => {
|
|
108
|
+
const merged = { ...DEFAULT_CORS_OPTIONS, ...options };
|
|
109
|
+
|
|
110
|
+
return new Headers({
|
|
111
|
+
"Access-Control-Allow-Headers": merged.headers,
|
|
112
|
+
"Access-Control-Allow-Methods": merged.methods,
|
|
113
|
+
"Access-Control-Allow-Origin": merged.origin,
|
|
114
|
+
"Access-Control-Expose-Headers": merged.exposeHeaders,
|
|
115
|
+
"Access-Control-Max-Age": String(merged.maxAge),
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const withCors = (response: Response, options: CorsOptions | false | undefined): Response => {
|
|
120
|
+
if (options === false) {
|
|
121
|
+
return response;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const headers = new Headers(response.headers);
|
|
125
|
+
|
|
126
|
+
for (const [headerName, value] of corsHeaders(options ?? {})) {
|
|
127
|
+
headers.set(headerName, value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Response(response.body, {
|
|
131
|
+
status: response.status,
|
|
132
|
+
statusText: response.statusText,
|
|
133
|
+
headers,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const corsOriginHostname = (corsOptions: CorsOptions | false | undefined): string | undefined => {
|
|
138
|
+
const origin = corsOptions === false ? undefined : corsOptions?.origin;
|
|
139
|
+
|
|
140
|
+
if (origin === undefined || !URL.canParse(origin)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const url = new URL(origin);
|
|
145
|
+
|
|
146
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== ""
|
|
147
|
+
? url.hostname
|
|
148
|
+
: undefined;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
interface EdgeGuardOptions {
|
|
152
|
+
readonly corsOptions: CorsOptions | false | undefined;
|
|
153
|
+
readonly allowedHostnames: ReadonlyArray<string> | undefined;
|
|
154
|
+
readonly allowedOriginHostnames: ReadonlyArray<string> | "*" | undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `Host` and `Origin` validation for the MCP route, using the MCP SDK's
|
|
159
|
+
* validation primitives with the same defaults Cloudflare applies: localhost
|
|
160
|
+
* and `workers.dev` endpoints validate the `Host` header against themselves,
|
|
161
|
+
* and present browser `Origin` headers must match localhost-class Origins, the
|
|
162
|
+
* endpoint's `workers.dev` hostname, or a concrete configured CORS origin.
|
|
163
|
+
*/
|
|
164
|
+
const edgeGuardResponse = (
|
|
165
|
+
request: Request,
|
|
166
|
+
requestUrl: URL,
|
|
167
|
+
options: EdgeGuardOptions,
|
|
168
|
+
): Response | undefined => {
|
|
169
|
+
const localEndpoint = localhostAllowedHostnames().includes(requestUrl.hostname);
|
|
170
|
+
const workersDevEndpoint = requestUrl.hostname.endsWith(".workers.dev");
|
|
171
|
+
const acceptedHostnames =
|
|
172
|
+
options.allowedHostnames ??
|
|
173
|
+
(localEndpoint
|
|
174
|
+
? localhostAllowedHostnames()
|
|
175
|
+
: workersDevEndpoint
|
|
176
|
+
? [requestUrl.hostname]
|
|
177
|
+
: undefined);
|
|
178
|
+
|
|
179
|
+
if (acceptedHostnames !== undefined) {
|
|
180
|
+
const hostRejection = hostHeaderValidationResponse(request, [...acceptedHostnames]);
|
|
181
|
+
|
|
182
|
+
if (hostRejection !== undefined) {
|
|
183
|
+
return hostRejection;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (options.allowedOriginHostnames === "*") {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let acceptedOriginHostnames = options.allowedOriginHostnames;
|
|
192
|
+
|
|
193
|
+
if (acceptedOriginHostnames === undefined) {
|
|
194
|
+
const defaults = new Set(localhostAllowedOrigins());
|
|
195
|
+
|
|
196
|
+
if (workersDevEndpoint) {
|
|
197
|
+
defaults.add(requestUrl.hostname);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const configuredOriginHostname = corsOriginHostname(options.corsOptions);
|
|
201
|
+
|
|
202
|
+
if (configuredOriginHostname !== undefined) {
|
|
203
|
+
defaults.add(configuredOriginHostname);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
acceptedOriginHostnames = [...defaults];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return originValidationResponse(request, [...acceptedOriginHostnames]);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error.";
|
|
213
|
+
|
|
214
|
+
const toolErrorResult = (message: string): CallToolResult => ({
|
|
215
|
+
isError: true,
|
|
216
|
+
content: [{ type: "text", text: message }],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Wraps a JSON Schema produced by Effect `Schema` as the Standard Schema shape
|
|
221
|
+
* accepted by MCP SDK v2 `registerTool`, with a passthrough `validate`.
|
|
222
|
+
*
|
|
223
|
+
* Used for output schemas and for dynamic tools whose parameters are already a
|
|
224
|
+
* JSON Schema: the toolkit performs its own decoding, so there is nothing
|
|
225
|
+
* additional to validate here.
|
|
226
|
+
*/
|
|
227
|
+
const standardSchemaFromJsonSchema = (
|
|
228
|
+
jsonSchema: JsonSchema.JsonSchema,
|
|
229
|
+
): StandardSchemaWithJSON => ({
|
|
230
|
+
"~standard": {
|
|
231
|
+
version: 1,
|
|
232
|
+
vendor: "effect-cf",
|
|
233
|
+
validate: (value) => ({ value }),
|
|
234
|
+
jsonSchema: {
|
|
235
|
+
input: () => jsonSchema,
|
|
236
|
+
output: () => jsonSchema,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Builds the tool input schema registered with the MCP SDK.
|
|
243
|
+
*
|
|
244
|
+
* The SDK validates incoming `tools/call` arguments with `~standard.validate`
|
|
245
|
+
* and answers validation failures with an MCP invalid-params error, so the
|
|
246
|
+
* tool's Effect parameters `Schema` backs `validate`. On success the original
|
|
247
|
+
* encoded arguments are kept: the toolkit decodes them itself when the tool
|
|
248
|
+
* handler executes.
|
|
249
|
+
*/
|
|
250
|
+
const standardInputSchema = (tool: Tool.Any): StandardSchemaWithJSON => {
|
|
251
|
+
const jsonSchema = Tool.getJsonSchema(tool);
|
|
252
|
+
|
|
253
|
+
if (!Schema.isSchema(tool.parametersSchema)) {
|
|
254
|
+
return standardSchemaFromJsonSchema(jsonSchema);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// SAFETY: MCP tool-call arguments arrive without an Effect service context,
|
|
258
|
+
// so the parameters schema must decode without services; the toolkit decodes
|
|
259
|
+
// the same schema service-free when the tool handler executes.
|
|
260
|
+
const parametersSchema = tool.parametersSchema as Schema.Top & Schema.ConstraintDecoder<unknown>;
|
|
261
|
+
const validate = Schema.toStandardSchemaV1(parametersSchema)["~standard"].validate;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
"~standard": {
|
|
265
|
+
version: 1,
|
|
266
|
+
vendor: "effect-cf",
|
|
267
|
+
validate: (value) => {
|
|
268
|
+
const result = validate(value);
|
|
269
|
+
|
|
270
|
+
if (result instanceof Promise) {
|
|
271
|
+
return result.then((awaited) => (awaited.issues === undefined ? { value } : awaited));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result.issues === undefined ? { value } : result;
|
|
275
|
+
},
|
|
276
|
+
jsonSchema: {
|
|
277
|
+
input: () => jsonSchema,
|
|
278
|
+
output: () => jsonSchema,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
type RunToolCallback<R> = (
|
|
285
|
+
effect: Effect.Effect<CallToolResult, never, R>,
|
|
286
|
+
) => Promise<Exit.Exit<CallToolResult, never>>;
|
|
287
|
+
|
|
288
|
+
const handleToolCall = <Tools extends Record<string, Tool.Any>>(
|
|
289
|
+
built: Toolkit.WithHandler<Tools>,
|
|
290
|
+
tool: Tool.Any,
|
|
291
|
+
params: Tool.Parameters<Tools[keyof Tools]>,
|
|
292
|
+
): Effect.Effect<CallToolResult, never, Tool.HandlerServices<Tools[keyof Tools]>> => {
|
|
293
|
+
const isDeclaredFailure = Schema.is(tool.failureSchema);
|
|
294
|
+
|
|
295
|
+
// SAFETY: registerToolkitTool only registers tools taken from `built.tools`,
|
|
296
|
+
// so the tool name is a key of the toolkit's tools record.
|
|
297
|
+
return built.handle(tool.name as keyof Tools, params).pipe(
|
|
298
|
+
Stream.unwrap,
|
|
299
|
+
Stream.run(Sink.last()),
|
|
300
|
+
Effect.flatMap(Effect.fromOption),
|
|
301
|
+
Effect.map((result): CallToolResult => {
|
|
302
|
+
if (result.isFailure) {
|
|
303
|
+
return toolErrorResult(JSON.stringify(result.encodedResult));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// SAFETY: encodedResult is the Schema-encoded tool result, so a
|
|
307
|
+
// non-array record value is a JSON object.
|
|
308
|
+
const structuredContent = Predicate.isObject(result.encodedResult)
|
|
309
|
+
? (result.encodedResult as JSONObject)
|
|
310
|
+
: undefined;
|
|
311
|
+
const content: CallToolResult["content"] =
|
|
312
|
+
result.encodedResult === undefined
|
|
313
|
+
? []
|
|
314
|
+
: [{ type: "text", text: JSON.stringify(result.encodedResult) }];
|
|
315
|
+
|
|
316
|
+
if (structuredContent === undefined) {
|
|
317
|
+
return { isError: false, content };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { isError: false, structuredContent, content };
|
|
321
|
+
}),
|
|
322
|
+
Effect.tapCause(Effect.logError),
|
|
323
|
+
Effect.catch((error) => {
|
|
324
|
+
if (AiError.isAiError(error)) {
|
|
325
|
+
// SAFETY: isAiError established the value is an AiError; TypeScript
|
|
326
|
+
// cannot narrow the generic handler-error union itself.
|
|
327
|
+
const reason = (error as AiError.AiError).reason;
|
|
328
|
+
|
|
329
|
+
return Effect.succeed(
|
|
330
|
+
toolErrorResult(
|
|
331
|
+
reason._tag === "ToolParameterValidationError"
|
|
332
|
+
? reason.message
|
|
333
|
+
: INTERNAL_TOOL_ERROR_MESSAGE,
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (isDeclaredFailure(error)) {
|
|
339
|
+
const message = error instanceof Error ? error.message : INTERNAL_TOOL_ERROR_MESSAGE;
|
|
340
|
+
|
|
341
|
+
return Effect.succeed(toolErrorResult(message));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return Effect.succeed(toolErrorResult(INTERNAL_TOOL_ERROR_MESSAGE));
|
|
345
|
+
}),
|
|
346
|
+
Effect.catchDefect(() => Effect.succeed(toolErrorResult(INTERNAL_TOOL_ERROR_MESSAGE))),
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const registerToolkitTool = <Tools extends Record<string, Tool.Any>>(
|
|
351
|
+
server: McpServer,
|
|
352
|
+
built: Toolkit.WithHandler<Tools>,
|
|
353
|
+
tool: Tool.Any,
|
|
354
|
+
run: RunToolCallback<Tool.HandlerServices<Tools[keyof Tools]>>,
|
|
355
|
+
): void => {
|
|
356
|
+
const outputJsonSchema = Tool.getJsonSchemaFromSchema(tool.successSchema);
|
|
357
|
+
const outputSchema =
|
|
358
|
+
outputJsonSchema.type === "object" ? standardSchemaFromJsonSchema(outputJsonSchema) : undefined;
|
|
359
|
+
const title = Context.getOrUndefined(tool.annotations, Tool.Title);
|
|
360
|
+
const toolMeta = Context.getOrUndefined(tool.annotations, Tool.Meta);
|
|
361
|
+
|
|
362
|
+
server.registerTool(
|
|
363
|
+
tool.name,
|
|
364
|
+
{
|
|
365
|
+
title,
|
|
366
|
+
description: Tool.getDescription(tool),
|
|
367
|
+
inputSchema: standardInputSchema(tool),
|
|
368
|
+
outputSchema,
|
|
369
|
+
annotations: {
|
|
370
|
+
title,
|
|
371
|
+
readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
|
|
372
|
+
destructiveHint: Context.get(tool.annotations, Tool.Destructive),
|
|
373
|
+
idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
|
|
374
|
+
openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
|
|
375
|
+
},
|
|
376
|
+
_meta: toolMeta,
|
|
377
|
+
},
|
|
378
|
+
async (args) => {
|
|
379
|
+
// SAFETY: the SDK validated the arguments with the tool input schema,
|
|
380
|
+
// whose `validate` keeps the encoded input, and the toolkit decodes the
|
|
381
|
+
// parameters with the tool's Effect `Schema` before invoking the
|
|
382
|
+
// handler.
|
|
383
|
+
const params = (args ?? {}) as Tool.Parameters<Tools[keyof Tools]>;
|
|
384
|
+
const exit = await run(handleToolCall(built, tool, params));
|
|
385
|
+
|
|
386
|
+
if (Exit.isSuccess(exit)) {
|
|
387
|
+
return exit.value;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw Cause.squash(exit.cause);
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Creates an Effect that serves a toolkit over MCP with the MCP SDK's
|
|
397
|
+
* stateless Streamable HTTP handler (`createMcpHandler` from
|
|
398
|
+
* `@modelcontextprotocol/server`), plus the Worker edge concerns Cloudflare's
|
|
399
|
+
* remote MCP deployments expect: CORS, `Host` header validation, and browser
|
|
400
|
+
* `Origin` validation.
|
|
401
|
+
*
|
|
402
|
+
* **Details**
|
|
403
|
+
*
|
|
404
|
+
* Yield the returned Effect inside a `Worker.make` fetch handler before other
|
|
405
|
+
* routing. When the request pathname matches `route` (default `"/mcp"`), the
|
|
406
|
+
* request is served by a fresh MCP server exposing every tool in the toolkit
|
|
407
|
+
* and the Effect succeeds with `Option.some(response)`. Any other pathname
|
|
408
|
+
* succeeds with `Option.none()` so the request can fall through to the rest of
|
|
409
|
+
* the fetch handler.
|
|
410
|
+
*
|
|
411
|
+
* Tool calls run through the caller's Effect context: provide the toolkit
|
|
412
|
+
* handlers (`toolkit.toLayer(...)`) and their services in the Worker layer or
|
|
413
|
+
* event layer. Tool parameters are decoded and results encoded with each
|
|
414
|
+
* tool's Effect `Schema`; parameter validation failures become MCP
|
|
415
|
+
* invalid-params errors, declared tool failures become tool results with
|
|
416
|
+
* `isError: true`, and unexpected errors are logged and reported to the client
|
|
417
|
+
* as an internal tool error.
|
|
418
|
+
*
|
|
419
|
+
* In-flight tool calls are awaited before the HTTP response is returned, so
|
|
420
|
+
* tool handlers always run to completion inside the fetch event, including on
|
|
421
|
+
* transport lanes that stream results into the response body.
|
|
422
|
+
*
|
|
423
|
+
* **Example**
|
|
424
|
+
*
|
|
425
|
+
* ```ts
|
|
426
|
+
* import { Effect, Layer, Option } from "effect"
|
|
427
|
+
* import { HttpRouter } from "effect/unstable/http"
|
|
428
|
+
* import { Worker } from "effect-cf"
|
|
429
|
+
* import * as Mcp from "effect-cf/mcp"
|
|
430
|
+
*
|
|
431
|
+
* const mcp = Mcp.fromToolkit(ActivityToolkit, {
|
|
432
|
+
* name: "strava-ingestor",
|
|
433
|
+
* version: "1.0.0",
|
|
434
|
+
* route: "/mcp",
|
|
435
|
+
* })
|
|
436
|
+
*
|
|
437
|
+
* const fetch = Effect.gen(function* () {
|
|
438
|
+
* const response = yield* mcp
|
|
439
|
+
* if (Option.isSome(response)) return response.value
|
|
440
|
+
* const router = yield* HttpRouter.HttpRouter
|
|
441
|
+
* return yield* router.asHttpEffect()
|
|
442
|
+
* })
|
|
443
|
+
*
|
|
444
|
+
* export default Worker.make(
|
|
445
|
+
* Layer.mergeAll(ApplicationLive, ActivityToolkitLive),
|
|
446
|
+
* { fetch },
|
|
447
|
+
* )
|
|
448
|
+
* ```
|
|
449
|
+
*/
|
|
450
|
+
export const fromToolkit = <Tools extends Record<string, Tool.Any>>(
|
|
451
|
+
toolkit: Toolkit.Toolkit<Tools>,
|
|
452
|
+
options: FromToolkitOptions,
|
|
453
|
+
): Effect.Effect<Option.Option<Response>, unknown, FromToolkitContext<Tools>> => {
|
|
454
|
+
const {
|
|
455
|
+
name,
|
|
456
|
+
version,
|
|
457
|
+
title,
|
|
458
|
+
serverOptions,
|
|
459
|
+
route = "/mcp",
|
|
460
|
+
corsOptions,
|
|
461
|
+
allowedHostnames,
|
|
462
|
+
allowedOriginHostnames,
|
|
463
|
+
...handlerOptions
|
|
464
|
+
} = options;
|
|
465
|
+
const serverInfo = { name, version, title };
|
|
466
|
+
|
|
467
|
+
return Effect.gen(function* () {
|
|
468
|
+
const request = yield* NativeRequest;
|
|
469
|
+
const requestUrl = new URL(request.url);
|
|
470
|
+
|
|
471
|
+
if (requestUrl.pathname !== route) {
|
|
472
|
+
return Option.none();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const rejection = edgeGuardResponse(request, requestUrl, {
|
|
476
|
+
corsOptions,
|
|
477
|
+
allowedHostnames,
|
|
478
|
+
allowedOriginHostnames,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (rejection !== undefined) {
|
|
482
|
+
return Option.some(withCors(rejection, corsOptions));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (request.method === "OPTIONS" && corsOptions !== false) {
|
|
486
|
+
return Option.some(new Response(null, { headers: corsHeaders(corsOptions ?? {}) }));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const built = yield* toolkit;
|
|
490
|
+
|
|
491
|
+
const response = yield* runNativeCallback<
|
|
492
|
+
CallToolResult,
|
|
493
|
+
never,
|
|
494
|
+
Tool.HandlerServices<Tools[keyof Tools]>,
|
|
495
|
+
Response
|
|
496
|
+
>(async (run) => {
|
|
497
|
+
// Some transport lanes (e.g. the 2025-era stateless fallback) resolve
|
|
498
|
+
// the HTTP response before in-flight tool handlers settle and stream the
|
|
499
|
+
// results into the response body afterwards. Track every tool-call
|
|
500
|
+
// effect and drain them before resolving, so the fetch Effect scope does
|
|
501
|
+
// not close (and interrupt them) mid-execution.
|
|
502
|
+
const pending = new Set<Promise<unknown>>();
|
|
503
|
+
const trackedRun: RunToolCallback<Tool.HandlerServices<Tools[keyof Tools]>> = (effect) => {
|
|
504
|
+
const promise = run(effect);
|
|
505
|
+
|
|
506
|
+
pending.add(promise);
|
|
507
|
+
void promise.finally(() => pending.delete(promise));
|
|
508
|
+
|
|
509
|
+
return promise;
|
|
510
|
+
};
|
|
511
|
+
const handler = createMcpHandler(() => {
|
|
512
|
+
const server = new McpServer(serverInfo, serverOptions);
|
|
513
|
+
|
|
514
|
+
for (const tool of Object.values(built.tools)) {
|
|
515
|
+
registerToolkitTool(server, built, tool, trackedRun);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return server;
|
|
519
|
+
}, handlerOptions);
|
|
520
|
+
const response = await handler.fetch(request);
|
|
521
|
+
|
|
522
|
+
while (pending.size > 0) {
|
|
523
|
+
await Promise.allSettled([...pending]);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return response;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
return Option.some(withCors(response, corsOptions));
|
|
530
|
+
});
|
|
531
|
+
};
|
package/src/RpcDefinition.ts
CHANGED
|
@@ -124,7 +124,7 @@ export const decodeWireError = (cause: WireValue): WireValue => {
|
|
|
124
124
|
}
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
const reservedMethodNameValues = [
|
|
128
128
|
"constructor",
|
|
129
129
|
"fetch",
|
|
130
130
|
"connect",
|
|
@@ -137,21 +137,11 @@ export const reservedMethodNames = new Set([
|
|
|
137
137
|
"dispose",
|
|
138
138
|
"serialize",
|
|
139
139
|
"deserialize",
|
|
140
|
-
]
|
|
140
|
+
] as const;
|
|
141
|
+
|
|
142
|
+
export const reservedMethodNames = new Set<string>(reservedMethodNameValues);
|
|
141
143
|
|
|
142
|
-
export type ReservedMethodName =
|
|
143
|
-
| "constructor"
|
|
144
|
-
| "fetch"
|
|
145
|
-
| "connect"
|
|
146
|
-
| "alarm"
|
|
147
|
-
| "webSocketMessage"
|
|
148
|
-
| "webSocketClose"
|
|
149
|
-
| "webSocketError"
|
|
150
|
-
| "then"
|
|
151
|
-
| "dup"
|
|
152
|
-
| "dispose"
|
|
153
|
-
| "serialize"
|
|
154
|
-
| "deserialize";
|
|
144
|
+
export type ReservedMethodName = (typeof reservedMethodNameValues)[number];
|
|
155
145
|
|
|
156
146
|
export type ServiceFreeSchema = S.Codec<any, any, never, never>;
|
|
157
147
|
|
package/src/ServiceBinding.ts
CHANGED
|
@@ -145,7 +145,11 @@ export type ServiceBindingEffectClient<
|
|
|
145
145
|
readonly scopedCall: <Method extends ServiceMethodKey<Api>>(
|
|
146
146
|
method: Method,
|
|
147
147
|
...args: ServiceMethodArgs<Api, Method>
|
|
148
|
-
) => Effect.Effect<
|
|
148
|
+
) => Effect.Effect<
|
|
149
|
+
Awaited<ServiceMethodSuccess<Api, Method>>,
|
|
150
|
+
ServiceBindingRpcError,
|
|
151
|
+
Scope.Scope
|
|
152
|
+
>;
|
|
149
153
|
};
|
|
150
154
|
|
|
151
155
|
export type ServiceBindingStaticClient<
|
|
@@ -168,7 +172,11 @@ export type ServiceBindingStaticClient<
|
|
|
168
172
|
readonly scopedCall: <Method extends ServiceMethodKey<Api>>(
|
|
169
173
|
method: Method,
|
|
170
174
|
...args: ServiceMethodArgs<Api, Method>
|
|
171
|
-
) => Effect.Effect<
|
|
175
|
+
) => Effect.Effect<
|
|
176
|
+
Awaited<ServiceMethodSuccess<Api, Method>>,
|
|
177
|
+
ServiceBindingRpcError,
|
|
178
|
+
Scope.Scope | R
|
|
179
|
+
>;
|
|
172
180
|
};
|
|
173
181
|
|
|
174
182
|
type ServiceBindingValue = Schema.Schema.Type<typeof Schema.Unknown>;
|
|
@@ -195,7 +203,22 @@ export const makeClient = <
|
|
|
195
203
|
init?: RequestInit,
|
|
196
204
|
) {
|
|
197
205
|
return yield* Effect.tryPromise({
|
|
198
|
-
try: () =>
|
|
206
|
+
try: (signal) => {
|
|
207
|
+
const callerSignal =
|
|
208
|
+
init?.signal !== undefined
|
|
209
|
+
? init.signal
|
|
210
|
+
: input instanceof Request
|
|
211
|
+
? input.signal
|
|
212
|
+
: undefined;
|
|
213
|
+
|
|
214
|
+
return service.fetch(input, {
|
|
215
|
+
...init,
|
|
216
|
+
signal:
|
|
217
|
+
callerSignal === null || callerSignal === undefined
|
|
218
|
+
? signal
|
|
219
|
+
: AbortSignal.any([callerSignal, signal]),
|
|
220
|
+
});
|
|
221
|
+
},
|
|
199
222
|
catch: (cause) => new ServiceBindingFetchError({ binding: definition.binding, cause }),
|
|
200
223
|
});
|
|
201
224
|
});
|
|
@@ -298,10 +321,19 @@ export const makeClient = <
|
|
|
298
321
|
function* <Method extends ServiceMethodKey<Api>>(
|
|
299
322
|
method: Method,
|
|
300
323
|
...args: ServiceMethodArgs<Api, Method>
|
|
301
|
-
): Effect.fn.Return<ServiceMethodSuccess<Api, Method>,
|
|
324
|
+
): Effect.fn.Return<ServiceMethodSuccess<Api, Method>, ServiceBindingRpcError, Scope.Scope> {
|
|
302
325
|
const methodName = String(method);
|
|
303
326
|
const result = yield* rpc(method, ...args);
|
|
304
|
-
const value = yield* CloudflareRpc.scoped(result)
|
|
327
|
+
const value = yield* CloudflareRpc.scoped(result).pipe(
|
|
328
|
+
Effect.mapError(
|
|
329
|
+
(cause) =>
|
|
330
|
+
new ServiceBindingRpcError({
|
|
331
|
+
binding: definition.binding,
|
|
332
|
+
method: methodName,
|
|
333
|
+
cause,
|
|
334
|
+
}),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
305
337
|
|
|
306
338
|
return yield* decodeSuccess<Method>(methodName, value);
|
|
307
339
|
},
|
package/src/Vitest.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "./DurableObjectState";
|
|
34
34
|
import { WorkerConfig, WorkerEnvironment, type WorkerEnv } from "./Environment";
|
|
35
35
|
import type { WorkflowInstanceStatusName } from "./WorkflowBinding";
|
|
36
|
+
import { runNativeCallback } from "./internal/NativeCallback";
|
|
36
37
|
|
|
37
38
|
const currentEnv: WorkerEnv = env;
|
|
38
39
|
|
|
@@ -207,15 +208,20 @@ export const runInDurableObject = Effect.fn("effect-cf/vitest/runInDurableObject
|
|
|
207
208
|
stub: globalThis.DurableObjectStub<Object>,
|
|
208
209
|
use: (instance: Object, state: DurableObjectStateService) => Effect.Effect<A, E, R>,
|
|
209
210
|
): Effect.fn.Return<A, E, Exclude<R, DurableObjectState>> {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
211
|
+
const exit = yield* runNativeCallback<A, E, Exclude<R, DurableObjectState>, Exit.Exit<A, E>>(
|
|
212
|
+
(run) =>
|
|
213
|
+
runInDurableObjectPromise(stub, (instance, nativeState) => {
|
|
214
|
+
const state = fromDurableObjectState(nativeState);
|
|
215
|
+
|
|
216
|
+
return run(
|
|
217
|
+
Effect.provideService(
|
|
218
|
+
Effect.suspend(() => use(instance, state)),
|
|
219
|
+
DurableObjectState,
|
|
220
|
+
state,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
}),
|
|
224
|
+
).pipe(Effect.orDie);
|
|
219
225
|
|
|
220
226
|
return yield* exit;
|
|
221
227
|
});
|
|
@@ -394,15 +400,12 @@ const runWorkflowModifier = Effect.fnUntraced(function* <A, E, R>(
|
|
|
394
400
|
register: (use: (modifier: NativeWorkflowInstanceModifier) => Promise<void>) => Promise<any>,
|
|
395
401
|
use: (modifier: WorkflowInstanceModifier) => Effect.Effect<A, E, R>,
|
|
396
402
|
): Effect.fn.Return<A, E, R> {
|
|
397
|
-
const
|
|
398
|
-
const exit = yield* Effect.promise(async () => {
|
|
403
|
+
const exit = yield* runNativeCallback<A, E, R, Exit.Exit<A, E>>(async (run) => {
|
|
399
404
|
let callbackExit: Exit.Exit<A, E> | undefined;
|
|
400
405
|
|
|
401
406
|
try {
|
|
402
407
|
await register(async (modifier) => {
|
|
403
|
-
callbackExit = await Effect.
|
|
404
|
-
use(wrapWorkflowModifier(modifier)),
|
|
405
|
-
);
|
|
408
|
+
callbackExit = await run(Effect.suspend(() => use(wrapWorkflowModifier(modifier))));
|
|
406
409
|
|
|
407
410
|
if (Exit.isFailure(callbackExit)) {
|
|
408
411
|
throw callbackExit;
|
|
@@ -421,7 +424,7 @@ const runWorkflowModifier = Effect.fnUntraced(function* <A, E, R>(
|
|
|
421
424
|
}
|
|
422
425
|
|
|
423
426
|
return callbackExit;
|
|
424
|
-
});
|
|
427
|
+
}).pipe(Effect.orDie);
|
|
425
428
|
|
|
426
429
|
return yield* exit;
|
|
427
430
|
});
|