@effected/mcp 0.2.0 → 0.4.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 CHANGED
@@ -1,16 +1,16 @@
1
1
  # @effected/mcp
2
2
 
3
- The boundary layer of an `effect/unstable/ai` MCP server: stdio wiring that keeps stdout the JSON-RPC wire, tool-failure shaping, and strict-input walkers, plus a `./testing` subpath for driving a built server from a test.
3
+ The boundary layer of an `effect/ai` MCP server: stdio wiring that keeps stdout the JSON-RPC wire, tool-failure shaping, and strict-input walkers, plus a `./testing` subpath for driving a built server from a test.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@effected%2Fmcp?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/mcp)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
7
7
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
8
8
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
9
9
 
10
- > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
- > development against a single pinned Effect v4 prerelease. Packages graduate to
12
- > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
- > exactly the ones the kit is built and tested against, install
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
14
  > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
15
  >
16
16
  > **Stability: unstable.** This package's API surface is not yet considered
@@ -94,7 +94,7 @@ For a refusal with no fields of its own, `ToolRefusal` is that shape ready-made:
94
94
  ```ts
95
95
  import { ToolFailure, ToolRefusal } from "@effected/mcp";
96
96
  import { Effect, Schema } from "effect";
97
- import { Tool } from "effect/unstable/ai";
97
+ import { Tool } from "effect/ai";
98
98
 
99
99
  const GetRun = Tool.make("get_run", {
100
100
  parameters: Schema.Struct({ id: Schema.String }),
@@ -120,7 +120,7 @@ A stdio server's `main.ts` is one line, plus the layer that wires it:
120
120
  import { McpStdio, McpToolkit, ToolFailure } from "@effected/mcp";
121
121
  import { NodeRuntime, NodeStdio } from "@effect/platform-node";
122
122
  import { Effect, Layer, Schema } from "effect";
123
- import { Tool, Toolkit } from "effect/unstable/ai";
123
+ import { Tool, Toolkit } from "effect/ai";
124
124
 
125
125
  class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
126
126
  ...ToolFailure.fields,
@@ -172,20 +172,20 @@ its own queue-backed `Stdio`.
172
172
 
173
173
  `McpToolkit.layer` re-annotates every tool without its own `Tool.Strict`
174
174
  annotation to strict by default, so `get_thing({ id: "known", extra: 1 })`
175
- is rejected with one `Unrecognized parameter(s): extra. Accepted params:
176
- id.` before the handler ever runs — see [Strict input](#strict-input)
177
- below.
175
+ is rejected by core's strict decode, which names every excess, missing and
176
+ invalid field in one `InvalidParams`, before the handler ever runs, and the
177
+ layer adds `Accepted params at the root: id.` — see
178
+ [Strict input](#strict-input) below.
178
179
 
179
180
  ## The stdio boundary
180
181
 
181
182
  `McpStdio.layer` is core's `McpServer.layerStdio` with every log line sent to
182
- stderr, and with stdin read through a guard. Core's own stdio decoder throws on
183
- a line that is not JSON without ever trimming it from its buffer, so one bad
184
- line wedges the server: every later request goes unanswered, yet the process
185
- still ends at stdin EOF exactly as a healthy session does. The guard frames stdin exactly as core does — one streaming
186
- UTF-8 decoder, a byte-order mark stripped only at the start of the stream,
187
- lines split on `\n` — and answers each line core would choke on itself, on
188
- stdout:
183
+ stderr, and with stdin read through a guard. Core's own stdio decoder skips a
184
+ line it cannot use and keeps serving, but it sends no reply, where JSON-RPC 2.0
185
+ requires one: the client is left waiting on a request it will never hear about.
186
+ The guard frames stdin exactly as core does — one streaming UTF-8 decoder, a
187
+ byte-order mark stripped only at the start of the stream, lines split on `\n`
188
+ — and answers each such line itself, on stdout:
189
189
 
190
190
  - a line that is not JSON gets a JSON-RPC parse error,
191
191
  `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}`,
@@ -196,11 +196,16 @@ stdout:
196
196
  - a line that is JSON but no JSON-RPC message gets an Invalid Request,
197
197
  `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}`,
198
198
  and the server keeps serving. That covers a value that is neither an object
199
- nor an array (core throws on a bare `null`, dropping every other frame that
200
- arrived in the same chunk, and ignores a number, string or boolean without a
201
- reply), an object whose `method` is not a string and whose `id` is absent or
202
- `null` (core throws on that too), and an object with neither `method` nor
203
- `id`, which is neither a request nor a response;
199
+ nor an array, an object whose `method` is not a string and whose `id` is
200
+ absent or `null`, and an object with neither `method` nor `id`, which is
201
+ neither a request nor a response;
202
+ - a line whose `method` starts with `@effect/rpc/` never reaches core, which
203
+ would read one without an `id` as its own RPC control message:
204
+ `{"jsonrpc":"2.0","method":"@effect/rpc/Eof"}` alone silently stops an
205
+ unguarded server ([Effect-TS/effect#8499](https://github.com/Effect-TS/effect/issues/8499),
206
+ open upstream). Such a notification is
207
+ dropped; such a request is answered `-32601` Method not found, echoing its
208
+ `id`;
204
209
  - a line of JSON whitespace (space, tab, carriage return) is ignored.
205
210
 
206
211
  Every other line goes to core. That includes an array, which core answers with
@@ -275,35 +280,73 @@ scopes:
275
280
  - **`McpToolkit.layer(toolkit, options?)`** — the registration-scoped
276
281
  decorator, and the recommended default: every tool without its own
277
282
  `Tool.Strict` annotation is served and decoded strict
278
- (`options.strict` defaults to `"all"`), and a rejected call names every
279
- unknown key at every depth in one response, not just the first. An
280
- explicit `Tool.Strict` annotation always wins, and a `Tool.dynamic` tool
281
- is never re-annotated. Pass `{ strict: "annotated" }` to leave
283
+ (`options.strict` defaults to `"all"`). Rejection and the report are
284
+ core's: a strict tool is decoded with `errors: "all"`, so one
285
+ `InvalidParams` names every excess key at every depth together with every
286
+ missing or invalid field. The layer appends to that report, never in
287
+ place of it: one `Accepted params at <path>: …` line for each object level
288
+ of the payload that carries an unknown key, so an agent can fix the call
289
+ from the reply alone (example below). An explicit `Tool.Strict`
290
+ annotation always wins, and a `Tool.dynamic` tool is never re-annotated.
291
+ `options.unknownKeyMessage` is deprecated and ignored. Pass `{ strict: "annotated" }` to leave
282
292
  unannotated tools lenient, or annotate an individual tool
283
293
  `Tool.Strict` false to opt it out under the default.
284
294
 
285
295
  ```ts
286
296
  import { McpToolkit } from "@effected/mcp";
287
297
  import { Layer } from "effect";
288
- import type { Tool, Toolkit } from "effect/unstable/ai";
298
+ import type { Tool, Toolkit } from "effect/ai";
289
299
 
290
300
  // The toolkit and its handlers, as built in "Putting it together".
291
301
  declare const MyTools: Toolkit.Toolkit<Record<string, Tool.Any>>;
292
302
  declare const MyHandlers: Layer.Layer<never>;
293
303
 
294
- // Default: every tool is strict, and every rejection names every unknown key.
304
+ // Default: every tool is strict; core's rejection names every bad field at once.
295
305
  const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
296
306
 
297
307
  // Only tools explicitly annotated Tool.Strict are decoded strict.
298
308
  const LenientByDefault = McpToolkit.layer(MyTools, { strict: "annotated" }).pipe(Layer.provide(MyHandlers));
299
309
  ```
300
310
 
311
+ A strict `search` tool taking `query` and an optional `filter` union, called
312
+ with an unknown key at the root and one inside the selected `filter` member,
313
+ and without its required `query`:
314
+
315
+ ```text
316
+ search({ extra: 1, filter: { kind: "tag", tag: "t", text: "sneaky" } })
317
+ => Invalid parameters for tool 'search': Expected no excess property
318
+ at ["extra"]
319
+ Missing key
320
+ at ["query"]
321
+ Expected no excess property
322
+ at ["filter"]["text"]
323
+ Accepted params at the root: query, filter.
324
+ Accepted params at ["filter"]: kind, tag.
325
+ ```
326
+
327
+ The first six lines are core's. The layer adds the last two: one per object
328
+ level that carries an unknown key, naming its accepted keys, plus
329
+ `keys matching <pattern>` where the level accepts keys by `patternProperties`.
330
+ A zero-parameter tool gets `This tool accepts no params.` instead. A failure
331
+ with no unknown key, only missing or mistyped fields, is core's report
332
+ unchanged.
333
+
334
+ A pattern-keyed `Schema.Record` is served closed, with `patternProperties`,
335
+ only when its key pattern's RegExp carries the Unicode flag:
336
+ `Schema.isPattern(/^x-/u)`. Without `u`, Effect treats the exported pattern as
337
+ approximate and serves the record open (`additionalProperties` set to the value
338
+ schema), so the served schema, and `ToolInputSchema.unknownKeys` walking it,
339
+ accept any key; the strict decode still enforces the pattern. `McpToolAudit`'s `input: "closed"` check reports such
340
+ a record open and suggests the flag. Where a closed level accepts keys by
341
+ pattern, `formatUnknownKeys` names the patterns (`Accepted keys matching: ^x-.`)
342
+ rather than claiming it accepts none.
343
+
301
344
  The dynamic-tool recipe, with the handler reporting every unknown key:
302
345
 
303
346
  ```ts
304
347
  import { McpToolkit, ToolInputSchema } from "@effected/mcp";
305
348
  import { Effect, Layer, Schema } from "effect";
306
- import { Tool, Toolkit } from "effect/unstable/ai";
349
+ import { Tool, Toolkit } from "effect/ai";
307
350
 
308
351
  // A top-level union, written as raw JSON Schema and rewritten to an object root.
309
352
  const EditInput = ToolInputSchema.objectRooted({
@@ -363,7 +406,7 @@ A tool whose parameters are a `Schema.Union` of objects cannot be a `Tool.make`
363
406
  ```ts
364
407
  import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
365
408
  import { Effect, Schema } from "effect";
366
- import { Tool, Toolkit } from "effect/unstable/ai";
409
+ import { Tool, Toolkit } from "effect/ai";
367
410
 
368
411
  const AddNote = Schema.Struct({ action: Schema.Literal("add"), text: Schema.String });
369
412
  const ListNotes = Schema.Struct({ action: Schema.Literal("list"), limit: Schema.optionalKey(Schema.Number) });
@@ -391,7 +434,7 @@ const Handlers = Kit.toLayer({
391
434
 
392
435
  `annotate` and `addDependency` keep the union on the tool, so a chained `.annotate(Tool.Title, …)` still hands `unionHandler` the decoded type.
393
436
 
394
- Registered through `McpToolkit.layer`, a union tool is checked like a strict `Tool.make` tool: every unknown key at every depth is named in one `InvalidParams`, then the union is decoded strictly, both before the handler runs. Bad arguments therefore answer as a `Tool.make` decode failure does: a JSON-RPC `-32602` on `2025-06-18`, an `isError` result on the later revisions. Registered through core's `McpServer.toolkit` instead, or called directly (a test helper, say), `unionHandler` runs the same check itself, so the message is identical: every unknown key named per level in the `ToolInputSchema.formatUnknownKeys` report, then core's `Tool.make` wording for a bad value. Under `McpServer.toolkit` that `InvalidParams` is a declared failure, an `isError` result on every revision. A custom rendering goes in `unionHandler`'s third argument, `{ unknownKeyMessage }`, as it does in `McpToolkit.layer`'s options: pass the same function to both. Never annotate a union tool `Tool.Strict` true: core dies at registration on a strict tool with a raw JSON Schema.
437
+ Registered through `McpToolkit.layer`, a union tool is decoded like a strict `Tool.make` tool, before the handler runs: strictly, with `errors: "all"`, so one `InvalidParams` in core's `Tool.make` wording names every excess key at every depth together with every missing or invalid field of the matched member, followed by the same `Accepted params at <path>: …` lines. Bad arguments therefore answer as a `Tool.make` decode failure does: a JSON-RPC `-32602` on `2025-06-18`, an `isError` result on the later revisions. Registered through core's `McpServer.toolkit` instead, or called directly (a test helper, say), `unionHandler` runs the same decode itself, so the message is identical. Under `McpServer.toolkit` that `InvalidParams` is a declared failure, an `isError` result on every revision. `unionHandler`'s third argument, `{ unknownKeyMessage }`, is deprecated and ignored. Never annotate a union tool `Tool.Strict` true: core dies at registration on a strict tool with a raw JSON Schema.
395
438
 
396
439
  ## Union success schemas
397
440
 
@@ -417,7 +460,7 @@ test machinery into a server's runtime import graph.
417
460
  ```ts
418
461
  import { McpHarness } from "@effected/mcp/testing";
419
462
  import { Effect, type Layer, type Stdio } from "effect";
420
- import type { McpServer } from "effect/unstable/ai";
463
+ import type { McpServer } from "effect/ai";
421
464
 
422
465
  // The server layer from "Putting it together" — still requiring Stdio.
423
466
  declare const ServerLayer: Layer.Layer<McpServer.McpServer, never, Stdio.Stdio>;
@@ -434,7 +477,8 @@ const test = Effect.gen(function* () {
434
477
  On a stateful revision (the default is `2025-11-25`), `initialize` comes
435
478
  first: any other request sent before it fails fast with `McpTestFailure`
436
479
  reason `NotInitialized` instead of the server's opaque `Invalid request
437
- metadata`. `client.initializeWith("2025-06-18")` asks for a different stateful revision than the harness speaks and returns the whole response, so a test can assert the negotiated `protocolVersion`. A stateful server never refuses a version: an unknown one, or the stateless `2026-07-28`, is counter-offered `2025-11-25`. On a harness made with the stateless revision, `initialize` itself is not served, and the response is a JSON-RPC `-32601` error with no `notifications/initialized` sent after it. `client.sentSoFar` holds every frame the harness has written, in order.
480
+ metadata`. `ping` is the exception: the server answers it `{}` before
481
+ initialization, so the harness sends it. `client.initializeWith("2025-06-18")` asks for a different stateful revision than the harness speaks and returns the whole response, so a test can assert the negotiated `protocolVersion`. A stateful server never refuses a version: an unknown one, or the stateless `2026-07-28`, is counter-offered `2025-11-25`. On a harness made with the stateless revision, `initialize` itself is not served, and the response is a JSON-RPC `-32601` error with no `notifications/initialized` sent after it. `client.sentSoFar` holds every frame the harness has written, in order.
438
482
 
439
483
  `McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
440
484
  a test sees the exact served schemas and wire results a real client would,
@@ -477,7 +521,7 @@ import { join } from "node:path";
477
521
  import { NodeServices } from "@effect/platform-node";
478
522
  import { McpProcess } from "@effected/mcp/testing";
479
523
  import { Effect } from "effect";
480
- import { ChildProcess } from "effect/unstable/process";
524
+ import { ChildProcess } from "effect/process";
481
525
 
482
526
  // A minimal server, written to disk so it can be spawned as a real process.
483
527
  const serverFile = join(import.meta.dirname, "mcp-guard-demo-server.mjs");
@@ -487,7 +531,7 @@ writeFileSync(
487
531
  import { McpStdio, McpToolkit } from "@effected/mcp";
488
532
  import { NodeRuntime, NodeStdio } from "@effect/platform-node";
489
533
  import { Effect, Layer } from "effect";
490
- import { Tool, Toolkit } from "effect/unstable/ai";
534
+ import { Tool, Toolkit } from "effect/ai";
491
535
 
492
536
  const Ping = Tool.make("ping", { description: "Liveness check.", parameters: Tool.EmptyParams });
493
537
  const Tools = Toolkit.make(Ping);
package/ToolFailure.js CHANGED
@@ -9,7 +9,7 @@ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
9
9
  * @remarks
10
10
  * Core sends a declared failure that is an `Error` instance — every
11
11
  * `Schema.TaggedError` is — as `isError: true` with `error.message` as the only
12
- * text and no `structuredContent` (effect `unstable/ai/McpServer.ts`,
12
+ * text and no `structuredContent` (effect `ai/McpServer.ts`,
13
13
  * `declaredFailureResult`). So the remediation must be folded into `message`
14
14
  * when the error is constructed: whatever is not in the message never reaches
15
15
  * the agent.
@@ -26,6 +26,9 @@ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
26
26
  *
27
27
  * @example
28
28
  * ```ts
29
+ * import { ToolFailure } from "@effected/mcp";
30
+ * import { Schema } from "effect";
31
+ *
29
32
  * class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
30
33
  * const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
31
34
  * new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
@@ -77,11 +77,15 @@ const collect = (payload, root) => {
77
77
  if (match === void 0) extra.push(key);
78
78
  else patterned.push([key, match[1]]);
79
79
  }
80
- if (extra.length > 0 && isClosed) out.push({
81
- path,
82
- unknown: extra,
83
- accepted
84
- });
80
+ if (extra.length > 0 && isClosed) {
81
+ const acceptedPatterns = patterns.map(([pattern]) => pattern);
82
+ out.push({
83
+ path,
84
+ unknown: extra,
85
+ accepted,
86
+ ...acceptedPatterns.length > 0 ? { acceptedPatterns } : {}
87
+ });
88
+ }
85
89
  for (const key of accepted) if (Object.hasOwn(value, key)) walk(value[key], properties[key], [...path, key], depth + 1);
86
90
  for (const [key, schema] of patterned) walk(value[key], schema, [...path, key], depth + 1);
87
91
  if (additional !== void 0) for (const key of extra) walk(value[key], additional, [...path, key], depth + 1);
@@ -94,10 +98,12 @@ const collect = (payload, root) => {
94
98
  * depth, a message naming them all, and an object-rooted form of a union.
95
99
  *
96
100
  * @remarks
97
- * Core's `Tool.Strict` rejects unknown keys but reports only the first
98
- * (effect `unstable/ai/McpServer.ts` decodes without `errors: "all"`), so an
99
- * agent fixes one typo per round trip. These walkers run over the schema core
100
- * actually serves, so what they accept matches what the tool advertises:
101
+ * For a `Tool.make` tool, core's strict decode already names every unknown
102
+ * key, with every missing or invalid field, in one response. Core never
103
+ * validates a `Tool.dynamic` tool's raw JSON Schema, so these walkers are
104
+ * for that case: a handler runs them against the schema it registered.
105
+ * They run over the schema as served, so what they accept matches what the
106
+ * tool advertises:
101
107
  *
102
108
  * - Only `additionalProperties: false` closes a node, as in JSON Schema; core
103
109
  * emits it on every object node of a strict tool and `true` otherwise.
@@ -108,6 +114,9 @@ const collect = (payload, root) => {
108
114
  * - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
109
115
  * honoured), and `patternProperties` are followed; a key matched by a
110
116
  * pattern is accepted and walked with the first matching pattern's schema.
117
+ * Effect serves a pattern-keyed `Schema.Record` with `patternProperties`
118
+ * only when the key pattern's RegExp has the `u` flag; without it the
119
+ * record is served open, and every key is accepted.
111
120
  * - The payload is untrusted: the walk stops descending at depth 256 rather
112
121
  * than overflowing the stack, so a deeper unknown key goes unreported and
113
122
  * is left for decoding. Every hop counts toward that cap: each property or
@@ -121,17 +130,22 @@ var ToolInputSchema = class {
121
130
  /** Every level of `payload` that carries a key `schema` does not accept. */
122
131
  static unknownKeys = (payload, schema) => collect(payload, schema);
123
132
  /**
124
- * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level. Each
125
- * echoed key is truncated, and at most 20 keys and 20 levels are named; a
126
- * trailing `(and N more levels).` counts the levels left out.
133
+ * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level,
134
+ * followed by `Accepted keys matching: ^x-.` when the level also accepts
135
+ * keys by pattern. A level that accepts keys only by pattern names its
136
+ * patterns alone, never `Accepted params: (none).` Each echoed key and
137
+ * pattern is truncated, and at most 20 keys, 20 patterns and 20 levels are
138
+ * named; a trailing `(and N more levels).` counts the levels left out.
127
139
  */
128
140
  static formatUnknownKeys = (levels, options = {}) => {
129
141
  const limit = options.echoLimit ?? ToolFailure.ECHO_LIMIT;
130
142
  const sentences = levels.slice(0, MAX_ECHOED).map((level) => {
131
143
  const shown = level.unknown.slice(0, MAX_ECHOED).map((key) => ToolFailure.truncate([...level.path, key].join("."), limit));
132
144
  const more = level.unknown.length > MAX_ECHOED ? ` (and ${level.unknown.length - MAX_ECHOED} more)` : "";
133
- const accepted = level.accepted.length === 0 ? "(none)" : level.accepted.join(", ");
134
- return `Unrecognized parameter(s): ${shown.join(", ")}${more}. Accepted params: ${accepted}.`;
145
+ const patterns = (level.acceptedPatterns ?? []).slice(0, MAX_ECHOED).map((pattern) => ToolFailure.truncate(pattern, limit));
146
+ const params = level.accepted.length > 0 ? ` Accepted params: ${level.accepted.join(", ")}.` : patterns.length === 0 ? " Accepted params: (none)." : "";
147
+ const matching = patterns.length > 0 ? ` Accepted keys matching: ${patterns.join(", ")}.` : "";
148
+ return `Unrecognized parameter(s): ${shown.join(", ")}${more}.${params}${matching}`;
135
149
  });
136
150
  const hidden = levels.length > MAX_ECHOED ? ` (and ${levels.length - MAX_ECHOED} more levels).` : "";
137
151
  return `${sentences.join(" ")}${hidden}`;
package/ToolRefusal.js CHANGED
@@ -14,11 +14,14 @@ import { Schema } from "effect";
14
14
  * {@link ToolRefusal.refuse}; its message already carries the remediation,
15
15
  * because a declared `Error` failure sends `message` and nothing else.
16
16
  *
17
- * Audit every throw path when porting a tool: any failure left undeclared
18
- * loses its message on the wire, however carefully it was worded.
17
+ * Audit every throw path of a tool: any failure left undeclared loses its
18
+ * message on the wire, however carefully it was worded.
19
19
  *
20
20
  * @example
21
21
  * ```ts
22
+ * import { ToolFailure, ToolRefusal } from "@effected/mcp";
23
+ * import { Tool } from "effect/ai";
24
+ *
22
25
  * const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
23
26
  * // in the handler:
24
27
  * return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })