@gjermundgaraba/effect-actions 0.16.0 → 0.18.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 (76) hide show
  1. package/README.md +3 -1
  2. package/dist/Action.d.mts +3 -5
  3. package/dist/Action.mjs +3 -1
  4. package/dist/ActionCli.d.mts +31 -42
  5. package/dist/ActionCli.mjs +6 -16
  6. package/dist/ActionHttp.d.mts +12 -8
  7. package/dist/ActionHttp.mjs +3 -1
  8. package/dist/ActionMcp.d.mts +1 -1
  9. package/dist/ActionMcp.mjs +4 -1
  10. package/dist/ActionRpc.d.mts +5 -3
  11. package/dist/ActionRpc.mjs +2 -1
  12. package/dist/ActionToolkit.d.mts +3 -2
  13. package/dist/ActionToolkit.mjs +3 -1
  14. package/dist/Authentication.d.mts +1 -1
  15. package/dist/Authentication.mjs +1 -1
  16. package/dist/authentication/provider.d.mts +2 -2
  17. package/dist/authentication/refusal.mjs +1 -1
  18. package/dist/cli/commands.d.mts +4 -3
  19. package/dist/cli/commands.mjs +6 -5
  20. package/dist/contract/ast.mjs +10 -0
  21. package/dist/contract/call.d.mts +1 -1
  22. package/dist/contract/caller.d.mts +9 -0
  23. package/dist/contract/caller.mjs +12 -0
  24. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  25. package/dist/contract/declared.mjs +42 -0
  26. package/dist/contract/hints.mjs +14 -0
  27. package/dist/contract/implementation.d.mts +4 -3
  28. package/dist/contract/implementation.mjs +4 -8
  29. package/dist/contract/names.mjs +25 -0
  30. package/dist/contract/status.mjs +18 -0
  31. package/dist/http/client.d.mts +19 -9
  32. package/dist/http/client.mjs +6 -1
  33. package/dist/mcp/client.d.mts +52 -12
  34. package/dist/mcp/client.mjs +62 -19
  35. package/dist/mcp/protocol.mjs +1 -1
  36. package/docs/Action.md +17 -3
  37. package/docs/ActionCli-failures.md +10 -9
  38. package/docs/ActionCli.md +22 -21
  39. package/docs/ActionHttp-failures.md +6 -0
  40. package/docs/ActionHttp.md +9 -4
  41. package/docs/ActionMcp.md +3 -3
  42. package/docs/ActionToolkit.md +25 -2
  43. package/docs/Authentication-failures.md +1 -1
  44. package/docs/CONTEXT.md +2 -1
  45. package/docs/README.md +4 -4
  46. package/docs/Testing.md +3 -1
  47. package/examples/README.md +10 -2
  48. package/examples/assistant/README.md +24 -20
  49. package/examples/assistant/src/agent.ts +11 -7
  50. package/examples/assistant/src/cli.ts +2 -2
  51. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  52. package/examples/assistant/src/tasks/store.ts +6 -38
  53. package/examples/assistant/src/toolkit.ts +11 -1
  54. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  55. package/examples/kitchen-sink/src/cli.ts +19 -21
  56. package/examples/mcp-tools/README.md +14 -11
  57. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  58. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  59. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  60. package/examples/mcp-tools/src/server.ts +4 -4
  61. package/examples/mcp-tools/src/stdio.ts +4 -4
  62. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  63. package/examples/mcp-tools/test/routes.test.ts +4 -4
  64. package/examples/notes-service/README.md +34 -2
  65. package/examples/notes-service/src/cli.ts +3 -9
  66. package/examples/notes-service/src/commands.ts +40 -36
  67. package/examples/notes-service/src/identity/authorize.ts +1 -0
  68. package/examples/notes-service/src/notes/Notes.ts +22 -5
  69. package/examples/notes-service/src/notes/handlers.ts +2 -2
  70. package/examples/notes-service/src/notes/store.ts +36 -6
  71. package/examples/notes-service/test/commands.test.ts +13 -19
  72. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  73. package/examples/notes-service/test/routes.test.ts +40 -1
  74. package/package.json +5 -5
  75. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  76. package/dist/contract/rules.mjs +0 -100
@@ -1,5 +1,7 @@
1
+ import { Anyone, assertAuthentication } from "./caller.mjs";
1
2
  import { Unauthenticated } from "./errors.mjs";
2
- import { Anyone, assertAuthentication, assertDistinct, assertOnce, projectedErrors } from "./rules.mjs";
3
+ import { assertDistinct, assertOnce } from "./names.mjs";
4
+ import { projectedErrors } from "./declared.mjs";
3
5
  import { Array, Cause, Context, Effect, Exit, Layer, Option, Schema } from "effect";
4
6
  //#region src/contract/implementation.ts
5
7
  const uniqueKey = () => Math.random().toString(36).slice(2);
@@ -152,13 +154,7 @@ const dispatch = (action, handle, before) => {
152
154
  } else reasons.push(reason);
153
155
  return yield* Effect.failCause(Cause.fromReasons(reasons));
154
156
  });
155
- return (input) => {
156
- const handled = Effect.withSpan(Effect.annotateLogs(Effect.suspend(() => handle(input)), actionAttributes), action.name, {
157
- captureStackTrace: false,
158
- attributes: actionAttributes
159
- });
160
- return Effect.scoped(authentication.pipe(Effect.andThen(authorization), Effect.andThen(handled), Effect.catchCause(declaredOrDefect)));
161
- };
157
+ return (input) => Effect.scoped(authentication.pipe(Effect.andThen(authorization), Effect.andThen(Effect.suspend(() => handle(input))), Effect.catchCause(declaredOrDefect))).pipe(Effect.annotateLogs(actionAttributes), Effect.withSpan(action.name, { attributes: actionAttributes }, { captureStackTrace: false }));
162
158
  };
163
159
  //#endregion
164
160
  export { Implementation, acquire, assertHeld, builders, built, memoized, provideHandlers, select, servingOf, toList };
@@ -0,0 +1,25 @@
1
+ //#region src/contract/names.ts
2
+ const validName = /^[A-Za-z0-9_-]{1,128}$/;
3
+ const assertName = (what, name) => {
4
+ if (!validName.test(name) || name === "then") throw new Error(`Invalid ${what}: ${name}`);
5
+ };
6
+ const assertDistinct = (what, items, nameOf, claimantOf = nameOf) => {
7
+ const seen = /* @__PURE__ */ new Map();
8
+ for (const item of items) {
9
+ const name = nameOf(item);
10
+ const claimant = claimantOf(item);
11
+ const other = seen.get(name);
12
+ if (other !== void 0) {
13
+ const by = other === claimant ? "" : `, claimed by ${other} and ${claimant}`;
14
+ throw new Error(`Duplicate ${what}: ${name}${by}`);
15
+ }
16
+ seen.set(name, claimant);
17
+ }
18
+ };
19
+ const assertOnce = (what, actions) => assertDistinct(what, actions, (action) => action.name);
20
+ const assertKnown = (what, keys, names) => {
21
+ const unknown = keys.filter((key) => !names.includes(key));
22
+ if (unknown.length > 0) throw new Error(`Unknown ${what}: ${unknown.join(", ")}`);
23
+ };
24
+ //#endregion
25
+ export { assertDistinct, assertKnown, assertName, assertOnce };
@@ -0,0 +1,18 @@
1
+ import { unsuspended } from "./ast.mjs";
2
+ import { Schema, SchemaAST } from "effect";
3
+ //#region src/contract/status.ts
4
+ const httpApiStatusOf = SchemaAST.resolveAt("httpApiStatus");
5
+ const statusThroughSuspensions = (ast) => httpApiStatusOf(ast) ?? (SchemaAST.isSuspend(ast) ? statusThroughSuspensions(ast.thunk()) : void 0);
6
+ /**
7
+ * `error` as an endpoint declares it: each member of a union without a status of its own apart,
8
+ * each with its status, 422 without one.
9
+ */
10
+ const declared = (error) => {
11
+ const status = statusThroughSuspensions(error.ast);
12
+ if (status !== void 0) return [httpApiStatusOf(error.ast) === void 0 ? error.annotate({ httpApiStatus: status }) : error];
13
+ const resolved = unsuspended(error.ast);
14
+ if (SchemaAST.isUnion(resolved) && resolved.checks === void 0 && resolved.encoding === void 0) return resolved.types.flatMap((member) => declared(Schema.make(member)));
15
+ return [error.annotate({ httpApiStatus: 422 })];
16
+ };
17
+ //#endregion
18
+ export { declared, httpApiStatusOf };
@@ -1,17 +1,25 @@
1
1
  import { BuiltIns } from "../contract/errors.mjs";
2
2
  import { Call } from "../contract/call.mjs";
3
- import { Action_d_exports, Any, Errors } from "../Action.mjs";
3
+ import { Any, Errors } from "../Action.mjs";
4
4
  import { Any as Any$1, VerifierError } from "../authentication/provider.mjs";
5
5
  import { Effect, Schema } from "effect";
6
6
  import { HttpClient, HttpClientError } from "effect/http";
7
7
  import { HttpApi, HttpApiClient } from "effect/http-api";
8
8
  //#region src/http/client.d.ts
9
9
  /**
10
- * The native `HttpApiClient.make` options except `transformResponse`: `baseUrl` and
11
- * `transformClient`. `transformResponse` may change a call's success, failure or
12
- * services, which the method types cannot follow.
10
+ * The native `HttpApiClient.make` options except `transformResponse`, which may change a call's
11
+ * success, failure or services the method types cannot follow: `baseUrl`, and `transformClient`,
12
+ * which may return a client failing with `E` or requiring `R` of its own, as
13
+ * `HttpApiClient.makeWith` takes one.
13
14
  */
14
- export type Options = Omit<NonNullable<Parameters<typeof HttpApiClient.make>[1]>, "transformResponse">;
15
+ export interface Options<E = HttpClientError.HttpClientError, R = never> extends Pick<NonNullable<Parameters<typeof HttpApiClient.make>[1]>, "baseUrl"> {
16
+ /**
17
+ * Wraps the native `HttpClient` the calls send with: a bearer token is
18
+ * `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. A method also fails with what
19
+ * the returned client fails with beyond an `HttpClientError`, and requires what it requires.
20
+ */
21
+ readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient.With<E, R>) | undefined;
22
+ }
15
23
  type BindingError = Errors[number];
16
24
  /**
17
25
  * What one call of `A` fails with: a declared error value (the action's own, one `E` of the
@@ -20,7 +28,7 @@ type BindingError = Errors[number];
20
28
  * the success does not decode. Input that does not encode is `InvalidInput`, and nothing is sent.
21
29
  */
22
30
  export type MethodError<A extends Any, E extends BindingError = never> = A["error"][number]["Type"] | E["Type"] | BuiltIns["Type"] | HttpClientError.HttpClientError | Schema.SchemaError;
23
- type Method<A extends Any, E extends BindingError> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E>>>;
31
+ type Method<A extends Any, E extends BindingError, CE, CR> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E> | CE, CR>>;
24
32
  /**
25
33
  * Any HTTP binding, with its actions erased: what `layer` and `client` read. The API is a native
26
34
  * constraint here because native groups are invariant in their endpoints; `actions` carries the
@@ -35,7 +43,9 @@ export interface AnyHttp {
35
43
  }
36
44
  /**
37
45
  * Every action of the binding `H`, as `client.<action>(input)`: a protected one may also fail
38
- * with what its descriptor's verifier declares.
46
+ * with what its descriptor's verifier declares. Each call also fails with `E` and requires `R`,
47
+ * what its `transformClient` adds.
39
48
  */
40
- export type Client<H extends AnyHttp> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]>>; };
41
- //#endregion
49
+ export type Client<H extends AnyHttp, E = never, R = never> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]>, E, R>; };
50
+ //#endregion
51
+ export {};
@@ -1,8 +1,12 @@
1
1
  import { checked } from "../contract/call.mjs";
2
2
  import { Effect } from "effect";
3
+ import { HttpClient } from "effect/http";
3
4
  import { HttpApiClient } from "effect/http-api";
4
5
  //#region src/http/client.ts
5
- const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api, options), (client) => {
6
+ const methodsWith = (http, httpClient, baseUrl) => Effect.map(HttpApiClient.makeWith(http.api, {
7
+ baseUrl,
8
+ httpClient
9
+ }), (client) => {
6
10
  const native = client;
7
11
  return Object.fromEntries(http.actions.map((action) => {
8
12
  const method = native[action.name];
@@ -10,5 +14,6 @@ const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api,
10
14
  return [action.name, checked(action, (payload) => method({ payload }))];
11
15
  }));
12
16
  });
17
+ const methods = (http, { baseUrl, transformClient } = {}) => Effect.flatMap(HttpClient.HttpClient, (native) => transformClient === void 0 ? methodsWith(http, native, baseUrl) : methodsWith(http, transformClient(native), baseUrl));
13
18
  //#endregion
14
19
  export { methods };
@@ -1,6 +1,6 @@
1
1
  import { BuiltIn } from "../contract/errors.mjs";
2
2
  import { Call } from "../contract/call.mjs";
3
- import { Action_d_exports, Any } from "../Action.mjs";
3
+ import { Any } from "../Action.mjs";
4
4
  import { Any as Any$1, VerifierError } from "../authentication/provider.mjs";
5
5
  import { Effect, Schema } from "effect";
6
6
  import { Headers, HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
@@ -16,8 +16,11 @@ export interface Params {
16
16
  };
17
17
  readonly [key: string]: Schema.Json | undefined;
18
18
  }
19
- /** Where `client` sends, through what client, and what the endpoint's authentication declares. */
20
- export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined> {
19
+ /**
20
+ * Where `client` sends, through what client, and what the endpoint's authentication declares: the
21
+ * client `transformClient` returns may fail with `E` and require `R`.
22
+ */
23
+ export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined, E = HttpClientError.HttpClientError, R = never> {
21
24
  /**
22
25
  * The endpoint, resolved by the `HttpClient`: relative under `Testing.layer`. Defaults to `/mcp`,
23
26
  * the default `layerHttp` path.
@@ -27,7 +30,7 @@ export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined>
27
30
  * Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token is
28
31
  * `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
29
32
  */
30
- readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
33
+ readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient.With<E, R>;
31
34
  /**
32
35
  * The endpoint's descriptor, as `layerHttp` takes it: an error its verifier declares, the
33
36
  * endpoint's HTTP answer for a protected tool, is then that error's decoded value, as over
@@ -45,20 +48,55 @@ export interface RequestOptions {
45
48
  readonly url?: string;
46
49
  readonly headers?: Headers.Input;
47
50
  }
51
+ declare const StatusError_base: Schema.Class<StatusError, Schema.TaggedStruct<"StatusError", {
52
+ readonly status: Schema.Int;
53
+ readonly body: Schema.String;
54
+ }>, import("effect/Cause").YieldableError>;
55
+ /** The endpoint answered with an HTTP status other than 200, and a body no declared error decodes. */
56
+ declare class StatusError extends StatusError_base {}
57
+ declare const ProtocolError_base: Schema.Class<ProtocolError, Schema.TaggedStruct<"ProtocolError", {
58
+ readonly code: Schema.Finite;
59
+ readonly message: Schema.String;
60
+ }>, import("effect/Cause").YieldableError>;
61
+ /**
62
+ * The endpoint answered with a JSON-RPC error, such as the native server's for invalid arguments
63
+ * or an unknown tool.
64
+ */
65
+ declare class ProtocolError extends ProtocolError_base {}
66
+ declare const NoReply_base: Schema.Class<NoReply, Schema.TaggedStruct<"NoReply", {
67
+ readonly body: Schema.String;
68
+ }>, import("effect/Cause").YieldableError>;
69
+ /** The endpoint answered 200 without a JSON-RPC reply to read, as JSON or as an event stream. */
70
+ declare class NoReply extends NoReply_base {}
71
+ declare const UndeclaredError_base: Schema.Class<UndeclaredError, Schema.TaggedStruct<"UndeclaredError", {
72
+ readonly text: Schema.String;
73
+ }>, import("effect/Cause").YieldableError>;
74
+ /** The tool returned an error whose text decodes as none of the action's declared errors. */
75
+ declare class UndeclaredError extends UndeclaredError_base {}
76
+ declare const NoStructuredContent_base: Schema.Class<NoStructuredContent, Schema.TaggedStruct<"NoStructuredContent", {
77
+ readonly body: Schema.String;
78
+ }>, import("effect/Cause").YieldableError>;
79
+ /** The tool returned a result without the structured content its action's success needs. */
80
+ declare class NoStructuredContent extends NoStructuredContent_base {}
48
81
  declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<"McpCallError", {
82
+ readonly tool: Schema.String;
83
+ readonly reason: Schema.Union<readonly [typeof StatusError, typeof ProtocolError, typeof NoReply, typeof UndeclaredError, typeof NoStructuredContent]>;
49
84
  readonly message: Schema.String;
50
85
  }>, import("effect/Cause").YieldableError>;
51
86
  /**
52
- * An answer a client method cannot read as the action's success or a declared error, such as
53
- * the native server's message for invalid arguments or an unknown tool, or a result without
54
- * its structured content. Its message holds the answer. Structured content that does not
55
- * decode as the success is a `SchemaError` instead, and a request that fails an
56
- * `HttpClientError`.
87
+ * An answer a client method cannot read as the action's success or a declared error: `reason`
88
+ * says which, with what the endpoint sent, so a caller branches on it with
89
+ * `Effect.catchReason("McpCallError", "StatusError", ...)`. `message` names the tool and the
90
+ * reason. Structured content that does not decode as the success is a `SchemaError` instead,
91
+ * and a request that fails an `HttpClientError`.
57
92
  */
58
93
  export declare class McpCallError extends McpCallError_base {}
59
94
  type CallError<A extends Any, D> = A["error"][number]["Type"] | VerifierError<A, D>["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
60
- /** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
61
- export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D>>>; };
95
+ /**
96
+ * Every action of `Actions` as `client.<action>(input)`, calling its tool. Each call also fails
97
+ * with `E` and requires `R`, what its `transformClient` adds.
98
+ */
99
+ export type Client<Actions extends ReadonlyArray<Any>, D = undefined, E = never, R = never> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D> | E, R>>; };
62
100
  /**
63
101
  * A client of an MCP endpoint served by `layerHttp`, one method per action calling
64
102
  * its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
@@ -71,6 +109,7 @@ export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readon
71
109
  * native `HttpClient`, such as the one `Testing.layer` provides.
72
110
  */
73
111
  export declare function client<const Actions extends ReadonlyArray<Any>, D extends Any$1 | undefined = undefined>(actions: Actions, options?: ClientOptions<D>): Effect.Effect<Client<Actions, D>, never, HttpClient.HttpClient>;
112
+ export declare function client<const Actions extends ReadonlyArray<Any>, E, R, D extends Any$1 | undefined = undefined>(actions: Actions, options: ClientOptions<D, E, R>): Effect.Effect<Client<Actions, D, E, R>, never, HttpClient.HttpClient>;
74
113
  /**
75
114
  * One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
76
115
  * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
@@ -84,4 +123,5 @@ export declare function client<const Actions extends ReadonlyArray<Any>, D exten
84
123
  * a call its types would not send.
85
124
  */
86
125
  export declare const request: (method: string, params?: Params, { headers, url }?: RequestOptions) => HttpClientRequest.HttpClientRequest;
87
- //#endregion
126
+ //#endregion
127
+ export {};
@@ -1,8 +1,10 @@
1
+ import { Anyone } from "../contract/caller.mjs";
1
2
  import { Refusal } from "../contract/errors.mjs";
2
- import { Anyone, assertOnce, projectedErrors } from "../contract/rules.mjs";
3
+ import { assertOnce } from "../contract/names.mjs";
4
+ import { projectedErrors } from "../contract/declared.mjs";
3
5
  import { checked } from "../contract/call.mjs";
4
6
  import { defaultPath, httpProtocol, liftOf, unliftedFrom } from "./protocol.mjs";
5
- import { Effect, Option, Predicate, Schema, identity } from "effect";
7
+ import { Effect, Match, Option, Predicate, Schema } from "effect";
6
8
  import { McpSchema } from "effect/ai";
7
9
  import { HttpClient, HttpClientRequest } from "effect/http";
8
10
  import { Sse } from "effect/encoding";
@@ -42,14 +44,56 @@ const statelessRequest = (method, params) => {
42
44
  }
43
45
  };
44
46
  };
47
+ /** The endpoint answered with an HTTP status other than 200, and a body no declared error decodes. */
48
+ var StatusError = class extends Schema.TaggedError()("StatusError", {
49
+ status: Schema.Int,
50
+ body: Schema.String
51
+ }) {};
45
52
  /**
46
- * An answer a client method cannot read as the action's success or a declared error, such as
47
- * the native server's message for invalid arguments or an unknown tool, or a result without
48
- * its structured content. Its message holds the answer. Structured content that does not
49
- * decode as the success is a `SchemaError` instead, and a request that fails an
50
- * `HttpClientError`.
53
+ * The endpoint answered with a JSON-RPC error, such as the native server's for invalid arguments
54
+ * or an unknown tool.
51
55
  */
52
- var McpCallError = class extends Schema.TaggedError()("McpCallError", { message: Schema.String }) {};
56
+ var ProtocolError = class extends Schema.TaggedError()("ProtocolError", {
57
+ code: Schema.Finite,
58
+ message: Schema.String
59
+ }) {};
60
+ /** The endpoint answered 200 without a JSON-RPC reply to read, as JSON or as an event stream. */
61
+ var NoReply = class extends Schema.TaggedError()("NoReply", { body: Schema.String }) {};
62
+ /** The tool returned an error whose text decodes as none of the action's declared errors. */
63
+ var UndeclaredError = class extends Schema.TaggedError()("UndeclaredError", { text: Schema.String }) {};
64
+ /** The tool returned a result without the structured content its action's success needs. */
65
+ var NoStructuredContent = class extends Schema.TaggedError()("NoStructuredContent", { body: Schema.String }) {};
66
+ const Reason = Schema.Union([
67
+ StatusError,
68
+ ProtocolError,
69
+ NoReply,
70
+ UndeclaredError,
71
+ NoStructuredContent
72
+ ]);
73
+ const sentenceOf = Match.typeTags()({
74
+ StatusError: ({ status, body }) => `answered ${status}: ${body}`,
75
+ ProtocolError: ({ code, message }) => `failed with ${code}: ${message}`,
76
+ NoReply: ({ body }) => `had no reply: ${body}`,
77
+ UndeclaredError: ({ text }) => `returned an error: ${text}`,
78
+ NoStructuredContent: ({ body }) => `returned no structured content: ${body}`
79
+ });
80
+ /**
81
+ * An answer a client method cannot read as the action's success or a declared error: `reason`
82
+ * says which, with what the endpoint sent, so a caller branches on it with
83
+ * `Effect.catchReason("McpCallError", "StatusError", ...)`. `message` names the tool and the
84
+ * reason. Structured content that does not decode as the success is a `SchemaError` instead,
85
+ * and a request that fails an `HttpClientError`.
86
+ */
87
+ var McpCallError = class extends Schema.TaggedError()("McpCallError", {
88
+ tool: Schema.String,
89
+ reason: Reason,
90
+ message: Schema.String
91
+ }) {};
92
+ const callError = (tool, reason) => new McpCallError({
93
+ tool,
94
+ reason,
95
+ message: `MCP tools/call "${tool}" ${sentenceOf(reason)}`
96
+ });
53
97
  const textOf = (block) => block?.type === "text" ? block.text : void 0;
54
98
  const ToolCallReply = Schema.Union([Schema.Struct({ result: McpSchema.CallToolResult }), Schema.Struct({ error: Schema.Struct({
55
99
  code: Schema.Finite,
@@ -67,7 +111,6 @@ const replyOf = (response, text) => {
67
111
  const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
68
112
  const callTool = (client, url, action, lift, refusedWith, input) => {
69
113
  const { name } = action;
70
- const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
71
114
  return Effect.gen(function* () {
72
115
  const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
73
116
  const response = yield* client.execute(request("tools/call", {
@@ -75,26 +118,26 @@ const callTool = (client, url, action, lift, refusedWith, input) => {
75
118
  arguments: encoded
76
119
  }, { url }));
77
120
  const text = yield* response.text;
78
- if (response.status !== 200) return yield* failWith(refusedWith, text, other(`answered ${response.status}: ${text}`));
121
+ if (response.status !== 200) return yield* failWith(refusedWith, text, callError(name, new StatusError({
122
+ status: response.status,
123
+ body: text
124
+ })));
79
125
  const reply = replyOf(response, text);
80
- if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
81
- if ("error" in reply.value) {
82
- const { code, message } = reply.value.error;
83
- return yield* Effect.fail(other(`failed with ${code}: ${message}`));
84
- }
126
+ if (Option.isNone(reply)) return yield* Effect.fail(callError(name, new NoReply({ body: text })));
127
+ if ("error" in reply.value) return yield* Effect.fail(callError(name, new ProtocolError(reply.value.error)));
85
128
  const { result } = reply.value;
86
129
  if (result.isError === true) {
87
130
  const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
88
- return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
131
+ return yield* failWith(projectedErrors(action), error, callError(name, new UndeclaredError({ text: error })));
89
132
  }
90
- if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
133
+ if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(callError(name, new NoStructuredContent({ body: text })));
91
134
  return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(unliftedFrom(lift, result));
92
135
  });
93
136
  };
94
- function client(actions, { url = defaultPath, transformClient = identity, authentication } = {}) {
137
+ function client(actions, { url = defaultPath, transformClient, authentication } = {}) {
95
138
  assertOnce("action", actions);
96
139
  return Effect.map(HttpClient.HttpClient, (native) => {
97
- const http = transformClient(native);
140
+ const http = transformClient === void 0 ? native : transformClient(native);
98
141
  return Object.fromEntries(actions.map((action) => {
99
142
  const lift = liftOf(action.success);
100
143
  const refusedWith = [...Refusal.members, ...action.caller === Anyone ? [] : authentication?.error ?? []];
@@ -1,4 +1,4 @@
1
- import { unsuspended } from "../contract/rules.mjs";
1
+ import { unsuspended } from "../contract/ast.mjs";
2
2
  import { Media, isMarked, kindOf } from "../contract/media.mjs";
3
3
  import { Predicate, Schema, SchemaAST } from "effect";
4
4
  import { McpProtocol } from "effect/ai";
package/docs/Action.md CHANGED
@@ -158,11 +158,25 @@ and the whole builder above [notes-service/src/notes/handlers.ts](../examples/no
158
158
  - **Pass the builder to `implement` itself.** Through `.pipe(...)` or a wrapper such as
159
159
  `Effect.withSpan(builder, name)`, an `Effect.fn` handler it returns takes an `any` input. A
160
160
  builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
161
- - **Leave a handler's `Effect.fn` unnamed**: each handler already runs in a span named after its
162
- action, and its log lines carry `action.name` and `action.read_only`.
161
+ - **Leave a handler's `Effect.fn` unnamed** unless you need handler-only timing: each action
162
+ already has a span ([Observe calls](#observe-calls)).
163
163
  - **Calling the function you passed to `implement` skips authorization.** Call the
164
164
  implementation through [`Action.client`](Action-in-process.md#call-actions-in-process).
165
165
 
166
+ ## Observe calls
167
+
168
+ The span named after an action includes authorization, handler execution and call cleanup.
169
+ Its exit reflects the final dispatch outcome; the span and its logs carry `action.name` and
170
+ `action.read_only`.
171
+
172
+ Transport credential verification, input decoding and response encoding run outside this span.
173
+ A request rejected by verification or decoding has no action span; response encoding can fail
174
+ after the action span succeeds.
175
+
176
+ Add a named handler span to measure handler time alone. The named authorizer in
177
+ [notes-service/src/identity/authorize.ts](../examples/notes-service/src/identity/authorize.ts)
178
+ similarly measures authorization as a child of the action span.
179
+
166
180
  ## Authorize calls
167
181
 
168
182
  An implementation holding a protected action states `{ authorize }`: a function of the called
@@ -233,5 +247,5 @@ failure compiles. What each surface answers is in [guarantees.md](guarantees.md#
233
247
  or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
234
248
  told to sign in again. Map it to an error the action declares, or to a defect.
235
249
  - **Map that client's `HttpClientError` and `SchemaError` to one declared error a caller can
236
- act on** ([mcp-tools/src/printers/farm.ts](../examples/mcp-tools/src/printers/farm.ts)). As
250
+ act on** ([mcp-tools/src/platform/print-farm.ts](../examples/mcp-tools/src/platform/print-farm.ts)). As
237
251
  defects, a model reads only a generic text.
@@ -63,7 +63,10 @@ The tasks are in [ActionCli.md](ActionCli.md).
63
63
  `client`. To choose by a condition, build a command from each:
64
64
  `remotely ? ActionCli.remote(Http, options) : ActionCli.make(users, options)`.
65
65
  - `Type 'HttpClient' is not assignable to type 'never'` in the pipe that runs a remote command:
66
- provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`).
66
+ provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`). Another service named there
67
+ is what its `client` requires: provide it too.
68
+ - An argument of type `Binding<…>` not assignable at `remote` or `remoteCommand`, beside a
69
+ `client`: the client is another binding's. Make it with `ActionHttp.client` of this one.
67
70
  - A local command requires an identity service no handler yields: its action is protected, and
68
71
  requires the identity its contract declares. Provide it on the command
69
72
  ([Name the caller](ActionCli.md#name-the-caller)); do not make the contract public to satisfy
@@ -79,20 +82,18 @@ The tasks are in [ActionCli.md](ActionCli.md).
79
82
  - `--help` or a mistyped flag connects to a database, or fails reading config: services provided
80
83
  around `Command.run` are built before parsing. Provide them on the command,
81
84
  `Command.provide(layer)`.
82
- - `ERROR (#n): ConfigError: SchemaError(Expected string at ["NOTES_TOKEN"])` from a public
83
- command, exit 1, on stdout, or on stderr under `logToStderr`: the client provided on the
84
- command requires a token, and a failure of what a command is provided is `runMain`'s report,
85
- not the command's JSON. Read the token with `Config.option` and add the header only when it is
86
- there. Where every command needs it, keep it required and map the failure, in the Effect that
87
- provides the client, to `new CliError.UserError({ cause, userMessage })`:
85
+ - `ConfigError: …` or another error's `Tag: message` on stderr from a remote command, a public
86
+ one too: its `client` failed, as when a required token is not set. Read an optional one with
87
+ `Config.option` and add the header only when it is there. Where every command needs it, map
88
+ the failure to a tagged error whose `message` says what to do:
88
89
  [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
89
90
  - `HttpClientError` whose `reason._tag` is `InvalidUrlError`, from a remote command: its client
90
91
  has no `baseUrl` and prepends no URL.
91
92
  - 401 `Unauthenticated` from a remote command: a remote command adds no headers of its own. Add
92
- credentials in `client`'s `transformClient`, or on the `HttpClient` the command is provided.
93
+ credentials in its client's `transformClient`.
93
94
  - Another command's request goes to the API's URL, or carries its token: an `HttpClient`
94
95
  configured around the whole program rewrites every request the program makes. Configure it in
95
- the `client` option, or provide it on the remote command alone.
96
+ the `client` option.
96
97
  - A report with a stack on stdout, `ERROR (#1): ...`, instead of JSON on stderr: a defect, or a
97
98
  failure of a layer the host provides, which `runMain` reports. Fix the defect; apply
98
99
  `ActionCli.logToStderr` to keep such reports off stdout.
package/docs/ActionCli.md CHANGED
@@ -36,10 +36,10 @@ Import `@gjermundgaraba/effect-actions/ActionCli`.
36
36
  | `make`, `remote`: `actions` | The actions that are subcommands; default: every one. |
37
37
  | `make`, `remote`: `commands` | Each subcommand's `command` options, by action name: `{ getNote: { render } }`. |
38
38
  | `make`, `remote`: `subcommands` | Commands of your own beside the derived ones: `[exportNotes]`. |
39
- | `remoteCommand`, `remote`: `client` | The `ActionHttp.client` options, `baseUrl` and `transformClient`. |
39
+ | `remoteCommand`, `remote`: `client` | The client its commands call: `ActionHttp.client(Http, { baseUrl })`. |
40
40
 
41
- Exported types: `Options<A>`, `RemoteOptions<A>`, `CommandOptions<A>`, `RemoteCommandOptions<A>`,
42
- and `UserError<E>`, a type only: what a command fails with when its action fails with `E`.
41
+ Exported types: `Options<A>` of `make` and `remote`, `CommandOptions<A>` of `command` and
42
+ `remoteCommand`, and `UserError<E>`, a type only: what a command fails with when its action fails with `E`.
43
43
 
44
44
  ## Run actions as local commands
45
45
 
@@ -47,7 +47,7 @@ Pass implementations to `make`, and provide the services and the caller on the c
47
47
 
48
48
  ```ts
49
49
  const assistant = ActionCli.make([tasks, identity], { name: "assistant" }).pipe(
50
- Command.provide(Layer.mergeAll(TaskStore.layerFile, Permissions.layerMemory)),
50
+ Command.provide(Layer.mergeAll(tasksFile, Permissions.layerMemory)),
51
51
  Command.provideSync(CurrentCaller, operator),
52
52
  );
53
53
  ```
@@ -81,21 +81,22 @@ Run it with Effect's own `Command.run`:
81
81
  Pass the HTTP binding to `remote`. The program imports the binding alone, never a handler; each
82
82
  command calls its action through the binding's `ActionHttp.client` method
83
83
  ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)) and fails as that method does. The host
84
- provides `HttpClient` (`NodeHttpClient.layerUndici`) and says where the server is:
85
- [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts) and
86
- [notes-service/src/cli.ts](../examples/notes-service/src/cli.ts).
84
+ provides `HttpClient` (`NodeHttpClient.layerUndici`) in
85
+ [notes-service/src/cli.ts](../examples/notes-service/src/cli.ts); the client says where the
86
+ server is in [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
87
87
  [kitchen-sink/src/cli.ts](../examples/kitchen-sink/src/cli.ts) adds a tenant header and uses
88
88
  every command option.
89
89
 
90
- - **Choose where the connection comes from.** A fixed URL or token goes in the `client` option:
91
- `client: { baseUrl, transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)) }`.
92
- One read from `Config` configures the `HttpClient` provided on the command, with
93
- `Command.provideEffect(HttpClient.HttpClient, …)` as `notes-service/src/cli.ts` does, so
94
- `--help` needs neither. A command of your own in `subcommands` calls through the same client.
95
- - **Configure the client on the command, never around the program.** Around the program it
96
- rewrites every request the program makes, an absolute URL too, and sends each one the token.
97
- - **A token every command needs is required, its failure mapped to a `CliError.UserError`**, or a
98
- missing one prints a raw `ConfigError` log line: `connection` in
90
+ - **The connection is the `client` option**, an Effect of the binding's client run each time a
91
+ command runs: `client: ActionHttp.client(Http, { baseUrl, transformClient })`. One reading
92
+ `Config` is an `Effect.gen` returning `yield* ActionHttp.client(…)`, so `--help` reads nothing.
93
+ Its failures and its methods' are the command's, printed as `Tag: message`, a
94
+ `CliError.UserError` as its `userMessage`; what they require, the host provides. Credentials go in `transformClient`
95
+ ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)).
96
+ - **Never transform the `HttpClient` around the program**: it rewrites every request the program
97
+ makes, an absolute URL too, and sends each one the token.
98
+ - **A token every command needs is required, its failure mapped to a tagged error whose `message`
99
+ says what to do**, or a missing one prints as `ConfigError: …`: `api` in
99
100
  [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts). Where a public
100
101
  command must answer signed out, read it with `Config.option` and add the header when it is
101
102
  there: [kitchen-sink/src/cli.ts](../examples/kitchen-sink/src/cli.ts).
@@ -156,15 +157,15 @@ in `subcommands`: `notes export <file>` takes `Argument.String("file")`
156
157
  ([notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)):
157
158
 
158
159
  ```ts
159
- const notes = ActionCli.remote(Http, { name: "notes", subcommands: [exportNotes] });
160
+ const notes = ActionCli.remote(Http, { name: "notes", client: api, subcommands: [exportNotes] });
160
161
  ```
161
162
 
162
163
  - **Never pipe `Command.withSubcommands` onto `make` or `remote`.** It replaces the derived
163
164
  commands, which then answer with the usage text.
164
- - **Call the actions through a client**, in the command's own handler: `ActionHttp.client`, which
165
- needs no scope and uses the `HttpClient` provided on the aggregate, or `Action.client`, acquired
166
- inside `Effect.scoped` ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
167
- `authorize` still runs. The `client` option reaches the derived commands alone.
165
+ - **Call the actions through a client**, in the command's own handler: `yield* api`, the Effect
166
+ the aggregate's `client` is, which needs no scope, or `Action.client`, acquired inside
167
+ `Effect.scoped` ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
168
+ `authorize` still runs.
168
169
  - **Its output is what it prints**, with `Console.log`: the output rules below are a derived
169
170
  command's. **Pipe its whole Effect through `ActionCli.toUserError(Http)`**, the binding its
170
171
  client is made from, HTTP or RPC, or `toUserError([GetNote])` over `Action.client`, to fail as a derived command does: a declared
@@ -53,6 +53,12 @@ The tasks are in [ActionHttp.md](ActionHttp.md).
53
53
  the binding's `error`, given inline so its type is certain, or move the check to the handlers
54
54
  and declare the error on their actions.
55
55
 
56
+ - `No overload matches this call` at `client`, naming `Options<unknown, unknown>`: options built
57
+ before the call hold the generic `HttpClient.mapRequestEffect(authorize)`, whose failures the
58
+ call cannot infer, or a helper types them as `Parameters<typeof ActionHttp.client>[1]`. Write
59
+ the transform as `(client) => HttpClient.mapRequestEffect(client, authorize)`, and type such
60
+ options as `ActionHttp.ClientOptions<E, R>`.
61
+
56
62
  ## Requests
57
63
 
58
64
  - A route answers without credentials: its contract is `caller: Action.Anyone`.
@@ -36,7 +36,7 @@ Exported types: `Binding`; `Any`, any binding; `Client<typeof Http>`, a client's
36
36
  | `layer`: `actions` | The bound actions it serves: `[GetUser]`. Defaults to every one the implementations hold. |
37
37
  | `layer`: `middleware` | Native `HttpApiMiddleware` around every route it serves, the first innermost. |
38
38
  | `client`: `baseUrl` | What routes resolve against: `https://api.example.com`. Omitted: relative ones, a page's origin. |
39
- | `client`: `transformClient` | Wraps the native `HttpClient`: `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. |
39
+ | `client`: `transformClient` | Wraps the native `HttpClient`: a token, or a sign-in per request, whose failures each call adds. |
40
40
  | `fetchClient`: `fetch` | Its `fetch`, beside `client`'s options. Omitted: the global `fetch`, looked up on every call. |
41
41
 
42
42
  Each action is served at `POST <prefix>/<action>`, a `readOnly` one too. One without `success`
@@ -185,9 +185,12 @@ Files: [quickstart/src/client.ts](../examples/quickstart/src/client.ts),
185
185
  [kitchen-sink/src/seed.ts](../examples/kitchen-sink/src/seed.ts) (`baseUrl` from `Config`,
186
186
  headers, errors by tag).
187
187
 
188
- - Outside a browser, set `baseUrl`. A token or any other header goes through `transformClient`.
189
- Where no client is built by hand, as under `ActionCli.remote`, transform the `HttpClient`
190
- itself (`connection` in [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)).
188
+ - Outside a browser, set `baseUrl`. A token or any other header goes through `transformClient`:
189
+ `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. A credential read or refreshed
190
+ as each request is sent, which may fail, is
191
+ `(client) => HttpClient.mapRequestEffect(client, authorize)`: each call then also fails with
192
+ `authorize`'s own errors, as they are, and requires its services. `ActionMcp.client` takes the
193
+ same transform, and `ActionCli.remote` the client itself as its `client`.
191
194
  - Leave the argument out when `{}` is a valid input. A given argument is the decoded input, so
192
195
  it states a defaulted field.
193
196
  - A declared error is a typed failure, as its decoded value: the action's own, the binding's,
@@ -215,6 +218,8 @@ with `Effect.runPromise`. Files:
215
218
  - A page on another origin passes `baseUrl`, and the server must allow that origin with
216
219
  `HttpRouter.cors`
217
220
  ([kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts), `cors`).
221
+ - A `transformClient` requiring a service, such as the session's token, makes each call require
222
+ it: provide it on the call before `Effect.runPromise`.
218
223
  - An unhandled declared error rejects the promise as its decoded value, so
219
224
  `error instanceof Action.Unauthenticated` works in a `catch`. Handle an expected one by its tag
220
225
  before `Effect.runPromise`.
package/docs/ActionMcp.md CHANGED
@@ -21,7 +21,7 @@ Import `@gjermundgaraba/effect-actions/ActionMcp`.
21
21
  | `LayerHttpOptions` | Type of `layerHttp`'s options: `Options` and HTTP's own. |
22
22
  | `client(actions, options?)` | An Effect of a client calling each action's tool. |
23
23
  | `request(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` to send. |
24
- | `McpCallError` | A client method's failure for an answer it cannot decode. |
24
+ | `McpCallError` | A client method's undecodable answer, by tagged `reason`. |
25
25
 
26
26
  `implementations` is one implementation or a list.
27
27
 
@@ -44,7 +44,7 @@ options. Its `protocols` and `allowSessionTermination` are not options.
44
44
 
45
45
  `client` takes `url`, default `/mcp`, `transformClient`, and the endpoint's `authentication`, so an
46
46
  error its verifier declares decodes; `request` takes `url` and `headers`.
47
- Types: `Client<Actions, D>`, `ClientOptions`, `RequestOptions`, `Params`.
47
+ Types: `Client<Actions, D, E, R>`, `ClientOptions`, `RequestOptions`, `Params`.
48
48
 
49
49
  ## Serve tools over HTTP
50
50
 
@@ -140,7 +140,7 @@ tool's read-only hint, and the contract's `mcp` option holds its `title`, its ot
140
140
  field's `description`, annotated before the check: after a custom one it is lost too.
141
141
  - A declared error reaches the model as an `isError` result whose text is the error's JSON. Write
142
142
  its `message` for the model, which acts on it:
143
- [mcp-tools/src/printers/farm.ts](../examples/mcp-tools/src/printers/farm.ts). Every other case:
143
+ [mcp-tools/src/platform/print-farm.ts](../examples/mcp-tools/src/platform/print-farm.ts). Every other case:
144
144
  [guarantees.md](guarantees.md#wire-behavior).
145
145
  - `actions` lists the tools, and only listed actions must suit MCP:
146
146
  [kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts). Leave out an action a