effect-codemode 0.0.1 → 0.1.0-beta.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/README.md +638 -2
- package/dist/bridge.d.ts +46 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/codemode.d.ts +22 -0
- package/dist/codemode.d.ts.map +1 -0
- package/dist/executor-base.d.ts +25 -0
- package/dist/executor-base.d.ts.map +1 -0
- package/dist/executor.d.ts +18 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/group.d.ts +71 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1365 -0
- package/dist/index.js.map +25 -0
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/registry.d.ts +52 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/sanitize.d.ts +10 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/search.d.ts +35 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/serve.d.ts +35 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +890 -0
- package/dist/testing.js.map +18 -0
- package/dist/tool.d.ts +69 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/transport.d.ts +10 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/typegen.d.ts +33 -0
- package/dist/typegen.d.ts.map +1 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +44 -10
package/README.md
CHANGED
|
@@ -1,5 +1,641 @@
|
|
|
1
1
|
# effect-codemode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give LLMs a typed Effect SDK instead of individual tool calls.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
effect-codemode implements the [codemode pattern](https://blog.cloudflare.com/code-mode-mcp/) for [MCP](https://modelcontextprotocol.io) — collapsing N tools into 2 (`search_tools` + `execute_code`) so the LLM writes typed Effect code against your services instead of making one tool call at a time.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
Traditional MCP exposes each operation as a separate tool. A workspace with Linear, GitHub, Sheets, and Slack integrations might have 36+ tools — each loaded into the LLM's context at ~500 tokens each. That's 18,000 tokens before the LLM has done anything.
|
|
10
|
+
|
|
11
|
+
Worse, composition requires round trips. "Find my Linear issues and check for linked PRs" means:
|
|
12
|
+
|
|
13
|
+
1. LLM calls `linear_issue_list` -> waits for result
|
|
14
|
+
2. LLM calls `gh_list_prs` -> waits for result
|
|
15
|
+
3. LLM reasons about both -> responds
|
|
16
|
+
|
|
17
|
+
Each round trip burns tokens and adds latency. A 5-step workflow means 5 round trips.
|
|
18
|
+
|
|
19
|
+
## The codemode pattern
|
|
20
|
+
|
|
21
|
+
With effect-codemode, the LLM writes one code block:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const issues = yield* codemode.linear_issue_list({ assigneeId: me.id, first: 10 })
|
|
26
|
+
const prs = yield* codemode.gh_list_prs({ owner: "Org", repo: "app", state: "open" })
|
|
27
|
+
return issues.filter(i => prs.some(p => p.title.includes(i.identifier)))
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
One round trip. Multiple API calls. Data filtering happens in the sandbox, not in the LLM's context. And because this is Effect, the LLM gets typed errors, structured concurrency, and composable resilience — not just `async/await`.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
### Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bun add effect-codemode effect @effect/ai @effect/platform
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 1. Define your schemas and errors
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
45
|
+
|
|
46
|
+
const Todo = Schema.Struct({
|
|
47
|
+
id: Schema.String.annotations({ description: "Todo UUID" }),
|
|
48
|
+
title: Schema.String.annotations({ description: "Short summary" }),
|
|
49
|
+
completed: Schema.Boolean,
|
|
50
|
+
}).annotations({ identifier: "Todo" })
|
|
51
|
+
|
|
52
|
+
type Todo = typeof Todo.Type
|
|
53
|
+
|
|
54
|
+
class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", {
|
|
55
|
+
message: Schema.String,
|
|
56
|
+
}) {}
|
|
57
|
+
|
|
58
|
+
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
|
|
59
|
+
message: Schema.String,
|
|
60
|
+
field: Schema.String,
|
|
61
|
+
}) {}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. Define tool shapes
|
|
65
|
+
|
|
66
|
+
Tool shapes are pure data descriptors — name, description, input/output schemas, error types, and flags. No implementation.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { Codemode } from "effect-codemode"
|
|
70
|
+
|
|
71
|
+
const TodoGet = Codemode.tool("todo_get", {
|
|
72
|
+
description: "Get a todo by ID",
|
|
73
|
+
input: Schema.Struct({ id: Schema.String.annotations({ description: "Todo UUID" }) }),
|
|
74
|
+
output: Todo,
|
|
75
|
+
errors: [NotFoundError],
|
|
76
|
+
example: 'yield* codemode.todo_get({ id: "todo-1" })',
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const TodoList = Codemode.tool("todo_list", {
|
|
80
|
+
description: "List todos with optional filter",
|
|
81
|
+
input: Schema.Struct({
|
|
82
|
+
completed: Schema.optional(Schema.Boolean.annotations({ description: "Filter by status" }), { exact: true }),
|
|
83
|
+
}),
|
|
84
|
+
output: Schema.Array(Todo),
|
|
85
|
+
example: "yield* codemode.todo_list({ completed: false })",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const TodoCreate = Codemode.tool("todo_create", {
|
|
89
|
+
description: "Create a new todo",
|
|
90
|
+
input: Schema.Struct({
|
|
91
|
+
title: Schema.String.annotations({ description: "Title for the new todo" }),
|
|
92
|
+
}),
|
|
93
|
+
output: Todo,
|
|
94
|
+
errors: [ValidationError],
|
|
95
|
+
example: 'yield* codemode.todo_create({ title: "New task" })',
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Create a group with `.toLayer()`
|
|
100
|
+
|
|
101
|
+
Groups bundle related tool shapes and bind them to an implementation via `.toLayer()`. The handlers can depend on Effect services.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
class TodoService extends Context.Tag("TodoService")<
|
|
105
|
+
TodoService,
|
|
106
|
+
{
|
|
107
|
+
readonly get: (id: string) => Effect.Effect<Todo, NotFoundError>
|
|
108
|
+
readonly list: (filter?: { completed?: boolean }) => Effect.Effect<Todo[]>
|
|
109
|
+
readonly create: (input: { title: string }) => Effect.Effect<Todo, ValidationError>
|
|
110
|
+
}
|
|
111
|
+
>() {}
|
|
112
|
+
|
|
113
|
+
class TodoTools extends Codemode.group("todos", TodoGet, TodoList, TodoCreate) {}
|
|
114
|
+
|
|
115
|
+
const TodoToolsLive = TodoTools.toLayer(
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const svc = yield* TodoService
|
|
118
|
+
return {
|
|
119
|
+
todo_get: ({ id }) => svc.get(id),
|
|
120
|
+
todo_list: (input) => svc.list(input),
|
|
121
|
+
todo_create: (input) => svc.create(input),
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
).pipe(Layer.provide(TodoServiceLive))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Or pass handlers directly without an Effect if there are no service dependencies:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const TodoToolsLive = TodoTools.toLayer({
|
|
131
|
+
todo_get: ({ id }) => Effect.succeed(todos.find(t => t.id === id)),
|
|
132
|
+
todo_list: (input) => Effect.succeed(todos),
|
|
133
|
+
todo_create: (input) => Effect.succeed({ id: "new", title: input.title, completed: false }),
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 4. Serve
|
|
138
|
+
|
|
139
|
+
The quickest way to get running is `Codemode.live()`, which bundles `Codemode.serve()` with the default VM executor:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { Codemode, CodemodeRegistry } from "effect-codemode"
|
|
143
|
+
import { McpServer } from "@effect/ai"
|
|
144
|
+
import { Effect, Layer } from "effect"
|
|
145
|
+
|
|
146
|
+
Codemode.live().pipe(
|
|
147
|
+
Layer.provide(TodoToolsLive),
|
|
148
|
+
Layer.provide(CodemodeRegistry.layer),
|
|
149
|
+
Layer.provide(McpServer.layerStdio({
|
|
150
|
+
name: "my-mcp-server",
|
|
151
|
+
version: "1.0.0",
|
|
152
|
+
stdin,
|
|
153
|
+
stdout,
|
|
154
|
+
})),
|
|
155
|
+
Layer.launch,
|
|
156
|
+
Effect.runFork,
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
For more control, use `Codemode.serve()` and provide the executor explicitly:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { Codemode, CodemodeRegistry, CodeExecutor } from "effect-codemode"
|
|
164
|
+
|
|
165
|
+
Codemode.serve().pipe(
|
|
166
|
+
Layer.provide(TodoToolsLive),
|
|
167
|
+
Layer.provide(CodemodeRegistry.layer),
|
|
168
|
+
Layer.provide(CodeExecutor.Default),
|
|
169
|
+
Layer.provide(McpServer.layerStdio({
|
|
170
|
+
name: "my-mcp-server",
|
|
171
|
+
version: "1.0.0",
|
|
172
|
+
stdin,
|
|
173
|
+
stdout,
|
|
174
|
+
})),
|
|
175
|
+
Layer.launch,
|
|
176
|
+
Effect.runFork,
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Or use the `StdioTransport` helper if you have `@effect/platform-bun` or `@effect/platform-node`:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { BunRuntime } from "@effect/platform-bun"
|
|
184
|
+
import { BunSink, BunStream } from "@effect/platform-bun"
|
|
185
|
+
|
|
186
|
+
Codemode.live().pipe(
|
|
187
|
+
Layer.provide(TodoToolsLive),
|
|
188
|
+
Layer.provide(CodemodeRegistry.layer),
|
|
189
|
+
Layer.provide(Codemode.StdioTransport(BunStream.stdin, BunSink.stdout, {
|
|
190
|
+
name: "my-mcp-server",
|
|
191
|
+
version: "1.0.0",
|
|
192
|
+
})),
|
|
193
|
+
Layer.launch,
|
|
194
|
+
BunRuntime.runMain,
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 5. Connect to Claude Code
|
|
199
|
+
|
|
200
|
+
Create `.mcp.json` in your project root:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"mcpServers": {
|
|
205
|
+
"my-tools": {
|
|
206
|
+
"command": "bun",
|
|
207
|
+
"args": ["run", "src/server.ts"]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## What the LLM sees
|
|
214
|
+
|
|
215
|
+
effect-codemode walks your Effect Schema AST and generates TypeScript declarations. When the LLM calls `execute_code`, this is in the tool description:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
type NotFoundError = { readonly _tag: "NotFoundError"; readonly message: string }
|
|
219
|
+
|
|
220
|
+
type Todo = {
|
|
221
|
+
id: string
|
|
222
|
+
title: string
|
|
223
|
+
completed: boolean
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
declare const codemode: {
|
|
227
|
+
/** Get a todo by ID
|
|
228
|
+
* @param id - Todo UUID
|
|
229
|
+
* @error NotFoundError
|
|
230
|
+
*/
|
|
231
|
+
todo_get(input: { id: string }): Effect<Todo, NotFoundError>
|
|
232
|
+
|
|
233
|
+
/** List todos with optional filter */
|
|
234
|
+
todo_list(input: { completed?: boolean }): Effect<Todo[], never>
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The LLM writes natural Effect code against this typed SDK. Schema annotations become `@param` descriptions. Error types are deduplicated across tools. Output types shared between tools are emitted once.
|
|
239
|
+
|
|
240
|
+
When the tool count exceeds 7, the description automatically switches to a compact one-line-per-tool catalog and directs the LLM to use `search_tools` for full type signatures.
|
|
241
|
+
|
|
242
|
+
## Effect patterns in the sandbox
|
|
243
|
+
|
|
244
|
+
The sandbox has `Effect`, `pipe`, `Duration`, `Schedule`, `Option`, `Either`, `Match`, `Schema`, `Arr` (Effect's Array module), `Ref`, `Stream`, `Chunk`, `Data`, `Order`, and `Predicate` available as globals. Helper functions `parallel()` and `sleep(ms)` are also available. Each method on `codemode` returns an Effect — use `yield*` inside `Effect.gen` to call them.
|
|
245
|
+
|
|
246
|
+
### Typed error handling
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
return yield* codemode.todo_get({ id: "nonexistent" })
|
|
251
|
+
}).pipe(
|
|
252
|
+
Effect.catchTag("NotFoundError", (e) =>
|
|
253
|
+
Effect.succeed({ error: e.message, fallback: true })
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Parallel composition
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
Effect.gen(function* () {
|
|
262
|
+
const [todos, users] = yield* Effect.all([
|
|
263
|
+
codemode.todo_list({}),
|
|
264
|
+
codemode.user_list({}),
|
|
265
|
+
], { concurrency: "unbounded" })
|
|
266
|
+
return { todos: todos.length, users: users.length }
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Retry with backoff
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
Effect.gen(function* () {
|
|
274
|
+
return yield* codemode.flaky_api_call({})
|
|
275
|
+
}).pipe(
|
|
276
|
+
Effect.retry({ times: 3, schedule: Schedule.exponential("1 second") })
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Timeout
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
Effect.gen(function* () {
|
|
284
|
+
return yield* codemode.slow_operation({})
|
|
285
|
+
}).pipe(Effect.timeout("10 seconds"))
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Progress streaming
|
|
289
|
+
|
|
290
|
+
`console.log` in sandbox code streams as MCP logging notifications in real-time:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
Effect.gen(function* () {
|
|
294
|
+
console.log("Fetching todos...")
|
|
295
|
+
const todos = yield* codemode.todo_list({})
|
|
296
|
+
console.log(`Got ${todos.length} todos, processing...`)
|
|
297
|
+
return todos
|
|
298
|
+
})
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Multiple services
|
|
302
|
+
|
|
303
|
+
Compose groups for cross-service workflows:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
Codemode.live().pipe(
|
|
307
|
+
Layer.provide(Layer.mergeAll(TaskToolsLive, ProjectToolsLive, SlackToolsLive)),
|
|
308
|
+
Layer.provide(CodemodeRegistry.layer),
|
|
309
|
+
Layer.provide(McpServer.layerStdio({
|
|
310
|
+
name: "my-workspace",
|
|
311
|
+
version: "1.0.0",
|
|
312
|
+
stdin,
|
|
313
|
+
stdout,
|
|
314
|
+
})),
|
|
315
|
+
Layer.launch,
|
|
316
|
+
Effect.runFork,
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The LLM can now compose across all services in one code block:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
Effect.gen(function* () {
|
|
324
|
+
const user = yield* codemode.user_get({ id: "user-1" })
|
|
325
|
+
const todos = yield* codemode.todo_list({ assignee: user.id })
|
|
326
|
+
|
|
327
|
+
yield* Effect.all(
|
|
328
|
+
todos.map(todo =>
|
|
329
|
+
codemode.slack_send({
|
|
330
|
+
channel: "#tasks",
|
|
331
|
+
message: `${user.name}: ${todo.title}`,
|
|
332
|
+
})
|
|
333
|
+
),
|
|
334
|
+
{ concurrency: 5 }
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return { notified: todos.length }
|
|
338
|
+
})
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Failed groups are automatically excluded at startup. The server starts with whatever's available, and service status is included in the tool description so the LLM knows what's online.
|
|
342
|
+
|
|
343
|
+
## Features
|
|
344
|
+
|
|
345
|
+
### Middleware
|
|
346
|
+
|
|
347
|
+
Add cross-cutting concerns to all tool executions:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
import { Codemode } from "effect-codemode"
|
|
351
|
+
import type { ToolMiddleware } from "effect-codemode"
|
|
352
|
+
|
|
353
|
+
const loggingMiddleware: ToolMiddleware = (tool, next) =>
|
|
354
|
+
Effect.gen(function* () {
|
|
355
|
+
console.log(`Calling ${tool.name}`)
|
|
356
|
+
const start = Date.now()
|
|
357
|
+
const result = yield* next
|
|
358
|
+
console.log(`${tool.name} took ${Date.now() - start}ms`)
|
|
359
|
+
return result
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
// Global middleware wraps ALL tools from every group
|
|
363
|
+
Codemode.serve().pipe(
|
|
364
|
+
Layer.provide(Codemode.withMiddleware(loggingMiddleware)),
|
|
365
|
+
Layer.provide(Layer.mergeAll(TodoToolsLive, UserToolsLive)),
|
|
366
|
+
Layer.provide(CodemodeRegistry.layer),
|
|
367
|
+
// ...transport + executor
|
|
368
|
+
)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Middleware receives a `ToolInvocation` (with `name` and `input`) and a `next` Effect. You can inspect the invocation, transform results, catch errors, or skip execution entirely.
|
|
372
|
+
|
|
373
|
+
#### Per-group middleware
|
|
374
|
+
|
|
375
|
+
Attach middleware to a specific group using `.middleware()`:
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
class TodoTools extends Codemode.group("todos", TodoGet, TodoList, TodoCreate)
|
|
379
|
+
.middleware(auditMiddleware, rateLimitMiddleware) {}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Or pass middleware via `.toLayer()` options:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
const TodoToolsLive = TodoTools.toLayer(handlers, {
|
|
386
|
+
middleware: [auditMiddleware],
|
|
387
|
+
})
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
#### Per-tool middleware
|
|
391
|
+
|
|
392
|
+
Attach middleware to individual tool shapes:
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
const TodoDelete = Codemode.tool("todo_delete", {
|
|
396
|
+
description: "Delete a todo permanently",
|
|
397
|
+
input: Schema.Struct({ id: Schema.String }),
|
|
398
|
+
output: Schema.Struct({ deleted: Schema.Boolean }),
|
|
399
|
+
middleware: [auditMiddleware],
|
|
400
|
+
})
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Confirmation-required tools
|
|
404
|
+
|
|
405
|
+
Tools with `requiresConfirmation: true` are excluded from the sandbox and registered as separate MCP tools, so the MCP client can show a confirmation dialog:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
const TodoDelete = Codemode.tool("todo_delete", {
|
|
409
|
+
description: "Delete a todo permanently",
|
|
410
|
+
input: Schema.Struct({ id: Schema.String }),
|
|
411
|
+
output: Schema.Struct({ deleted: Schema.Boolean }),
|
|
412
|
+
errors: [NotFoundError],
|
|
413
|
+
requiresConfirmation: true,
|
|
414
|
+
})
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Tool search
|
|
418
|
+
|
|
419
|
+
The `search_tools` MCP tool supports keyword search, service browsing, fuzzy matching, and configurable detail levels:
|
|
420
|
+
|
|
421
|
+
```
|
|
422
|
+
search_tools({ query: "todo" }) // keyword search
|
|
423
|
+
search_tools({ query: "service:todos" }) // browse by service
|
|
424
|
+
search_tools({ query: "isue" }) // fuzzy match -> "issue"
|
|
425
|
+
search_tools({ query: "" }) // list all tools
|
|
426
|
+
search_tools({ query: "todo", detail: "list" }) // names + descriptions only
|
|
427
|
+
search_tools({ query: "todo", detail: "summary" }) // names + descriptions + params
|
|
428
|
+
search_tools({ query: "todo", detail: "full" }) // complete TypeScript declarations (default)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Multi-term queries use AND semantics — all terms must match. Workflow queries like `"sheets read write"` fall back to returning all tools for the matched service.
|
|
432
|
+
|
|
433
|
+
### Network isolation
|
|
434
|
+
|
|
435
|
+
Block network access in the sandbox:
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const result = await executor.execute(code, fns, { allowNetwork: false })
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
When disabled, `fetch`, `URL`, `Request`, `Headers`, and `Response` throw clear errors.
|
|
442
|
+
|
|
443
|
+
### Code executor
|
|
444
|
+
|
|
445
|
+
`CodeExecutor` is an Effect service tag with multiple implementations:
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { CodeExecutor } from "effect-codemode"
|
|
449
|
+
|
|
450
|
+
CodeExecutor.Vm // node:vm — stronger isolation (default)
|
|
451
|
+
CodeExecutor.Direct // new Function() — faster, less isolated
|
|
452
|
+
CodeExecutor.Default // alias for Vm
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Provide the executor when using `Codemode.serve()`:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
Codemode.serve().pipe(
|
|
459
|
+
Layer.provide(CodeExecutor.Default), // or CodeExecutor.Direct
|
|
460
|
+
// ...
|
|
461
|
+
)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Or use `Codemode.live()` which bundles `CodeExecutor.Default` automatically.
|
|
465
|
+
|
|
466
|
+
### Execution timeout
|
|
467
|
+
|
|
468
|
+
The sandbox has a default 30-second timeout. Configure it via `ExecutorOptions`:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
executor.execute(code, fns, { timeoutMs: 60_000 }) // 60 seconds
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Auto-retry on rate limits
|
|
475
|
+
|
|
476
|
+
Tool calls that fail with a `RateLimitError` (or any error with `status: 429`) are automatically retried up to 3 times with exponential backoff.
|
|
477
|
+
|
|
478
|
+
## Testing
|
|
479
|
+
|
|
480
|
+
effect-codemode exports testing utilities from `effect-codemode/test`:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
import { buildTestTool, buildInspector } from "effect-codemode/test"
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### `buildTestTool`
|
|
487
|
+
|
|
488
|
+
Creates a full CodemodeTool from group layers — run sandbox code without starting an MCP server:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
const codemode = buildTestTool(TodoToolsLive, UserToolsLive)
|
|
492
|
+
|
|
493
|
+
const result = await codemode.execute(`
|
|
494
|
+
Effect.gen(function* () {
|
|
495
|
+
const todos = yield* codemode.todo_list({})
|
|
496
|
+
return todos.length
|
|
497
|
+
})
|
|
498
|
+
`)
|
|
499
|
+
// result.result === 3
|
|
500
|
+
// result.logs === [...]
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### `buildInspector`
|
|
504
|
+
|
|
505
|
+
Returns just the TypeScript declarations string — useful for snapshot testing your generated API:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
const declarations = buildInspector(TodoToolsLive)
|
|
509
|
+
expect(declarations).toContain("todo_get")
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## Architecture
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
MCP Client (Claude Code, Cursor, etc.)
|
|
516
|
+
| search_tools / execute_code
|
|
517
|
+
@effect/ai McpServer (stdio / HTTP transport)
|
|
518
|
+
|
|
|
519
|
+
serve.ts -> registry snapshot -> tool registration
|
|
520
|
+
|
|
|
521
|
+
codemode.ts -> typegen (declarations) + bridge (fn table) + executor
|
|
522
|
+
|
|
|
523
|
+
+----------------------------------------------------------+
|
|
524
|
+
| Sandbox |
|
|
525
|
+
| |
|
|
526
|
+
| Globals: Effect, pipe, Duration, Schedule, Option, |
|
|
527
|
+
| Either, Match, Schema, Arr, Ref, Stream, Chunk, Data, |
|
|
528
|
+
| Order, Predicate, parallel, sleep, codemode.* |
|
|
529
|
+
| |
|
|
530
|
+
| codemode.tool_name(input) |
|
|
531
|
+
| -> Schema.decodeUnknown (input validation) |
|
|
532
|
+
| -> middleware chain |
|
|
533
|
+
| -> Effect service method (via captured Runtime) |
|
|
534
|
+
| -> unwrapEffectTypes (Option -> null, Either -> value) |
|
|
535
|
+
| -> auto-retry on RateLimitError |
|
|
536
|
+
| -> returns Effect<Output, Error> |
|
|
537
|
+
| |
|
|
538
|
+
| LLM writes: Effect.gen(function* () { yield* ... }) |
|
|
539
|
+
| Executor detects Effect return -> Effect.runPromise() |
|
|
540
|
+
+----------------------------------------------------------+
|
|
541
|
+
|
|
|
542
|
+
Effect Services (your existing Context.Tag + Layer stack)
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
## API reference
|
|
546
|
+
|
|
547
|
+
### Core
|
|
548
|
+
|
|
549
|
+
| Export | Description |
|
|
550
|
+
|--------|-------------|
|
|
551
|
+
| `Codemode.tool(name, config)` | Define a tool shape — pure data descriptor with no implementation |
|
|
552
|
+
| `Codemode.group(name, ...tools)` | Bundle related tools into a group class with `.toLayer()` and `.middleware()` |
|
|
553
|
+
| `Codemode.serve()` | Return a Layer that sets up the full MCP server (requires `CodeExecutor`) |
|
|
554
|
+
| `Codemode.live()` | Convenience — `serve()` with `CodeExecutor.Default` bundled |
|
|
555
|
+
| `Codemode.StdioTransport(stdin, stdout, options?)` | Stdio transport for MCP — provides McpServer |
|
|
556
|
+
| `Codemode.CodemodeRegistry` | Shared registry tag — also available as named export |
|
|
557
|
+
| `CodemodeRegistry.layer` | Shared registry Layer — provide once so groups and serve share state |
|
|
558
|
+
| `Codemode.withMiddleware(mw)` | Register a global middleware Layer |
|
|
559
|
+
|
|
560
|
+
### Tool config
|
|
561
|
+
|
|
562
|
+
| Field | Type | Description |
|
|
563
|
+
|-------|------|-------------|
|
|
564
|
+
| `description` | `string` | What the tool does |
|
|
565
|
+
| `input` | `Schema` | Input schema |
|
|
566
|
+
| `output` | `Schema?` | Output schema |
|
|
567
|
+
| `errors` | `TaggedError[]?` | Error types the tool can fail with |
|
|
568
|
+
| `example` | `string?` | Example code shown to the LLM |
|
|
569
|
+
| `requiresConfirmation` | `boolean?` | Register as separate MCP tool for confirmation |
|
|
570
|
+
| `middleware` | `ToolMiddleware[]?` | Per-tool middleware |
|
|
571
|
+
|
|
572
|
+
### Executor
|
|
573
|
+
|
|
574
|
+
| Export | Description |
|
|
575
|
+
|--------|-------------|
|
|
576
|
+
| `CodeExecutor` | Effect service tag for code execution |
|
|
577
|
+
| `CodeExecutor.Vm` | V8-isolated executor (`node:vm`) |
|
|
578
|
+
| `CodeExecutor.Direct` | Fast executor (`new Function`) |
|
|
579
|
+
| `CodeExecutor.Default` | Alias for `Vm` |
|
|
580
|
+
| `normalizeCode(code)` | Acorn-based code normalization |
|
|
581
|
+
|
|
582
|
+
### Bridge
|
|
583
|
+
|
|
584
|
+
| Export | Description |
|
|
585
|
+
|--------|-------------|
|
|
586
|
+
| `effectToEffectFn(tool, runtime)` | Convert a tool to an Effect-returning function |
|
|
587
|
+
| `buildEffectFnTable(tools, runtime)` | Build a table of Effect-returning functions |
|
|
588
|
+
| `unwrapEffectTypes(value)` | Recursively unwrap Option/Either to plain values |
|
|
589
|
+
|
|
590
|
+
### Discovery
|
|
591
|
+
|
|
592
|
+
| Export | Description |
|
|
593
|
+
|--------|-------------|
|
|
594
|
+
| `searchTools(catalog, query, detail?)` | Search tools by keyword with fuzzy matching |
|
|
595
|
+
| `listToolSummary(catalog, detail?)` | List all tools grouped by service |
|
|
596
|
+
| `generateDeclarations(tools)` | Generate TypeScript declarations from Schema AST |
|
|
597
|
+
| `astToTypeScript(ast)` | Convert a single Schema AST node to TypeScript |
|
|
598
|
+
|
|
599
|
+
### Testing
|
|
600
|
+
|
|
601
|
+
| Export | Description |
|
|
602
|
+
|--------|-------------|
|
|
603
|
+
| `buildTestTool(...groups)` | Create a CodemodeTool for in-process testing |
|
|
604
|
+
| `buildInspector(...groups)` | Generate declarations string for snapshot testing |
|
|
605
|
+
|
|
606
|
+
### Utilities
|
|
607
|
+
|
|
608
|
+
| Export | Description |
|
|
609
|
+
|--------|-------------|
|
|
610
|
+
| `sanitizeToolName(name)` | Sanitize a tool name to a valid JS identifier |
|
|
611
|
+
|
|
612
|
+
### Types
|
|
613
|
+
|
|
614
|
+
| Export | Description |
|
|
615
|
+
|--------|-------------|
|
|
616
|
+
| `ToolShape` | Pure tool shape descriptor from `Codemode.tool()` |
|
|
617
|
+
| `ToolDescriptor` | Complete tool definition with schema, errors, execute |
|
|
618
|
+
| `ToolMiddleware` | Middleware function type `(invocation, next) => Effect` |
|
|
619
|
+
| `ToolInvocation` | Invocation context: `{ name, input }` |
|
|
620
|
+
| `ExecuteResult` | Result of sandbox execution: `{ result, error?, logs }` |
|
|
621
|
+
| `Executor` | Interface for code execution engines |
|
|
622
|
+
| `ExecutorOptions` | Options: `{ onLog?, allowNetwork?, timeoutMs? }` |
|
|
623
|
+
|
|
624
|
+
## Why Effect in the sandbox?
|
|
625
|
+
|
|
626
|
+
Cloudflare's codemode uses async/await. That works, but leaves capabilities on the table:
|
|
627
|
+
|
|
628
|
+
| | async/await | Effect |
|
|
629
|
+
|---|---|---|
|
|
630
|
+
| Error handling | `try/catch` guessing | `Effect.catchTag("NotFoundError", ...)` with typed errors |
|
|
631
|
+
| Concurrency | `Promise.all` | `Effect.all([...], { concurrency: 5 })` with structured semantics |
|
|
632
|
+
| Resilience | Manual retry loops | `Effect.retry({ schedule: Schedule.exponential("1s") })` |
|
|
633
|
+
| Timeout | `Promise.race` hacks | `Effect.timeout("10 seconds")` |
|
|
634
|
+
| Type generation | Zod -> JSON Schema -> TypeScript | Schema AST -> TypeScript (zero conversion, richer annotations) |
|
|
635
|
+
| Integration | Conversion layer needed | Your `Context.Tag` + `Layer` + `Schema` _are_ the tool definitions |
|
|
636
|
+
|
|
637
|
+
If your services already use Effect, there's no conversion layer. Your schemas, services, and error types flow directly into the LLM's typed SDK.
|
|
638
|
+
|
|
639
|
+
## License
|
|
640
|
+
|
|
641
|
+
MIT
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Effect, Runtime } from "effect";
|
|
2
|
+
import type { ToolDescriptor, ToolMiddleware } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Recursively unwraps Effect tagged types (Option, Either) into plain
|
|
5
|
+
* JSON-friendly values. Uses a WeakSet to safely handle circular references.
|
|
6
|
+
*
|
|
7
|
+
* - Option<A>: Some → unwrapped value, None → null
|
|
8
|
+
* - Either<L, R>: Right → unwrapped right value, Left → { _tag: "Left", left: ... }
|
|
9
|
+
* - Arrays: each element recursively unwrapped
|
|
10
|
+
* - Plain objects: each value recursively unwrapped
|
|
11
|
+
*/
|
|
12
|
+
export declare const unwrapEffectTypes: (value: unknown, seen?: WeakSet<object> | undefined) => unknown;
|
|
13
|
+
export interface EffectToAsyncFnOptions {
|
|
14
|
+
/** Whether to validate output against the tool's outputSchema (default: true) */
|
|
15
|
+
readonly validateOutput?: boolean | undefined;
|
|
16
|
+
/** Middleware to wrap around each tool execution */
|
|
17
|
+
readonly middleware?: ReadonlyArray<ToolMiddleware> | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Converts a single Effect-based tool into a plain async function
|
|
21
|
+
* using a captured Effect Runtime. Unwraps Effect types (Option, Either)
|
|
22
|
+
* from the return value and retries on rate limit errors.
|
|
23
|
+
*/
|
|
24
|
+
export declare const effectToAsyncFn: <R>(tool: ToolDescriptor, runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => (rawInput: unknown) => Promise<unknown>;
|
|
25
|
+
/**
|
|
26
|
+
* Builds a table of plain async functions from an array of tool descriptors
|
|
27
|
+
* and a captured Effect Runtime.
|
|
28
|
+
*/
|
|
29
|
+
export declare const buildFnTable: <R>(tools: readonly ToolDescriptor[], runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => Record<string, (...args: unknown[]) => Promise<unknown>>;
|
|
30
|
+
/**
|
|
31
|
+
* Converts a single Effect-based tool into a function that returns an Effect.
|
|
32
|
+
* The returned Effect decodes input, calls the service, unwraps Effect types,
|
|
33
|
+
* and keeps errors in the Effect error channel (no throwing).
|
|
34
|
+
*
|
|
35
|
+
* Rate limit errors are retried using Effect.retry with exponential backoff.
|
|
36
|
+
*/
|
|
37
|
+
export declare const effectToEffectFn: <R>(tool: ToolDescriptor, runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => (rawInput: unknown) => Effect.Effect<unknown, unknown, never>;
|
|
38
|
+
/**
|
|
39
|
+
* Builds a table of Effect-returning functions from an array of tool
|
|
40
|
+
* descriptors and a captured Effect Runtime.
|
|
41
|
+
*
|
|
42
|
+
* Each function in the table takes an input and returns an
|
|
43
|
+
* `Effect<unknown, unknown>` — errors stay in the Effect error channel.
|
|
44
|
+
*/
|
|
45
|
+
export declare const buildEffectFnTable: <R>(tools: readonly ToolDescriptor[], runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => Record<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, never>>;
|
|
46
|
+
//# sourceMappingURL=bridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAQ,OAAO,EAAoB,MAAM,QAAQ,CAAA;AAGhE,OAAO,KAAK,EAAE,cAAc,EAAkB,cAAc,EAAE,MAAM,SAAS,CAAA;AAM7E;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,iEA8C7B,CAAA;AAkDD,MAAM,WAAW,sBAAsB;IACrC,iFAAiF;IACjF,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC7C,oDAAoD;IACpD,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC,cAAc,CAAC,GAAG,SAAS,CAAA;CAChE;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,6IAyFhC,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,0KAe7B,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,mKAiDjC,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,GAAI,CAAC,gMAcnC,CAAA"}
|