@gjermundgaraba/effect-actions 0.13.0 → 0.15.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 (58) hide show
  1. package/README.md +2 -2
  2. package/dist/Action.d.mts +1 -1
  3. package/dist/ActionCli.d.mts +4 -6
  4. package/dist/ActionCli.mjs +11 -9
  5. package/dist/ActionHttp.mjs +18 -30
  6. package/dist/ActionMcp.d.mts +16 -7
  7. package/dist/ActionMcp.mjs +76 -89
  8. package/dist/ActionRpc.mjs +17 -29
  9. package/dist/ActionToolkit.mjs +26 -11
  10. package/dist/Authentication.mjs +1 -2
  11. package/dist/Testing.d.mts +6 -73
  12. package/dist/Testing.mjs +3 -102
  13. package/dist/authentication/provider.mjs +1 -6
  14. package/dist/authentication/refusal.mjs +14 -4
  15. package/dist/cli/commands.mjs +8 -8
  16. package/dist/contract/call.d.mts +2 -1
  17. package/dist/contract/call.mjs +14 -1
  18. package/dist/contract/errors.d.mts +2 -3
  19. package/dist/contract/errors.mjs +6 -12
  20. package/dist/contract/implementation.mjs +33 -5
  21. package/dist/contract/rules.d.mts +7 -0
  22. package/dist/contract/rules.mjs +50 -3
  23. package/dist/http/client.mjs +4 -7
  24. package/dist/mcp/client.d.mts +87 -0
  25. package/dist/mcp/client.mjs +122 -0
  26. package/dist/mcp/protocol.mjs +25 -37
  27. package/docs/Action.md +6 -5
  28. package/docs/ActionCli.md +4 -3
  29. package/docs/ActionMcp-failures.md +6 -2
  30. package/docs/ActionMcp.md +30 -33
  31. package/docs/ActionToolkit-failures.md +2 -1
  32. package/docs/ActionToolkit.md +2 -2
  33. package/docs/Authentication-failures.md +2 -2
  34. package/docs/Authentication.md +10 -5
  35. package/docs/CONTEXT.md +1 -1
  36. package/docs/Testing-failures.md +3 -3
  37. package/docs/Testing.md +16 -24
  38. package/docs/guarantees.md +15 -14
  39. package/docs/setup-failures.md +2 -2
  40. package/docs/setup.md +5 -5
  41. package/examples/kitchen-sink/README.md +26 -26
  42. package/examples/kitchen-sink/test/attachments/attachments.test.ts +3 -2
  43. package/examples/kitchen-sink/test/identity/identity.test.ts +13 -6
  44. package/examples/kitchen-sink/test/routes.test.ts +4 -3
  45. package/examples/live-rpc/README.md +7 -2
  46. package/examples/mcp-tools/test/routes.test.ts +7 -6
  47. package/examples/notes-service/README.md +13 -3
  48. package/examples/notes-service/src/notes/Notes.ts +4 -2
  49. package/examples/notes-service/src/routes.ts +24 -14
  50. package/examples/notes-service/src/worker.ts +20 -0
  51. package/examples/notes-service/test/commands.test.ts +2 -1
  52. package/examples/notes-service/test/routes.test.ts +6 -3
  53. package/examples/notes-service/test/worker.test.ts +34 -0
  54. package/examples/quickstart/test/greeting/greeting.test.ts +2 -1
  55. package/package.json +5 -5
  56. package/dist/http/declared.mjs +0 -20
  57. package/dist/mcp/protocol.d.mts +0 -18
  58. package/dist/toolkit/tools.mjs +0 -29
@@ -1,15 +1,11 @@
1
- import { BuiltIn } from "./contract/errors.mjs";
2
- import { Call } from "./contract/call.mjs";
3
- import { Action_d_exports, Any } from "./Action.mjs";
4
- import { Params } from "./mcp/protocol.mjs";
5
1
  import { Served } from "./testing/memory.mjs";
6
- import { Cause, Effect, Layer, Schema } from "effect";
7
- import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpRouter } from "effect/http";
2
+ import { Layer } from "effect";
3
+ import { HttpClient, HttpRouter } from "effect/http";
8
4
  //#region src/testing/Testing.d.ts
9
5
  /**
10
6
  * For a web handler the test already owns; most tests want `layer(routes)`, below, with
11
- * `ActionHttp.client` and `mcpClient`. The native `HttpClient`, answered by `handler` instead of
12
- * the network: a web handler the test serves, which other tests may share, such as
7
+ * `ActionHttp.client` and `ActionMcp.client`. The native `HttpClient`, answered by `handler`
8
+ * instead of the network: a web handler the test serves, which other tests may share, such as
13
9
  * `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))).handler`,
14
10
  * the routes' platform services provided, as `layer(routes)` provides them itself. It is the
15
11
  * client `layer(routes)` gives, without building anything: the test owns the handler, and
@@ -18,7 +14,7 @@ import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpRouter } f
18
14
  export declare function layer(handler: (request: Request) => Promise<Response>): Layer.Layer<HttpClient.HttpClient>;
19
15
  /**
20
16
  * The native `HttpClient`, answered in memory by `routes` instead of the network: provide
21
- * it to `ActionHttp.client` and to `mcpClient`. The routes are built with this layer and
17
+ * it to `ActionHttp.client` and to `ActionMcp.client`. The routes are built with this layer and
22
18
  * released with its scope, without request logs: only the cause of a defect, of a result that
23
19
  * does not encode or of a failure a route of your own leaves unhandled is logged, as an error,
24
20
  * where a server's request logger would show it, on the process console even under `it.effect`
@@ -36,67 +32,4 @@ export declare function layer(handler: (request: Request) => Promise<Response>):
36
32
  * A type error at `it.effect`, such as one naming `Scope`: `docs/Testing-failures.md`.
37
33
  */
38
34
  export declare function layer<A, E, R>(routes: Layer.Layer<A, E, R>): Layer.Layer<HttpClient.HttpClient, E, Exclude<HttpRouter.Request.Without<R> | HttpRouter.Request.Only<"Requires", R> | HttpRouter.Request.Only<"GlobalRequires", R>, Served>>;
39
- /** Where `mcpClient` sends, and through what client. */
40
- export interface McpClientOptions {
41
- /**
42
- * The endpoint, resolved by the `HttpClient`: relative under `layer`. Defaults to `/mcp`,
43
- * the default `ActionMcp.layerHttp` path.
44
- */
45
- readonly url?: string;
46
- /**
47
- * Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token is
48
- * `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
49
- */
50
- readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
51
- }
52
- /** Where an `mcpRequest` goes, and with what headers. */
53
- export interface McpRequestOptions {
54
- /**
55
- * The endpoint, resolved by the `HttpClient` that sends the request: relative under `layer`.
56
- * Defaults to `/mcp`, as `mcpClient`'s does; converting to a web `Request` needs an absolute
57
- * one.
58
- */
59
- readonly url?: string;
60
- readonly headers?: Headers.Input;
61
- }
62
- declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<"McpCallError", {
63
- readonly message: Schema.String;
64
- }>, Cause.YieldableError>;
65
- /**
66
- * An answer a client method cannot read as the action's success or a declared error, such as
67
- * the native server's message for invalid arguments or an unknown tool, or a result without
68
- * its structured content. Its message holds the answer. Structured content that does not
69
- * decode as the success is a `SchemaError` instead, and a request that fails an
70
- * `HttpClientError`.
71
- */
72
- export declare class McpCallError extends McpCallError_base {}
73
- type CallError<A extends Any> = A["error"][number]["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
74
- /** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
75
- export type McpClient<Actions extends ReadonlyArray<Any>> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A>>>; };
76
- /**
77
- * A client of an MCP endpoint served by `ActionMcp.layerHttp`, one method per action calling
78
- * its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
79
- * encoded with the action's schema, and the success decoded from its structured content, each
80
- * media field from the blocks whose `_meta` names it, and a success that is media, or whose
81
- * every field is, from its blocks alone. A declared error the tool returns, the action's own
82
- * or a refusal, is its decoded value, and so is a refusal the endpoint's authentication
83
- * answers with: a test telling a 401 or 403 from a tool result asserts the status of a request
84
- * `mcpRequest` builds. The argument may be omitted when `{}` is a valid input. Requires the
85
- * native `HttpClient`, such as the one `layer` provides.
86
- */
87
- export declare function mcpClient<const Actions extends ReadonlyArray<Any>>(actions: Actions, options?: McpClientOptions): Effect.Effect<McpClient<Actions>, never, HttpClient.HttpClient>;
88
- /**
89
- * One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it,
90
- * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
91
- * `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
92
- * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
93
- * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
94
- * version is always the request's own. The test sends it: `HttpClient.execute` answers the
95
- * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
96
- * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
97
- * asserting on what `mcpClient` decodes away, such as `tools/list`, a refusal's challenge, or
98
- * a call its types would not send.
99
- */
100
- export declare const mcpRequest: (method: string, params?: Params, { headers, url }?: McpRequestOptions) => HttpClientRequest.HttpClientRequest;
101
- //#endregion
102
- export type { Params as McpParams };
35
+ //#endregion
package/dist/Testing.mjs CHANGED
@@ -1,12 +1,6 @@
1
- import { Refusal } from "./contract/errors.mjs";
2
- import { assertOnce, projectedErrors } from "./contract/rules.mjs";
3
- import { checked } from "./contract/call.mjs";
4
- import { defaultPath, liftOf, statelessRequest } from "./mcp/protocol.mjs";
5
1
  import { clientOf } from "./testing/memory.mjs";
6
- import { Cause, Console, Context, Effect, ErrorReporter, FileSystem, Layer, Option, Path, Predicate, Schema, identity } from "effect";
7
- import { McpSchema } from "effect/ai";
8
- import { Etag, HttpClient, HttpClientRequest, HttpEffect, HttpPlatform, HttpRouter } from "effect/http";
9
- import { Base64, Sse } from "effect/encoding";
2
+ import { Cause, Console, Context, Effect, ErrorReporter, FileSystem, Layer, Option, Path } from "effect";
3
+ import { Etag, HttpEffect, HttpPlatform, HttpRouter } from "effect/http";
10
4
  //#region src/testing/Testing.ts
11
5
  function layer(routes) {
12
6
  if (!Layer.isLayer(routes)) return clientOf(routes);
@@ -19,98 +13,5 @@ function layer(routes) {
19
13
  }));
20
14
  }
21
15
  const isReported = (cause) => cause.reasons.some((reason) => Cause.isFailReason(reason) && !ErrorReporter.isIgnored(reason.error) || Cause.isDieReason(reason) && !ErrorReporter.isIgnored(reason.defect));
22
- /**
23
- * An answer a client method cannot read as the action's success or a declared error, such as
24
- * the native server's message for invalid arguments or an unknown tool, or a result without
25
- * its structured content. Its message holds the answer. Structured content that does not
26
- * decode as the success is a `SchemaError` instead, and a request that fails an
27
- * `HttpClientError`.
28
- */
29
- var McpCallError = class extends Schema.TaggedError()("McpCallError", { message: Schema.String }) {};
30
- const textOf = (block) => block?.type === "text" ? block.text : void 0;
31
- const ToolCallReply = Schema.Union([Schema.Struct({ result: McpSchema.CallToolResult }), Schema.Struct({ error: Schema.Struct({
32
- code: Schema.Finite,
33
- message: Schema.String
34
- }) })]);
35
- const decodeReply = Schema.decodeUnknownOption(Schema.fromJsonString(ToolCallReply));
36
- const replyOf = (response, text) => {
37
- if (!(response.headers["content-type"] ?? "").startsWith("text/event-stream")) return decodeReply(text);
38
- const data = [];
39
- Sse.makeParser((event) => {
40
- if (Predicate.isTagged(event, "Event")) data.push(event.data);
41
- }).feed(text);
42
- return Option.fromNullishOr(data.flatMap((message) => Option.toArray(decodeReply(message))).at(-1));
43
- };
44
- const mediaFromBlocks = ({ name, kind, many }, result) => {
45
- const values = result.content.flatMap((block) => block.type === kind && block._meta?.["effect-actions/field"] === name ? [{
46
- data: Base64.encode(block.data),
47
- mimeType: block.mimeType
48
- }] : []);
49
- return many || values.length > 1 ? values : values[0];
50
- };
51
- const encodedSuccessOf = (lift, result) => {
52
- const [whole] = lift.fields;
53
- if (whole !== void 0 && whole.name === void 0) return mediaFromBlocks(whole, result);
54
- const { structuredContent = {} } = result;
55
- if (!Predicate.isObject(structuredContent)) return structuredContent;
56
- const lifted = new Set(lift.fields.map(({ name }) => name));
57
- return Object.fromEntries([...Object.entries(structuredContent).filter(([key]) => !lifted.has(key)), ...lift.fields.flatMap((field) => {
58
- const value = mediaFromBlocks(field, result);
59
- return field.name === void 0 || value === void 0 ? [] : [[field.name, value]];
60
- })]);
61
- };
62
- const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
63
- const callTool = (client, url, action, lift, input) => {
64
- const { name } = action;
65
- const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
66
- return Effect.gen(function* () {
67
- const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
68
- const response = yield* client.execute(mcpRequest("tools/call", {
69
- name,
70
- arguments: encoded
71
- }, { url }));
72
- const text = yield* response.text;
73
- if (response.status !== 200) return yield* failWith(Refusal.members, text, other(`answered ${response.status}: ${text}`));
74
- const reply = replyOf(response, text);
75
- if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
76
- if ("error" in reply.value) {
77
- const { code, message } = reply.value.error;
78
- return yield* Effect.fail(other(`failed with ${code}: ${message}`));
79
- }
80
- const { result } = reply.value;
81
- if (result.isError === true) {
82
- const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
83
- return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
84
- }
85
- if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
86
- return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(encodedSuccessOf(lift, result));
87
- });
88
- };
89
- function mcpClient(actions, { url = defaultPath, transformClient = identity } = {}) {
90
- assertOnce("action", actions);
91
- return Effect.map(HttpClient.HttpClient, (native) => {
92
- const client = transformClient(native);
93
- return Object.fromEntries(actions.map((action) => {
94
- const lift = liftOf(action.success);
95
- return [action.name, checked(action, (input) => callTool(client, url, action, lift, input))];
96
- }));
97
- });
98
- }
99
- /**
100
- * One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it,
101
- * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
102
- * `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
103
- * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
104
- * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
105
- * version is always the request's own. The test sends it: `HttpClient.execute` answers the
106
- * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
107
- * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
108
- * asserting on what `mcpClient` decodes away, such as `tools/list`, a refusal's challenge, or
109
- * a call its types would not send.
110
- */
111
- const mcpRequest = (method, params = {}, { headers = {}, url = defaultPath } = {}) => {
112
- const { headers: routingHeaders, body } = statelessRequest(method, params);
113
- return HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.setHeaders(routingHeaders), HttpClientRequest.bodyJsonUnsafe(body));
114
- };
115
16
  //#endregion
116
- export { McpCallError, layer, mcpClient, mcpRequest };
17
+ export { layer };
@@ -1,12 +1,7 @@
1
1
  import { Unauthenticated } from "../contract/errors.mjs";
2
- import { Anyone } from "../contract/rules.mjs";
3
2
  import { Effect, Option } from "effect";
4
3
  //#region src/authentication/provider.ts
5
- const withVerifierErrors = (action, errors, verifierErrors) => action.caller === Anyone || verifierErrors === void 0 ? errors : [...errors, ...verifierErrors];
6
- const assertAuthentication = (actions, auth) => {
7
- for (const action of actions) if (action.caller !== Anyone && (auth === void 0 || action.caller.key !== auth.identity.key)) throw new Error(`Protected action '${action.name}' requires its matching authentication descriptor`);
8
- };
9
4
  const providerOf = (auth) => Effect.flatMap(Effect.service(auth["~provider"]), (provider) => provider.descriptor === auth ? Effect.succeed(provider) : Effect.die(/* @__PURE__ */ new Error(`Authentication "${auth.name}": the binding's descriptor is not its provider's; build both from one descriptor`)));
10
5
  const promote = (auth, run) => Effect.flatMap(Effect.serviceOption(auth["~verified"]), (actor) => Option.isSome(actor) ? Effect.provideService(run, auth.identity, actor.value) : Effect.fail(new Unauthenticated()));
11
6
  //#endregion
12
- export { assertAuthentication, promote, providerOf, withVerifierErrors };
7
+ export { promote, providerOf };
@@ -1,14 +1,20 @@
1
- import { Refusal, statuses } from "../contract/errors.mjs";
1
+ import { Forbidden, Refusal, Unauthenticated } from "../contract/errors.mjs";
2
+ import { declared, httpApiStatusOf } from "../contract/rules.mjs";
2
3
  import { Cause, Context, Effect, Exit, Option, Predicate, Ref, Schema } from "effect";
3
4
  import { HttpServerResponse } from "effect/http";
4
5
  //#region src/authentication/refusal.ts
6
+ const responseOf = (schema, error) => HttpServerResponse.jsonUnsafe(Schema.encodeUnknownSync(Schema.toCodecJson(schema))(error), { status: httpApiStatusOf(schema.ast) ?? 422 });
7
+ /** `error` answered as an endpoint declaring `errors` answers it, if one of them declares it. */
8
+ const declaredResponse = (errors, error) => {
9
+ const schema = errors.flatMap(declared).find((each) => Schema.is(each)(error));
10
+ return schema === void 0 ? void 0 : responseOf(schema, error);
11
+ };
5
12
  const rfc6750ErrorDescriptionCharacters = /^[\x20\x21\x23-\x5B\x5D-\x7E]+$/;
6
13
  const challenge = (scheme, parameters) => {
7
14
  const given = parameters.flatMap(([name, value]) => value === void 0 ? [] : [`${name}="${value.replace(/["\\]/g, "\\$&")}"`]);
8
15
  return given.length === 0 ? scheme : `${scheme} ${given.join(", ")}`;
9
16
  };
10
17
  const bearer = (parameters) => challenge("Bearer", parameters);
11
- const encode = Schema.encodeSync(Refusal);
12
18
  const isRefusal = Schema.is(Refusal);
13
19
  const insufficientScopeChallenge = (error, metadataUrl) => Predicate.isTagged(error, "Forbidden") && error.scopes !== void 0 ? bearer([
14
20
  ["error", "insufficient_scope"],
@@ -16,7 +22,11 @@ const insufficientScopeChallenge = (error, metadataUrl) => Predicate.isTagged(er
16
22
  ["resource_metadata", metadataUrl],
17
23
  ["error_description", rfc6750ErrorDescriptionCharacters.test(error.message) ? error.message : void 0]
18
24
  ]) : void 0;
19
- const plain = (error) => HttpServerResponse.jsonUnsafe(encode(error), { status: statuses[error._tag] });
25
+ const refusalSchemas = {
26
+ Unauthenticated,
27
+ Forbidden
28
+ };
29
+ const plain = (error) => responseOf(refusalSchemas[error._tag], error);
20
30
  const answer = (error, metadataUrl) => {
21
31
  const response = plain(error);
22
32
  const scoped = insufficientScopeChallenge(error, metadataUrl);
@@ -33,4 +43,4 @@ const answerStepUp = (route, metadataUrl) => Effect.gen(function* () {
33
43
  return Option.isSome(refused) && !diedOrInterruptedAfterRefusal ? answer(refused.value, metadataUrl) : yield* exit;
34
44
  });
35
45
  //#endregion
36
- export { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain, recordStepUp };
46
+ export { answer, answerStepUp, bearer, challenge, declaredResponse, isRefusal, isStepUp, plain, recordStepUp };
@@ -1,7 +1,8 @@
1
1
  import { InvalidInput } from "../contract/errors.mjs";
2
- import { assertDistinct, literalValues, members, projectedErrors, unsuspended } from "../contract/rules.mjs";
2
+ import { assertDistinct, literalValues, members, unsuspended } from "../contract/rules.mjs";
3
+ import { decodeInput } from "../contract/call.mjs";
3
4
  import { withStderrConsole } from "../stdio/console.mjs";
4
- import { Console, Effect, Option, Predicate, Record, Runtime, Schema, SchemaAST, Stdio, Stream, flow } from "effect";
5
+ import { Console, Effect, Option, Predicate, Record, Runtime, Schema, SchemaAST, Stdio, Stream } from "effect";
5
6
  import { CliError, Command, Flag, Param } from "effect/cli";
6
7
  //#region src/cli/commands.ts
7
8
  const kebab = (name) => {
@@ -204,8 +205,8 @@ const userErrorOf = (errors) => {
204
205
  userMessage
205
206
  }))));
206
207
  };
207
- const failingAsDerived = (actions, surfaceErrorsOf) => {
208
- const failure = userErrorOf([...new Set(actions.flatMap((action) => projectedErrors(action, surfaceErrorsOf(action))))]);
208
+ const failingAsDerived = (actions, errorsOf) => {
209
+ const failure = userErrorOf([...new Set(actions.flatMap(errorsOf))]);
209
210
  return (self) => Effect.catchIf(self, (error) => !CliError.isCliError(error), failure);
210
211
  };
211
212
  const unreadableStdinField = (field, message) => new InvalidInput({
@@ -228,8 +229,7 @@ const stdinTextOf = (field) => Effect.gen(function* () {
228
229
  const inputConfig = (action, positional, aliases, stdin) => {
229
230
  const codec = Schema.toCodecJson(action.input);
230
231
  const encoded = unsuspended(SchemaAST.toEncoded(codec.ast));
231
- const decodeRefusingUndeclared = Schema.decodeUnknownEffect(codec, { onExcessProperty: "error" });
232
- const decode = flow(decodeRefusingUndeclared, Effect.mapError(InvalidInput.fromSchemaError));
232
+ const decode = decodeInput(action);
233
233
  if (SchemaAST.isObjects(encoded) && encoded.indexSignatures.every((signature) => SchemaAST.isNever(signature.type))) {
234
234
  const params = fieldParams(encoded, unsuspended(action.input.ast), positional, aliases);
235
235
  const fromStdin = Option.map(Option.fromUndefinedOr(stdin), (field) => {
@@ -269,11 +269,11 @@ const inputConfig = (action, positional, aliases, stdin) => {
269
269
  stdinHelp: void 0
270
270
  };
271
271
  };
272
- const command = (action, execute, options, errors = []) => {
272
+ const command = (action, execute, options, errors) => {
273
273
  const name = options?.name ?? kebab(action.name);
274
274
  const render = options?.render;
275
275
  const { flags, positional, decode, stdinHelp } = inputConfig(action, options?.positional ?? [], options?.aliases ?? {}, options?.stdin);
276
- const failure = userErrorOf(projectedErrors(action, errors));
276
+ const failure = userErrorOf(errors);
277
277
  assertDistinct("flag", [
278
278
  ...Object.keys(flags).map((field) => [`--${kebab(field)}`, `field ${field}`]),
279
279
  ...render === void 0 ? [] : [["--json", "render's --json"]],
@@ -1,3 +1,4 @@
1
+ import "./errors.mjs";
1
2
  import "./implementation.mjs";
2
3
  import { Action_d_exports, Any } from "../Action.mjs";
3
4
  import { Effect, Schema } from "effect";
@@ -6,7 +7,7 @@ import { Effect, Schema } from "effect";
6
7
  * A call of `A` answering `R`. It may leave its input out when `{}` is a valid encoded input,
7
8
  * such as for an action declared without `input` or one whose every field has a decoding
8
9
  * default, and then sends the input `{}` decodes to. Every client shares this rule:
9
- * `ActionHttp.client`, `ActionRpc.client`, `Testing.mcpClient` and `Action.client`.
10
+ * `ActionHttp.client`, `ActionRpc.client`, `ActionMcp.client` and `Action.client`.
10
11
  */
11
12
  export type Call<A extends Any, R> = {} extends A["input"]["Encoded"] ? (...input: [] | [input: A["input"]["Type"]]) => R : (input: A["input"]["Type"]) => R;
12
13
  //#endregion
@@ -1,10 +1,23 @@
1
1
  import { InvalidInput } from "./errors.mjs";
2
2
  import { Effect, Schema } from "effect";
3
3
  //#region src/contract/call.ts
4
+ /**
5
+ * How HTTP, RPC, MCP and a command decode a request's input: reporting every issue, and refusing a
6
+ * field the input does not declare.
7
+ */
8
+ const inputParseOptions = {
9
+ errors: "all",
10
+ onExcessProperty: "error"
11
+ };
12
+ /** Decode `action`'s input from its JSON form by `inputParseOptions`, or fail with `InvalidInput`. */
13
+ const decodeInput = (action) => {
14
+ const decode = Schema.decodeUnknownEffect(Schema.toCodecJson(action.input), inputParseOptions);
15
+ return (encoded) => Effect.mapError(decode(encoded), InvalidInput.fromSchemaError);
16
+ };
4
17
  const inputOf = (action, args) => args.length === 0 ? Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))({}) : Effect.succeed(args[0]);
5
18
  const checked = (action, send) => {
6
19
  const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(action.input), { errors: "all" });
7
20
  return (...args) => inputOf(action, args).pipe(Effect.tap(encode), Effect.mapError(InvalidInput.fromSchemaError), Effect.flatMap(send));
8
21
  };
9
22
  //#endregion
10
- export { checked, inputOf };
23
+ export { checked, decodeInput, inputOf, inputParseOptions };
@@ -50,9 +50,8 @@ declare const Forbidden_base: Schema.Class<Forbidden, Schema.TaggedStruct<"Forbi
50
50
  * The caller may not run this action: an implementation's `authorize` refuses. `scopes`,
51
51
  * when given, are every OAuth scope the call needs: a refusal naming them is answered with
52
52
  * the `insufficient_scope` challenge an OAuth client re-authorizes on, so name them only where
53
- * re-authorizing can grant them. Over HTTP it is a 403 either way. Over MCP, from an authorizer
54
- * or a handler, it is a 403 only when it names scopes on an endpoint under Bearer, and
55
- * otherwise a tool result with `isError: true`, in a 200 response.
53
+ * re-authorizing can grant them. What it answers on each surface: `docs/guarantees.md` in this
54
+ * package.
56
55
  */
57
56
  export declare class Forbidden extends Forbidden_base {
58
57
  private readonly "~declared in the action's error, or built in";
@@ -1,11 +1,6 @@
1
1
  import { Effect, Predicate, Schema, SchemaIssue } from "effect";
2
2
  //#region src/contract/errors.ts
3
3
  const defaultedMessage = (fallback) => Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed(fallback)));
4
- const statuses = {
5
- InvalidInput: 400,
6
- Unauthenticated: 401,
7
- Forbidden: 403
8
- };
9
4
  const Issue = Schema.Struct({
10
5
  path: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
11
6
  message: Schema.String
@@ -20,7 +15,7 @@ const standardIssues = SchemaIssue.makeFormatterStandardSchemaV1();
20
15
  var InvalidInput = class InvalidInput extends Schema.TaggedError()("InvalidInput", {
21
16
  message: defaultedMessage("The input does not match the action's input."),
22
17
  issues: Schema.Array(Issue).pipe(Schema.withConstructorDefault(Effect.succeed([])))
23
- }, { httpApiStatus: statuses.InvalidInput }) {
18
+ }, { httpApiStatus: 400 }) {
24
19
  /**
25
20
  * The `InvalidInput` for input that did not decode, as every surface answers it: the
26
21
  * schema's message, and each of its issues by path, for code that decodes input of its own,
@@ -41,21 +36,20 @@ var InvalidInput = class InvalidInput extends Schema.TaggedError()("InvalidInput
41
36
  });
42
37
  };
43
38
  /** The caller is not authenticated: authentication or an implementation's `authorize` refuses. */
44
- var Unauthenticated = class extends Schema.TaggedError()("Unauthenticated", { message: defaultedMessage("Authentication is required.") }, { httpApiStatus: statuses.Unauthenticated }) {};
39
+ var Unauthenticated = class extends Schema.TaggedError()("Unauthenticated", { message: defaultedMessage("Authentication is required.") }, { httpApiStatus: 401 }) {};
45
40
  const scopeToken = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
46
41
  const ScopeToken = Schema.String.check(Schema.isPattern(scopeToken));
47
42
  /**
48
43
  * The caller may not run this action: an implementation's `authorize` refuses. `scopes`,
49
44
  * when given, are every OAuth scope the call needs: a refusal naming them is answered with
50
45
  * the `insufficient_scope` challenge an OAuth client re-authorizes on, so name them only where
51
- * re-authorizing can grant them. Over HTTP it is a 403 either way. Over MCP, from an authorizer
52
- * or a handler, it is a 403 only when it names scopes on an endpoint under Bearer, and
53
- * otherwise a tool result with `isError: true`, in a 200 response.
46
+ * re-authorizing can grant them. What it answers on each surface: `docs/guarantees.md` in this
47
+ * package.
54
48
  */
55
49
  var Forbidden = class extends Schema.TaggedError()("Forbidden", {
56
50
  message: defaultedMessage("Not allowed."),
57
51
  scopes: Schema.optionalKey(Schema.NonEmptyArray(ScopeToken))
58
- }, { httpApiStatus: statuses.Forbidden }) {};
52
+ }, { httpApiStatus: 403 }) {};
59
53
  const refusals = [Unauthenticated, Forbidden];
60
54
  /**
61
55
  * What authentication or an implementation's `authorize` refuses a caller with, instead of
@@ -71,4 +65,4 @@ const builtIns = [InvalidInput, ...refusals];
71
65
  /** The built-in failures as one schema: `Schema.is(Action.BuiltIn)(error)`. */
72
66
  const BuiltIn = Schema.Union(builtIns);
73
67
  //#endregion
74
- export { BuiltIn, Forbidden, InvalidInput, Refusal, Unauthenticated, builtIns, scopeToken, statuses };
68
+ export { BuiltIn, Forbidden, InvalidInput, Refusal, Unauthenticated, builtIns, scopeToken };
@@ -1,6 +1,6 @@
1
1
  import { Unauthenticated } from "./errors.mjs";
2
- import { Anyone, assertOnce } from "./rules.mjs";
3
- import { Array, Context, Effect, Layer, Option } from "effect";
2
+ import { Anyone, assertAuthentication, assertDistinct, assertOnce, projectedErrors } from "./rules.mjs";
3
+ import { Array, Cause, Context, Effect, Exit, Layer, Option, Schema } from "effect";
4
4
  //#region src/contract/implementation.ts
5
5
  const uniqueKey = () => Math.random().toString(36).slice(2);
6
6
  const memoized = (build) => {
@@ -62,7 +62,7 @@ var Implementation = class Implementation {
62
62
  return Effect.gen(function* () {
63
63
  const bound = yield* source.#handlers.key;
64
64
  const before = actions.some((action) => action.caller !== Anyone) ? yield* source.#authorizer.key : () => Effect.void;
65
- return bound.filter(([action]) => actions.includes(action)).map(([action, handle]) => [action, dispatch(action, handle, before)]);
65
+ return actions.flatMap((action) => bound.flatMap(([held, handle]) => held === action ? [[action, dispatch(action, handle, before)]] : []));
66
66
  });
67
67
  }
68
68
  /** `app`, if this copy of the module made it; another installed copy's cannot be read. */
@@ -100,6 +100,21 @@ const servedBy = (what, bound, apps, listed) => {
100
100
  assertOnce("served action", actions);
101
101
  return served;
102
102
  };
103
+ /**
104
+ * What `ActionHttp.layer` and `ActionRpc.layer` serve, with the checks they share;
105
+ * `authentication` is the binding's where a served action is protected.
106
+ */
107
+ const servingOf = (what, binding, served, options) => {
108
+ const apps = servedBy(what, binding.actions, toList(served), options.actions);
109
+ const actions = apps.flatMap((app) => app.actions);
110
+ assertAuthentication(actions, binding.authentication);
111
+ assertDistinct("middleware", options.middleware ?? [], (key) => key.key);
112
+ return {
113
+ apps,
114
+ actions,
115
+ authentication: actions.some((action) => action.caller !== Anyone) ? binding.authentication : void 0
116
+ };
117
+ };
103
118
  const provideHandlers = (apps) => (layer) => {
104
119
  const [first, ...rest] = apps.map((app) => Implementation.layerOf(app));
105
120
  return first === void 0 ? layer : Layer.provide(layer, Layer.mergeAll(first, ...rest));
@@ -124,13 +139,26 @@ const dispatch = (action, handle, before) => {
124
139
  };
125
140
  const authentication = action.caller === Anyone ? Effect.void : Effect.flatMap(Effect.serviceOption(action.caller), (actor) => Option.isSome(actor) ? Effect.void : Effect.fail(new Unauthenticated()));
126
141
  const authorization = action.caller === Anyone ? Effect.void : Effect.suspend(() => before(action));
142
+ const validate = Schema.decodeUnknownEffect(Schema.toType(Schema.Union(projectedErrors(action))));
143
+ const asDefects = (reason, rejected) => {
144
+ const trace = Context.makeUnsafe(reason.annotations);
145
+ return [Cause.makeDieReason(reason.error).annotate(trace), Cause.makeDieReason(Cause.squash(rejected)).annotate(trace)];
146
+ };
147
+ const declaredOrDefect = Effect.fnUntraced(function* (cause) {
148
+ const reasons = [];
149
+ for (const reason of cause.reasons) if (Cause.isFailReason(reason)) {
150
+ const validated = yield* Effect.exit(validate(reason.error));
151
+ reasons.push(...Exit.isSuccess(validated) ? [reason] : asDefects(reason, validated.cause));
152
+ } else reasons.push(reason);
153
+ return yield* Effect.failCause(Cause.fromReasons(reasons));
154
+ });
127
155
  return (input) => {
128
156
  const handled = Effect.withSpan(Effect.annotateLogs(Effect.suspend(() => handle(input)), actionAttributes), action.name, {
129
157
  captureStackTrace: false,
130
158
  attributes: actionAttributes
131
159
  });
132
- return Effect.scoped(authentication.pipe(Effect.andThen(authorization), Effect.andThen(handled)));
160
+ return Effect.scoped(authentication.pipe(Effect.andThen(authorization), Effect.andThen(handled), Effect.catchCause(declaredOrDefect)));
133
161
  };
134
162
  };
135
163
  //#endregion
136
- export { Implementation, acquire, assertHeld, builders, built, memoized, provideHandlers, select, servedBy, toList };
164
+ export { Implementation, acquire, assertHeld, builders, built, memoized, provideHandlers, select, servingOf, toList };
@@ -16,6 +16,13 @@ type Single<T> = true extends Types.IsUnion<T> ? never : T;
16
16
  * `E` may be one of several.
17
17
  */
18
18
  export type Certain<E extends Errors> = true extends Types.IsUnion<E> ? never : number extends E["length"] ? never : { readonly [K in keyof E]: Single<E[K]>; }[number];
19
+ /** The errors a binding declares for every call, and its protected actions' authentication's. */
20
+ export interface Declaring {
21
+ readonly error: Errors;
22
+ readonly authentication?: {
23
+ readonly error: Errors;
24
+ } | undefined;
25
+ }
19
26
  /**
20
27
  * The caller of a public action, `caller: Action.Anyone`: anyone, signed in or not, and no
21
28
  * authorizer runs for it. Registered globally, so two copies of the package agree on it, as they
@@ -1,4 +1,4 @@
1
- import { builtIns, statuses } from "./errors.mjs";
1
+ import { builtIns } from "./errors.mjs";
2
2
  import { Predicate, Schema, SchemaAST } from "effect";
3
3
  //#region src/contract/rules.ts
4
4
  const errorList = (error) => error === void 0 ? [] : Schema.isSchema(error) ? [error] : error;
@@ -8,6 +8,14 @@ const projectedErrors = (action, surface = []) => [.../* @__PURE__ */ new Set([
8
8
  ...surface
9
9
  ])];
10
10
  /**
11
+ * Every error a binding's call of `action` declares: the built-in ones, the action's, the
12
+ * binding's and, where `action` is protected, its authentication's.
13
+ */
14
+ const bindingErrors = (action, binding) => projectedErrors(action, action.caller === Anyone || binding.authentication === void 0 ? binding.error : [...binding.error, ...binding.authentication.error]);
15
+ const assertAuthentication = (actions, authentication) => {
16
+ for (const action of actions) if (action.caller !== Anyone && (authentication === void 0 || action.caller.key !== authentication.identity.key)) throw new Error(`Protected action '${action.name}' requires its matching authentication descriptor`);
17
+ };
18
+ /**
11
19
  * The caller of a public action, `caller: Action.Anyone`: anyone, signed in or not, and no
12
20
  * authorizer runs for it. Registered globally, so two copies of the package agree on it, as they
13
21
  * agree on a name.
@@ -45,9 +53,48 @@ const encodedTags = (ast) => members(SchemaAST.toEncoded(ast)).flatMap((member)
45
53
  const tag = SchemaAST.isObjects(member) ? member.propertySignatures.find((property) => property.name === "_tag")?.type : void 0;
46
54
  return tag === void 0 ? [] : members(tag).flatMap(literalValues).filter(Predicate.isString);
47
55
  });
56
+ const builtInTags = new Set(builtIns.flatMap((error) => encodedTags(error.ast)));
48
57
  const assertOwnTags = (what, errors) => {
49
- const tag = errors.flatMap((error) => encodedTags(error.ast)).find((tag) => Object.hasOwn(statuses, tag));
58
+ const tag = errors.flatMap((error) => encodedTags(error.ast)).find((tag) => builtInTags.has(tag));
50
59
  if (tag !== void 0) throw new Error(`${what}: error _tag "${tag}" is built in, and declared on every surface`);
51
60
  };
61
+ /**
62
+ * The checks `ActionHttp.make` and `ActionRpc.make` share: each action once, no built-in error in
63
+ * `error`, and the descriptor every protected action names. Returns `error` as a list.
64
+ */
65
+ const bindingOf = (what, actions, options) => {
66
+ assertOnce("action", actions);
67
+ const error = errorList(options.error);
68
+ assertOwnTags(what, error);
69
+ assertAuthentication(actions, options.authentication);
70
+ return {
71
+ error,
72
+ authentication: options.authentication
73
+ };
74
+ };
75
+ const httpApiStatusOf = SchemaAST.resolveAt("httpApiStatus");
76
+ const statusThroughSuspensions = (ast) => httpApiStatusOf(ast) ?? (SchemaAST.isSuspend(ast) ? statusThroughSuspensions(ast.thunk()) : void 0);
77
+ /**
78
+ * `error` as an endpoint declares it: each member of a union without a status of its own apart,
79
+ * each with its status, 422 without one.
80
+ */
81
+ const declared = (error) => {
82
+ const status = statusThroughSuspensions(error.ast);
83
+ if (status !== void 0) return [httpApiStatusOf(error.ast) === void 0 ? error.annotate({ httpApiStatus: status }) : error];
84
+ const resolved = unsuspended(error.ast);
85
+ if (SchemaAST.isUnion(resolved) && resolved.checks === void 0 && resolved.encoding === void 0) return resolved.types.flatMap((member) => declared(Schema.make(member)));
86
+ return [error.annotate({ httpApiStatus: 422 })];
87
+ };
88
+ /**
89
+ * An action's MCP tool hints, MCP's defaults where its `mcp` leaves one out: a write destructive,
90
+ * a read not, no tool idempotent and every tool open-world. A given value passes through, as MCP
91
+ * calls `destructiveHint` and `idempotentHint` meaningless on a read-only tool.
92
+ */
93
+ const hintsOf = ({ readOnly, mcp }) => ({
94
+ readOnlyHint: readOnly,
95
+ destructiveHint: mcp.destructiveHint ?? !readOnly,
96
+ idempotentHint: mcp.idempotentHint ?? false,
97
+ openWorldHint: mcp.openWorldHint ?? true
98
+ });
52
99
  //#endregion
53
- export { Anyone, assertDistinct, assertKnown, assertName, assertOnce, assertOwnTags, errorList, literalValues, members, projectedErrors, unsuspended };
100
+ export { Anyone, assertAuthentication, assertDistinct, assertKnown, assertName, assertOnce, assertOwnTags, bindingErrors, bindingOf, declared, errorList, hintsOf, httpApiStatusOf, literalValues, members, projectedErrors, unsuspended };
@@ -2,16 +2,13 @@ import { checked } from "../contract/call.mjs";
2
2
  import { Effect } from "effect";
3
3
  import { HttpApiClient } from "effect/http-api";
4
4
  //#region src/http/client.ts
5
- const assertInBinding = (actions, action) => {
6
- if (!actions.includes(action)) throw new Error(`Action "${action.name}" is not in this HTTP binding`);
7
- };
8
5
  const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api, options), (client) => {
9
6
  const native = client;
10
- return (action) => {
7
+ return Object.fromEntries(http.actions.map((action) => {
11
8
  const method = native[action.name];
12
9
  if (method === void 0) throw new Error(`No client method for ${action.name}`);
13
- return checked(action, (payload) => method({ payload }));
14
- };
10
+ return [action.name, checked(action, (payload) => method({ payload }))];
11
+ }));
15
12
  });
16
13
  //#endregion
17
- export { assertInBinding, methods };
14
+ export { methods };