@gjermundgaraba/effect-actions 0.9.0 → 0.11.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.
Files changed (70) hide show
  1. package/README.md +56 -50
  2. package/dist/Action.d.mts +286 -79
  3. package/dist/Action.mjs +125 -23
  4. package/dist/ActionCli.d.mts +102 -31
  5. package/dist/ActionCli.mjs +114 -16
  6. package/dist/ActionHttp.d.mts +170 -51
  7. package/dist/ActionHttp.mjs +130 -79
  8. package/dist/ActionMcp.d.mts +71 -21
  9. package/dist/ActionMcp.mjs +231 -80
  10. package/dist/ActionToolkit.d.mts +56 -27
  11. package/dist/ActionToolkit.mjs +16 -5
  12. package/dist/Authentication.d.mts +129 -33
  13. package/dist/Authentication.mjs +231 -38
  14. package/dist/Testing.d.mts +84 -57
  15. package/dist/Testing.mjs +121 -79
  16. package/dist/internal/actions.d.mts +5 -25
  17. package/dist/internal/actions.mjs +75 -27
  18. package/dist/internal/authentication.d.mts +102 -0
  19. package/dist/internal/authentication.mjs +11 -0
  20. package/dist/internal/call.d.mts +12 -0
  21. package/dist/internal/call.mjs +12 -0
  22. package/dist/internal/cli.d.mts +36 -22
  23. package/dist/internal/cli.mjs +311 -33
  24. package/dist/internal/client.d.mts +43 -0
  25. package/dist/internal/client.mjs +25 -0
  26. package/dist/internal/console.mjs +77 -0
  27. package/dist/internal/declared.d.mts +13 -0
  28. package/dist/internal/declared.mjs +34 -0
  29. package/dist/internal/errors.d.mts +77 -0
  30. package/dist/internal/errors.mjs +79 -0
  31. package/dist/internal/implementation.d.mts +171 -41
  32. package/dist/internal/implementation.mjs +147 -29
  33. package/dist/internal/mcp.d.mts +14 -0
  34. package/dist/internal/mcp.mjs +182 -0
  35. package/dist/internal/media.d.mts +8 -0
  36. package/dist/internal/media.mjs +26 -0
  37. package/dist/internal/memory.d.mts +6 -0
  38. package/dist/internal/memory.mjs +38 -0
  39. package/dist/internal/refusal.mjs +67 -0
  40. package/dist/internal/schema-errors.mjs +20 -0
  41. package/dist/internal/tools.mjs +27 -57
  42. package/docs/Action.md +403 -73
  43. package/docs/ActionCli.md +281 -51
  44. package/docs/ActionHttp.md +267 -82
  45. package/docs/ActionMcp.md +232 -102
  46. package/docs/ActionToolkit.md +99 -35
  47. package/docs/Authentication.md +369 -62
  48. package/docs/CONTEXT.md +24 -36
  49. package/docs/README.md +29 -73
  50. package/docs/Testing.md +154 -77
  51. package/docs/guarantees.md +82 -43
  52. package/docs/setup.md +95 -27
  53. package/package.json +12 -20
  54. package/dist/ActionCatalog.d.mts +0 -30
  55. package/dist/ActionCatalog.mjs +0 -39
  56. package/dist/ActionCliClient.d.mts +0 -48
  57. package/dist/ActionCliClient.mjs +0 -43
  58. package/dist/ActionGroup.d.mts +0 -44
  59. package/dist/ActionGroup.mjs +0 -35
  60. package/dist/ActionHttpClient.d.mts +0 -33
  61. package/dist/ActionHttpClient.mjs +0 -16
  62. package/dist/TestingClient.d.mts +0 -17
  63. package/dist/TestingClient.mjs +0 -22
  64. package/dist/internal/fetchClient.d.mts +0 -6
  65. package/dist/internal/fetchClient.mjs +0 -8
  66. package/dist/internal/tools.d.mts +0 -22
  67. package/docs/ActionCatalog.md +0 -54
  68. package/docs/ActionCliClient.md +0 -63
  69. package/docs/ActionGroup.md +0 -128
  70. package/docs/ActionHttpClient.md +0 -80
@@ -0,0 +1,182 @@
1
+ import { Media, isMarked, kindOf } from "./media.mjs";
2
+ import { Predicate, Schema, SchemaAST } from "effect";
3
+ import { McpProtocol } from "effect/ai";
4
+ //#region src/internal/mcp.ts
5
+ /**
6
+ * The one protocol revision served over HTTP. 2026-07-28 is stateless: every request
7
+ * stands alone. The stateful revisions keep a session per `initialize`, which the native
8
+ * HTTP runtime never expires and which no identity owns. A mixed endpoint's gate relies on it
9
+ * too: it decides from the routing headers, which the native runtime checks against the body
10
+ * of a stateless request alone, so serving a stateful revision over HTTP would open the gate.
11
+ */
12
+ const httpProtocol = McpProtocol.v2026_07_28;
13
+ /** Where `ActionMcp.layerHttp` serves, and `Testing.mcpClient` calls, by default. */
14
+ const defaultPath = "/mcp";
15
+ /** The parameter a method's `mcp-name` header repeats, as the native runtime routes it. */
16
+ const routed = (method, params) => method === "resources/read" ? params.uri : params.name;
17
+ /**
18
+ * One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it:
19
+ * the headers routing it, which repeat what its body says, and its JSON-RPC body. The client
20
+ * metadata goes in `_meta`, under any given, such as a `progressToken`; the protocol version
21
+ * is always the request's own.
22
+ */
23
+ const statelessRequest = (method, params) => {
24
+ const { protocolVersion } = httpProtocol;
25
+ const name = routed(method, params);
26
+ return {
27
+ headers: {
28
+ accept: "application/json, text/event-stream",
29
+ "mcp-protocol-version": protocolVersion,
30
+ "mcp-method": method,
31
+ ...Predicate.isString(name) ? { "mcp-name": name } : {}
32
+ },
33
+ body: {
34
+ jsonrpc: "2.0",
35
+ id: 1,
36
+ method,
37
+ params: {
38
+ ...params,
39
+ _meta: {
40
+ "io.modelcontextprotocol/clientCapabilities": {},
41
+ "io.modelcontextprotocol/clientInfo": {
42
+ name: "effect-actions",
43
+ version: "0"
44
+ },
45
+ ...params._meta,
46
+ "io.modelcontextprotocol/protocolVersion": protocolVersion
47
+ }
48
+ }
49
+ }
50
+ };
51
+ };
52
+ /** The `_meta` key of a lifted block naming its field. */
53
+ const fieldKey = "effect-actions/field";
54
+ const isMedia = Schema.is(Media);
55
+ const isMediaArray = Schema.is(Schema.Array(Media));
56
+ /** The schema `ast` stands for, a `Schema.suspend` resolved, which only delays its definition. */
57
+ const resolved = (ast) => SchemaAST.isSuspend(ast) ? resolved(ast.thunk()) : ast;
58
+ /** The kind `ast` is, suspended or not. */
59
+ const kindIn = (ast) => kindOf(resolved(ast));
60
+ /**
61
+ * The media `ast` is, one value or an array of them, as a whole success may be: an array without
62
+ * an encoding of its own whose every element is media of one kind, such as `Schema.Array` or
63
+ * `Schema.NonEmptyArray` of it.
64
+ */
65
+ const wholeOf = (suspended) => {
66
+ const ast = resolved(suspended);
67
+ const one = kindOf(ast);
68
+ if (one !== void 0) return {
69
+ kind: one,
70
+ many: false
71
+ };
72
+ if (!SchemaAST.isArrays(ast) || ast.encoding !== void 0) return void 0;
73
+ const [kind, ...others] = new Set([...ast.elements, ...ast.rest].map(kindIn));
74
+ return kind === void 0 || others.length > 0 ? void 0 : {
75
+ kind,
76
+ many: true
77
+ };
78
+ };
79
+ /**
80
+ * The media a field's `ast` is: one value, which `Schema.optional` or `Schema.optionalKey` may
81
+ * make absent, or an array, which they may not, as no block would tell an absent one from an
82
+ * empty one.
83
+ */
84
+ const fieldOf = (field) => {
85
+ const optional = field.context?.isOptional === true;
86
+ const ast = resolved(field);
87
+ if (optional && SchemaAST.isUnion(ast) && ast.types.length === 2) {
88
+ const [value, ...more] = ast.types.filter((member) => !SchemaAST.isUndefined(member));
89
+ const kind = value === void 0 || more.length > 0 ? void 0 : kindIn(value);
90
+ return kind === void 0 ? void 0 : {
91
+ kind,
92
+ many: false
93
+ };
94
+ }
95
+ const whole = wholeOf(ast);
96
+ return whole === void 0 || optional && whole.many ? void 0 : whole;
97
+ };
98
+ /**
99
+ * How an MCP tool sends `success`: it lifts the success itself, one value or an array, or the
100
+ * top-level media fields of a struct without an encoding of its own, keyed by strings, and
101
+ * sends the rest of the struct, if any is left.
102
+ */
103
+ const liftOf = (success) => {
104
+ const ast = resolved(success.ast);
105
+ const whole = wholeOf(ast);
106
+ if (whole !== void 0) return {
107
+ fields: [{
108
+ name: void 0,
109
+ ...whole
110
+ }],
111
+ rest: void 0
112
+ };
113
+ if (!SchemaAST.isObjects(ast) || ast.encoding !== void 0) return {
114
+ fields: [],
115
+ rest: success
116
+ };
117
+ const fields = ast.propertySignatures.flatMap(({ name, type }) => {
118
+ const field = fieldOf(type);
119
+ return field === void 0 ? [] : [{
120
+ name: String(name),
121
+ ...field
122
+ }];
123
+ });
124
+ if (fields.length === 0) return {
125
+ fields,
126
+ rest: success
127
+ };
128
+ const names = new Set(fields.map(({ name }) => name));
129
+ const rest = ast.propertySignatures.filter(({ name }) => !names.has(name));
130
+ const { identifier: _identifier, ...annotations } = ast.annotations ?? {};
131
+ return {
132
+ fields,
133
+ rest: rest.length === 0 && ast.indexSignatures.length === 0 ? void 0 : Schema.make(new SchemaAST.Objects(rest, ast.indexSignatures, annotations))
134
+ };
135
+ };
136
+ /** The schemas `ast` is made of, its encoded side included. */
137
+ const partsOf = (ast) => [
138
+ ...SchemaAST.isDeclaration(ast) ? ast.typeParameters : [],
139
+ ...SchemaAST.isArrays(ast) ? [...ast.elements, ...ast.rest] : [],
140
+ ...SchemaAST.isObjects(ast) ? [...ast.propertySignatures, ...ast.indexSignatures].map(({ type }) => type) : [],
141
+ ...SchemaAST.isUnion(ast) ? ast.types : [],
142
+ ...SchemaAST.isSuspend(ast) ? [ast.thunk()] : [],
143
+ ...(ast.encoding ?? []).map(({ to }) => to)
144
+ ];
145
+ /** Whether `ast` holds media anywhere, each schema visited once, so a recursive one ends. */
146
+ const holdsMedia = (ast) => {
147
+ const seen = /* @__PURE__ */ new Set();
148
+ const pending = [ast];
149
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
150
+ if (seen.has(next)) continue;
151
+ seen.add(next);
152
+ if (isMarked(next)) return true;
153
+ pending.push(...partsOf(next));
154
+ }
155
+ return false;
156
+ };
157
+ /**
158
+ * Whether `action`, whose success an MCP tool lifts as `lift` says, holds media where no tool
159
+ * lifts it: in its input, in a declared error, or in the rest of its success, such as nested in
160
+ * a field or in a union.
161
+ */
162
+ const isMisplaced = (action, { rest }) => {
163
+ return [
164
+ action.input,
165
+ ...action.error,
166
+ ...rest === void 0 ? [] : [rest]
167
+ ].some(({ ast }) => holdsMedia(ast));
168
+ };
169
+ /**
170
+ * The media `lift` finds in `success`, a decoded value its schema has validated: the whole
171
+ * success's, or each field's, in field order, an array's in its order, none for an absent field.
172
+ */
173
+ const liftedFrom = (lift, success) => lift.fields.flatMap(({ name, kind, many }) => {
174
+ const value = name === void 0 ? success : Predicate.hasProperty(success, name) ? success[name] : void 0;
175
+ return (many ? isMediaArray(value) ? value : [] : isMedia(value) ? [value] : []).map((media) => ({
176
+ kind,
177
+ field: name,
178
+ value: media
179
+ }));
180
+ });
181
+ //#endregion
182
+ export { defaultPath, fieldKey, httpProtocol, isMisplaced, liftOf, liftedFrom, statelessRequest };
@@ -0,0 +1,8 @@
1
+ import { Schema, SchemaAST } from "effect";
2
+ //#region src/internal/media.d.ts
3
+ /** An image, a media field's schema, as `Action.Image` exports it. */
4
+ export declare const Image: Schema.Struct<{
5
+ readonly data: Schema.Uint8Array;
6
+ readonly mimeType: Schema.String;
7
+ }>;
8
+ //#endregion
@@ -0,0 +1,26 @@
1
+ import { Schema } from "effect";
2
+ //#region src/internal/media.ts
3
+ /**
4
+ * The annotation marking a media kind's schema, naming the MCP content block a value of it is
5
+ * lifted into: `Action.Image`'s is `"image"`.
6
+ */
7
+ const mediaKey = "effect-actions/media";
8
+ /** What every media kind holds: bytes, and their MIME type. */
9
+ const Media = Schema.Struct({
10
+ data: Schema.Uint8Array,
11
+ mimeType: Schema.String
12
+ });
13
+ /** An image, a media field's schema, as `Action.Image` exports it. */
14
+ const Image = Media.annotate({ [mediaKey]: "image" });
15
+ /**
16
+ * Whether `ast` is marked as media, read from its own annotations: a copy, such as an optional
17
+ * key or one with checks added, keeps them, and so does one with an encoding of its own.
18
+ */
19
+ const isMarked = (ast) => ast.annotations?.[mediaKey] === "image";
20
+ /**
21
+ * The kind `ast` is, when its values are the media they encode to: marked, without an encoding
22
+ * of its own, which would send its values as something else.
23
+ */
24
+ const kindOf = (ast) => isMarked(ast) && ast.encoding === void 0 ? "image" : void 0;
25
+ //#endregion
26
+ export { Image, Media, isMarked, kindOf };
@@ -0,0 +1,6 @@
1
+ import { Layer } from "effect";
2
+ import { HttpClient, HttpRouter, HttpServer } from "effect/http";
3
+ //#region src/internal/memory.d.ts
4
+ /** What in-memory routes may leave to the host: the router, the platform, nothing per request. */
5
+ export type Served = HttpRouter.HttpRouter | HttpRouter.Request<"Requires", never> | HttpRouter.Request<"GlobalRequires", never> | HttpRouter.Request<"Error", any> | HttpRouter.Request<"GlobalError", any> | Layer.Success<typeof HttpServer.layerServices>;
6
+ //#endregion
@@ -0,0 +1,38 @@
1
+ import { Effect, Layer, Predicate, Stream } from "effect";
2
+ import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/http";
3
+ //#region src/internal/memory.ts
4
+ /** Where relative request URLs resolve, so a client needs no `baseUrl`. */
5
+ const origin = "http://localhost/";
6
+ /**
7
+ * `request`, a streamed body turned into a web stream in the current context. The native client
8
+ * turns it in its send, under the in-memory `fetch`, where another client the stream calls
9
+ * would send to the routes too.
10
+ */
11
+ const pulled = (request) => {
12
+ const body = request.body;
13
+ if (!Predicate.isTagged(body, "Stream")) return Effect.succeed(request);
14
+ return Effect.map(Stream.toReadableStreamEffect(body.stream), (readable) => HttpClientRequest.setBody(request, HttpBody.raw(readable, {
15
+ contentType: body.contentType,
16
+ contentLength: body.contentLength
17
+ })).pipe(HttpClientRequest.updateHeaders(() => request.headers)));
18
+ };
19
+ /**
20
+ * The native `HttpClient`, sending every request to `handler` instead of the network. A
21
+ * relative URL resolves against `http://localhost/`, as a page there would resolve it, and a
22
+ * request carries the `Host` header of its URL, as one over the network does. The client is
23
+ * its own: the program's other clients keep their `fetch`, and get none of its requests.
24
+ */
25
+ const clientOf = (handler) => {
26
+ const fetch = (input, init) => {
27
+ const request = new Request(input, init);
28
+ if (!request.headers.has("host")) request.headers.set("host", new URL(request.url).host);
29
+ return handler(request);
30
+ };
31
+ return Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (native) => HttpClient.makeWith((request) => Effect.flatMap(request, (mapped) => {
32
+ const resolved = URL.canParse(mapped.url) ? null : URL.parse(mapped.url, origin);
33
+ const sent = resolved === null ? mapped : HttpClientRequest.setUrl(mapped, resolved.href);
34
+ return Effect.flatMap(pulled(sent), (ready) => native.postprocess(Effect.succeed(ready)).pipe(Effect.provideService(FetchHttpClient.Fetch, fetch)));
35
+ }), native.preprocess))).pipe(Layer.provide(Layer.fresh(FetchHttpClient.layer)));
36
+ };
37
+ //#endregion
38
+ export { clientOf };
@@ -0,0 +1,67 @@
1
+ import { Refusal, statuses } from "./errors.mjs";
2
+ import { Cause, Context, Effect, Exit, Option, Predicate, Ref, Schema } from "effect";
3
+ import { HttpServerResponse } from "effect/http";
4
+ //#region src/internal/refusal.ts
5
+ /** An RFC 6750 error description: printable ASCII but `"` and `\`. */
6
+ const description = /^[\x20\x21\x23-\x5B\x5D-\x7E]+$/;
7
+ /**
8
+ * An RFC 9110 challenge of `scheme` and the parameters given, each a quoted string whose `"`
9
+ * and `\` are escaped: a metadata URL's query may hold a `\`.
10
+ */
11
+ const challenge = (scheme, parameters) => {
12
+ const given = parameters.flatMap(([name, value]) => value === void 0 ? [] : [`${name}="${value.replace(/["\\]/g, "\\$&")}"`]);
13
+ return given.length === 0 ? scheme : `${scheme} ${given.join(", ")}`;
14
+ };
15
+ /** An RFC 6750 `Bearer` challenge of the parameters given. */
16
+ const bearer = (parameters) => challenge("Bearer", parameters);
17
+ const encode = Schema.encodeSync(Refusal);
18
+ /** Whether `error` is a built-in refusal, `Unauthenticated` or `Forbidden`. */
19
+ const isRefusal = Schema.is(Refusal);
20
+ /**
21
+ * The RFC 6750 `insufficient_scope` challenge an OAuth client steps up on, for a `Forbidden`
22
+ * naming scopes, naming the resource's metadata URL when there is one.
23
+ */
24
+ const insufficientScope = (error, metadataUrl) => Predicate.isTagged(error, "Forbidden") && error.scopes !== void 0 ? bearer([
25
+ ["error", "insufficient_scope"],
26
+ ["scope", error.scopes.join(" ")],
27
+ ["resource_metadata", metadataUrl],
28
+ ["error_description", description.test(error.message) ? error.message : void 0]
29
+ ]) : void 0;
30
+ /** A refusal as its JSON with its status alone, as a scheme no OAuth client acts on answers it. */
31
+ const plain = (error) => HttpServerResponse.jsonUnsafe(encode(error), { status: statuses[error._tag] });
32
+ /**
33
+ * A refusal as its JSON with its status, as every endpoint declares it, and its
34
+ * `insufficient_scope` challenge if it has one.
35
+ */
36
+ const answer = (error, metadataUrl) => {
37
+ const response = plain(error);
38
+ const scoped = insufficientScope(error, metadataUrl);
39
+ return scoped === void 0 ? response : HttpServerResponse.setHeader(response, "www-authenticate", scoped);
40
+ };
41
+ /** The step-up refusal a tool's call under `answerStepUp` failed with, which answers its request. */
42
+ var SteppedUp = class extends Context.Service()("effect-actions/SteppedUp") {};
43
+ /**
44
+ * Whether `error` is a refusal an OAuth client acts on: `Unauthenticated`, or `Forbidden`
45
+ * naming scopes.
46
+ */
47
+ const isStepUp = (error) => isRefusal(error) && (!Predicate.isTagged(error, "Forbidden") || error.scopes !== void 0);
48
+ /**
49
+ * A protected MCP tool's `call`, whose step-up refusal answers the request under a Bearer
50
+ * descriptor's `answerStepUp`. Elsewhere it is `call` as it is.
51
+ */
52
+ const recordStepUp = (call) => Effect.tapError(call, (error) => isStepUp(error) ? Effect.flatMap(Effect.serviceOption(SteppedUp), (slot) => Option.isSome(slot) ? Ref.set(slot.value, Option.some(error)) : Effect.void) : Effect.void);
53
+ /**
54
+ * An MCP request, answered with the step-up refusal a tool's call in it failed with, its
55
+ * status, JSON and challenge naming `metadataUrl`, however the request ended short of a defect
56
+ * or an interruption: a tool's failure is a successful result, and MCP authorization requires
57
+ * 401 or 403.
58
+ */
59
+ const answerStepUp = (route, metadataUrl) => Effect.gen(function* () {
60
+ const slot = yield* Ref.make(Option.none());
61
+ const exit = yield* Effect.exit(Effect.provideService(route, SteppedUp, slot));
62
+ const refused = yield* Ref.get(slot);
63
+ const broken = Exit.isFailure(exit) && (Cause.hasDies(exit.cause) || Cause.hasInterrupts(exit.cause));
64
+ return Option.isSome(refused) && !broken ? answer(refused.value, metadataUrl) : yield* exit;
65
+ });
66
+ //#endregion
67
+ export { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain, recordStepUp };
@@ -0,0 +1,20 @@
1
+ import { InvalidInput } from "./errors.mjs";
2
+ import { Effect } from "effect";
3
+ import { HttpApiMiddleware } from "effect/http-api";
4
+ //#region src/internal/schema-errors.ts
5
+ /**
6
+ * `HttpApiBuilder` reports these kinds while encoding the handler's answer, after the
7
+ * handler ran; every other kind (`Payload`, `Params`, `Headers`, `Query`) comes from
8
+ * decoding the request before it.
9
+ */
10
+ const responseKinds = /* @__PURE__ */ new Set(["Body", "ResponseHeaders"]);
11
+ /**
12
+ * The native middleware answering schema failures: a request that does not decode with
13
+ * `InvalidInput`, the schema's own message and issues. A result that does not encode stays the
14
+ * native failure, which Effect answers with an empty 500 and reports.
15
+ */
16
+ var SchemaErrors = class extends HttpApiMiddleware.Service()("effect-actions/http/SchemaErrors", { error: [InvalidInput] }) {};
17
+ /** `SchemaErrors`' server side, built by `ActionHttp.layer` alone, so a client's bundle leaves it out. */
18
+ const schemaErrors = () => HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(responseKinds.has(failure.kind) ? failure : InvalidInput.fromSchemaError(failure.cause)));
19
+ //#endregion
20
+ export { SchemaErrors, schemaErrors };
@@ -1,74 +1,44 @@
1
- import { assertDistinct, projectedErrors } from "./actions.mjs";
2
- import { dispatch } from "./implementation.mjs";
3
- import { Context, Effect, Schema } from "effect";
1
+ import { assertOnce, projectedErrors } from "./actions.mjs";
2
+ import { acquire } from "./implementation.mjs";
3
+ import { Effect, Schema } from "effect";
4
4
  import { Tool, Toolkit } from "effect/ai";
5
5
  //#region src/internal/tools.ts
6
+ /** `tool` annotated with `value` under `key`, or as it is where `value` is not given. */
7
+ const annotateIf = (tool, key, value) => value === void 0 ? tool : tool.annotate(key, value);
6
8
  /**
7
- * The success field an MCP tool sends as a raw text block, from `mcp.text`. The native
8
- * server reads a tool's annotations when it registers the tool, so `ActionMcp` finds
9
- * it there.
9
+ * A tool's annotations: read-only exactly when its action is, and the contract's `mcp` ones
10
+ * where given, so the native defaults, MCP's, stand for the others. A read-only tool is not
11
+ * destructive unless it says so, where the native default calls every tool destructive.
10
12
  */
11
- const TextField = Context.Reference("@gjermundgaraba/effect-actions/TextField", { defaultValue: () => void 0 });
12
- const isToolAction = (action) => action.mcp !== false;
13
- /** Only tool-enabled groups are acquired; each implementation appears once. */
14
- const toolApps = (apps) => apps.flatMap((app) => {
15
- const actions = app.group.actions.filter(isToolAction);
16
- return actions.length === 0 ? [] : [{
17
- app,
18
- actions
19
- }];
20
- });
21
- /** Flatten the projected contracts only after deciding which groups are served. */
22
- const entries = (apps) => apps.flatMap(({ app, actions }) => actions.map((action) => ({
23
- app,
24
- action
25
- })));
26
- const annotate = (tool, mcp) => tool.annotate(Tool.Readonly, mcp.readOnly).annotate(Tool.Destructive, mcp.destructive).annotate(Tool.Idempotent, mcp.idempotent).annotate(Tool.OpenWorld, mcp.openWorld);
27
- /**
28
- * A native Effect AI tool. Its schemas retain action transforms and its result
29
- * is the action result itself, rather than an MCP response envelope.
30
- */
31
- const nativeTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
32
- description: action.description,
33
- parameters: action.input,
34
- success: action.success,
35
- failure: Schema.Union(errors),
36
- failureMode: "return"
37
- }), action.mcp);
13
+ const annotate = (tool, { readOnly, mcp }) => {
14
+ const destructive = mcp.destructiveHint ?? (readOnly ? false : void 0);
15
+ const meta = mcp._meta === void 0 ? void 0 : { ...mcp._meta };
16
+ const hinted = annotateIf(annotateIf(annotateIf(tool.annotate(Tool.Readonly, readOnly), Tool.Destructive, destructive), Tool.Idempotent, mcp.idempotentHint), Tool.OpenWorld, mcp.openWorldHint);
17
+ return annotateIf(annotateIf(hinted, Tool.Title, mcp.title), Tool.Meta, meta);
18
+ };
38
19
  /**
39
- * MCP has a JSON-only wire contract. Success is the encoded success as structured
40
- * content; declared failures are returned as JSON text.
20
+ * The native tool of `action`. A model and an MCP client speak JSON, so it takes and gives
21
+ * the JSON encoding its schemas advertise, the whole success; handlers and callers see decoded
22
+ * values. A refusal by authentication or `authorize`, or a handler's built-in failure, is the implementation's failure, so
23
+ * it declares the built-in errors alongside the action's own, and returns them as its result.
41
24
  */
42
- const mcpTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
25
+ const toolOf = (action) => Tool.make(action.name, {
43
26
  description: action.description,
44
27
  parameters: Schema.toCodecJson(action.input),
45
28
  success: Schema.toCodecJson(action.success),
46
- failure: Schema.toCodecJson(Schema.Union(errors)),
29
+ failure: Schema.toCodecJson(Schema.Union(projectedErrors(action))),
47
30
  failureMode: "return"
48
- }), action.mcp).annotate(TextField, action.mcp.text);
49
- const project = (projection, entry, options) => {
50
- const errors = projectedErrors(entry.action, options.errors);
51
- const tool = projection === "native" ? nativeTool(entry.action, errors) : mcpTool(entry.action, errors);
52
- if (projection === "native") return tool;
53
- return tool.annotate(Tool.Strict, true);
54
- };
31
+ });
55
32
  /**
56
- * Project MCP-enabled actions and acquire their handlers exactly once per adapter layer.
57
- * Native and MCP differ only in tool codecs; selection, scoped acquisition, dispatch
58
- * and native Toolkit binding stay identical.
33
+ * Project actions as tools named after them, sharing each implementation's built handlers.
59
34
  */
60
- const bindTools = (apps, projection, options) => {
61
- const served = toolApps(apps);
62
- const selected = entries(served);
63
- assertDistinct("MCP tool", selected.map(({ action }) => action.mcp.name));
64
- const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry, options)));
35
+ const bindTools = (apps, projection) => {
36
+ assertOnce(projection.label, apps.flatMap((app) => app.actions));
37
+ const toolkit = Toolkit.make(...apps.flatMap((app) => app.actions.map((action) => annotate(projection.tool(toolOf(action), action, app), action))));
65
38
  return {
66
39
  toolkit,
67
- layer: toolkit.toLayer(Effect.map(Effect.forEach(served, ({ app, actions }) => Effect.map(app.build, (handlers) => {
68
- const record = handlers;
69
- return actions.map((action) => [action.mcp.name, dispatch(app.group, action, record, options.before)]);
70
- })), (built) => Object.fromEntries(built.flat())))
40
+ layer: toolkit.toLayer(Effect.map(acquire(apps), (bound) => Object.fromEntries(bound.map(([action, run]) => [action.name, projection.handler?.(run, action) ?? run]))))
71
41
  };
72
42
  };
73
43
  //#endregion
74
- export { TextField, bindTools };
44
+ export { bindTools };