@gjermundgaraba/effect-actions 0.4.0 → 0.5.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.
@@ -6,51 +6,21 @@ The pre-handler hook does not: each surface binds its own (see [guarantees.md](g
6
6
 
7
7
  ## API
8
8
 
9
- ```ts
10
- import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
9
+ Import `@gjermundgaraba/effect-actions/ActionGroup`.
11
10
 
12
- function make<Name, Actions, Errors = [], PolicyErrors = []>(
13
- options: Options<Name, Errors, PolicyErrors>,
14
- ...actions: Actions
15
- ): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
16
-
17
- interface Options<Name, Errors, PolicyErrors> {
18
- readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
19
- readonly errors?: Errors; // added to every action's own errors
20
- readonly schemaError?: SchemaErrorPolicy<PolicyErrors>; // HTTP only; MCP keeps native answers
21
- }
22
-
23
- interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
24
- readonly errors: Errors;
25
- readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
26
- }
27
-
28
- interface Group<Name, Actions, PolicyErrors> {
29
- readonly name: Name;
30
- readonly actions: Actions;
31
- readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
32
- /** Bind every handler at once; build-time services resolve once per adapter layer. */
33
- readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
34
- build: H | Effect.Effect<H, EX, RX>,
35
- ) => Implementation<Group<Name, Actions, PolicyErrors>, H, EX, Exclude<RX, Scope.Scope>>;
36
- }
37
-
38
- /** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
39
- class Implementation<G, H, EX, RX> {
40
- readonly group: G;
41
- get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
42
- }
43
-
44
- /** Every action of the supplied groups, keyed `<group>.<action>`. */
45
- function contracts<const Groups extends ReadonlyArray<Group>>(
46
- ...groups: Groups
47
- ): Contracts<Groups>;
48
-
49
- type Contracts<Groups>; // { "users.getUser": typeof GetUser, "users.renameUser": ... }
50
- ```
11
+ | API | Purpose |
12
+ | --------------------------------- | --------------------------------------------------------------------------------------- |
13
+ | `make(options, ...actions)` | Group actions under a required `name`; optional `errors` are inherited by each action. |
14
+ | `group.implement(recordOrEffect)` | Bind every action handler; an Effect builder carries startup requirements and failures. |
15
+ | `app.group`, `app.build` | Bound contract and scoped handler acquisition. |
16
+ | `contracts(...groups)` | Exact action types keyed by `<group>.<action>`. |
17
+
18
+ Exported types: `Group`, `Any`, `Options`, `Implementation`, `Contracts`, `SchemaErrorPolicy`.
51
19
 
52
- `HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
53
- all required.
20
+ The optional `schemaError` group option contains `errors` (policy error codecs) and
21
+ `map(failure)` (a native `HttpApiSchemaError` to one of their decoded values). It affects HTTP only.
22
+ Handler records require every action key. Each handler's input, success and declared errors
23
+ come from its action; request requirements stay distinct from the builder's requirements.
54
24
 
55
25
  ## Canonical
56
26
 
@@ -115,9 +85,9 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
115
85
 
116
86
  ## Rules
117
87
 
118
- - `name` matches `[A-Za-z0-9_-]+` and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
88
+ - `name` matches `[A-Za-z0-9_-]+`, cannot be `then`, and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
119
89
  - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them. A failure that only the surface produces belongs on the adapter instead (`ActionHttp.make`'s `errors`, `ActionMcp`'s and `ActionToolkit`'s `errors`), not here.
120
- - `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error. It takes nothing else.
90
+ - `implement` takes a complete record or an Effect producing one. Every action must have a handler; missing keys are compile errors. Additional keys are not dispatched. It takes nothing else.
121
91
  - The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
122
92
  - Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
123
93
  - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
@@ -134,7 +104,7 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
134
104
  - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
135
105
  - HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
136
106
  - Groups served by one adapter may have different policies; each action answers with its own group's.
137
- - A `before` hook refuses before decoding, so a refused request never reaches the policy.
107
+ - Input schema-error policies run before the `before` hook. Invalid input is answered by the policy without invoking the hook or handler.
138
108
  - Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
139
109
 
140
110
  ## Failure modes
@@ -5,41 +5,26 @@ of groups into a contract value shared by the server, every client, and the Open
5
5
 
6
6
  ## API
7
7
 
8
- ```ts
9
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
8
+ Import `@gjermundgaraba/effect-actions/ActionHttp`.
10
9
 
11
- function make<const G extends ReadonlyArray<Actions>, const E extends ReadonlyArray<Codec> = []>(
12
- options: Options<E>,
13
- ...groups: G
14
- ): Http<G, E>;
15
-
16
- interface Options<Errors = []> {
17
- readonly apiPath: `/${string}`; // no default
18
- readonly errors?: Errors; // failures the surface answers with, declared on every endpoint
19
- }
20
-
21
- interface LayerOptions<Errors, RB> {
22
- /** Runs before the payload is decoded, on every request this layer answers. */
23
- readonly before?: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
24
- }
25
-
26
- interface Http<Groups, Errors = []> {
27
- readonly groups: Groups; // the exact contracts bound, in declaration order
28
- readonly api: HttpApi.HttpApi<"actions", ...>; // native HttpApi, one HttpApiGroup per group
29
- /** Serve implementations. Errors and requirements are unions over exactly these apps. */
30
- readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(
31
- options: LayerOptions<Errors, RB>,
32
- ...apps: Apps
33
- ) => Layer.Layer<
34
- never,
35
- BuildError<Apps[number]>,
36
- | BuildContext<Apps[number]>
37
- | HttpRouter.HttpRouter
38
- | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]> | RB>
39
- | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path
40
- >;
41
- }
42
- ```
10
+ | API | Purpose |
11
+ | ---------------------------- | ------------------------------------------------------------------------------------ |
12
+ | `make(options, ...groups)` | Bind contracts and a mount path; returns `Http`. |
13
+ | `Http.groups` | The exact bound contracts, in declaration order. |
14
+ | `Http.api` | Native Effect `HttpApi` for clients and OpenAPI. |
15
+ | `Http.layer(apps, options?)` | Serve a readonly collection of implementations; omit options when no hook is needed. |
16
+
17
+ Exported types: `Options`, `LayerOptions`, `Http`, `Api`.
18
+
19
+ | Option | Meaning |
20
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | `make`: `apiPath` | Required absolute mount path; no default. |
22
+ | `make`: `errors` | Surface error codecs declared on every endpoint; defaults to none. |
23
+ | `layer`: `before` | Effectful hook receiving the selected `Action.Any`, after successful input decoding and before its handler. Failures must belong to the binding's `errors`. |
24
+
25
+ Layer failures and startup requirements come from the supplied implementations that actually
26
+ serve HTTP actions. Served handlers' and the hook's request services remain router request
27
+ requirements; router/platform services are also required. No request identity is supplied at startup.
43
28
 
44
29
  Route shape: `POST <apiPath>/<group>/<action>`. Operation ID: `<group>.<action>`.
45
30
 
@@ -67,8 +52,8 @@ export const Http = ActionHttp.make(
67
52
  // One layer per middleware set and per policy. Middleware provided to a layer
68
53
  // applies to that layer only, and so does its `before` hook.
69
54
  const routes = Layer.mergeAll(
70
- Http.layer({}, PublicApp),
71
- Http.layer({ before: authorize }, UserApp).pipe(Layer.provide(authentication.layer)),
55
+ Http.layer([PublicApp]),
56
+ Http.layer([UserApp], { before: authorize }).pipe(Layer.provide(authentication.layer)),
72
57
  );
73
58
 
74
59
  // Documents are Effect's own, reading the same contract.
@@ -106,10 +91,10 @@ export const greeting = Effect.gen(function* () {
106
91
  - `errors` declares the failures the surface around these endpoints answers with rather than a handler: authentication, authorization, rate limiting, upstream unavailability. They are added to every endpoint's error schemas, so `HttpApiClient`, `ActionCliClient` and the in-memory `Testing.httpClient` decode them as typed failures, and they appear in OpenAPI on every operation. A schema an action already declares is not repeated.
107
92
  - Schemas reachable from one endpoint must have distinct `_tag`s: the client decodes a response by trying the schemas declared for its status, and two errors may share a status. Effect unions them per status.
108
93
  - `errors` changes only what is declared. Nothing produces them: the middleware that renders those responses must encode a body that matches the schema, or the client sees a decode error again. Handlers cannot fail with them.
109
- - `Http.layer(options, ...apps)` mounts only the supplied implementations. A group that is never passed to `layer` has no routes. A group with no HTTP-enabled actions is not built at all. The options object is required; pass `{}` for a surface with no hook.
110
- - `before` runs once per request, with the selected action contract, **before the payload is decoded**, so an unauthorized caller learns nothing about the input schema and the handler never runs. It fails with the binding's own `errors`, and that failure is encoded exactly like a declared error, with the schema's `httpApiStatus`. Its services are request-time requirements, joined with the handlers'.
94
+ - `Http.layer(apps, options?)` requires each implementation to reference the exact group object passed to `make`; reconstructing an equal-looking group is not sufficient. Each group may occur only once per call. Only supplied implementations are mounted, and a group with no HTTP-enabled actions is not built. Options are optional.
95
+ - `before` runs after successful native input decoding and before the selected handler. Invalid input skips the hook and handler. A refusal uses the binding's declared error schema and `httpApiStatus`; its services join handler request requirements. Admission that must precede decoding belongs in outer native HTTP middleware.
111
96
  - Middleware and the hook are per layer call. Groups that need different middleware or a different policy go in separate `Http.layer` calls, merged with `Layer.mergeAll`.
112
- - Every response with a status of 400 or more carries `cache-control: no-store`, including hook refusals, declared errors and defects. Successful responses are left alone; only the host knows whether they are public.
97
+ - `ActionHttp` sets no response headers of its own. The host owns cache policy; `Authentication.middleware` marks its responses `cache-control: no-store`.
113
98
  - A hook refusal and a handler error are plain declared errors: a JSON body and a status, no other headers. Challenge headers such as `WWW-Authenticate` belong to the admission middleware that runs before the router reaches these routes (see [Authentication.md](Authentication.md)), which sets them on its own response.
114
99
  - Request-time handler services are `HttpRouter.Request.From<"Requires", R>`. Supply them with router middleware (`Authentication.middleware`, `HttpRouter.middleware`), `HttpRouter.provideRequest`, or the request context. Build-time services are ordinary layer requirements.
115
100
  - `Http.api` is a plain `HttpApi`. Anything Effect can do with an `HttpApi` works: `OpenApi.fromApi`, `HttpApiSwagger.layer`, `HttpApiScalar.layer`, `HttpApi.addHttpApi` to combine with other APIs, `HttpApiClient.make`.
@@ -117,14 +102,15 @@ export const greeting = Effect.gen(function* () {
117
102
  - Client effects fail with the declared errors, the group's policy errors, the binding's `errors`, `SchemaError` for local codec failures, and native `HttpClientError`. MCP-only actions are absent from the client.
118
103
  - Add authentication headers with the native `transformClient` option. Use `HttpApiClient.makeWith` for custom error or service channels. Native per-call response modes are available.
119
104
  - Wire format without a policy: input failure is an empty 400, success is the encoded body, a declared error is its JSON encoding with its `httpApiStatus`, an encoding failure is an empty 400, a defect is an empty 500. Full table in [guarantees.md](guarantees.md).
120
- - Each handler runs in a span named `<group>.<action>`, a child of the request span. The hook, decoding and encoding are outside it, in the request span.
105
+ - Each handler runs in a span named `<group>.<action>`, a child of the request span, attributed with `action.group`, `action.name` and `action.access`; its log lines carry the same annotations. The hook, decoding and encoding are outside it, in the request span.
121
106
 
122
107
  ## Failure modes
123
108
 
124
109
  - Route returns 404: the implementation was never passed to `Http.layer`, or the action has `http: false`, or the path lacks the group segment.
125
110
  - Type error listing `HttpRouter.Request.From<"Requires", CurrentActor>` as unsatisfied: a handler yields a request service and no middleware provides it. Wrap that `Http.layer` call with the middleware's `.layer`.
126
111
  - Two groups share a name: `make` throws. Rename one.
127
- - `Implementation of group "x" is not served by this adapter`: `Http.layer` received an implementation whose group was not passed to `ActionHttp.make`. Add the group to `make` or drop the app.
112
+ - `Implementation of group "x" is not served by this adapter`: `Http.layer` received an implementation whose group was not passed to `ActionHttp.make`. Implement the exact shared group object bound by `make`, or add a genuinely new group to the binding. Matching names and schemas do not establish identity.
113
+ - `Duplicate implementation group: <name>`: a single `Http.layer` call received more than one implementation of the same group. Pass one implementation per group.
128
114
  - Client method missing for an action: the action is `http: false`.
129
115
  - Empty 400 on a valid-looking request: input did not decode. Set a `schemaError` policy on the group to get a typed body, and check `Content-Type: application/json`.
130
116
  - 415: wrong or missing content type.
package/docs/ActionMcp.md CHANGED
@@ -6,51 +6,28 @@ JSON-RPC on standard I/O for a subprocess.
6
6
 
7
7
  ## API
8
8
 
9
- ```ts
10
- import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
11
-
12
- function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>, Errors = [], RB = never>(
13
- options: Options<Errors, RB>,
14
- ...apps: Apps
15
- ): Layer.Layer<
16
- never,
17
- BuildError<Apps[number]> | Cause.IllegalArgumentError,
18
- | BuildContext<Apps[number]>
19
- | HttpRouter.HttpRouter
20
- | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]> | RB>
21
- >;
22
-
23
- function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>, Errors = [], RB = never>(
24
- options: StdioOptions<Errors, RB>,
25
- ...apps: Apps
26
- ): Layer.Layer<
27
- never,
28
- BuildError<Apps[number]> | Cause.IllegalArgumentError,
29
- BuildContext<Apps[number]> | Stdio | RequestContext<Apps[number]> | RB
30
- >;
31
-
32
- /** Both transports share these. */
33
- interface SurfaceOptions<Errors, RB> {
34
- readonly errors?: Errors; // declared on every tool of this transport
35
- readonly before?: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
36
- }
37
-
38
- interface Options<Errors = [], RB = never> extends SurfaceOptions<Errors, RB> {
39
- readonly name: string; // server info
40
- readonly version: string;
41
- readonly protocols: ReadonlyArray<McpProtocol>; // required, e.g. [McpProtocol.v2026_07_28]
42
- readonly path: HttpRouter.PathInput; // no default
43
- readonly allowedOrigins?: ReadonlyArray<string>; // passed to McpServer.layerHttp
44
- readonly instructions?: string;
45
- }
46
-
47
- interface StdioOptions<Errors = [], RB = never> extends SurfaceOptions<Errors, RB> {
48
- readonly name: string;
49
- readonly version: string;
50
- readonly protocols: ReadonlyArray<McpProtocol>;
51
- readonly instructions?: string;
52
- }
53
- ```
9
+ Import `@gjermundgaraba/effect-actions/ActionMcp`.
10
+
11
+ | API | Purpose |
12
+ | --------------------------- | ----------------------------------------------------------------------------- |
13
+ | `layerHttp(apps, options)` | Serve a readonly collection of implementations at a Streamable HTTP endpoint. |
14
+ | `layerStdio(apps, options)` | Serve implementations over a subprocess's standard I/O. |
15
+ | `Options`, `StdioOptions` | Configuration for the two transports. |
16
+
17
+ | Option | Meaning |
18
+ | ----------------- | ----------------------------------------------------------------------------------------------------- |
19
+ | `name`, `version` | Required native server information. |
20
+ | `protocols` | Required nonempty list of native protocol adapters. |
21
+ | `path` | Required HTTP endpoint path; HTTP only, no default. |
22
+ | `allowedOrigins` | Optional exact Origin allowlist; HTTP only, not CORS configuration. |
23
+ | `instructions` | Optional native server instructions. |
24
+ | `errors` | Optional surface error codecs, declared on every served tool. |
25
+ | `before` | Optional Effectful hook receiving the selected `Action.Any`; fails only with declared surface errors. |
26
+
27
+ Both layers retain served implementations' build failures and requirements, plus native
28
+ `IllegalArgumentError`. HTTP needs the router and wraps handler/hook services as request
29
+ requirements. stdio needs `Stdio` and the caller's request services. Native `McpRequestContext`
30
+ is supplied by the server, not owed by the host.
54
31
 
55
32
  ## Canonical
56
33
 
@@ -65,50 +42,100 @@ const allowedOrigins = ["http://localhost:3000"];
65
42
 
66
43
  // One endpoint is one route: its middleware covers every tool on it.
67
44
  // Tools with different middleware needs get their own endpoint.
68
- const publicMcp = ActionMcp.layerHttp(
69
- {
70
- protocols: [McpProtocol.v2026_07_28],
71
- name: "app-public",
72
- version: "1.0.0",
73
- path: "/mcp/public",
74
- allowedOrigins,
75
- },
76
- PublicApp,
77
- );
78
-
79
- const mcp = ActionMcp.layerHttp(
80
- {
81
- protocols: [McpProtocol.v2026_07_28],
82
- name: "app",
83
- version: "1.0.0",
84
- path: "/mcp",
85
- allowedOrigins,
86
- // The same rule the HTTP binding runs, declared here so a refusal is an
87
- // ordinary tool failure rather than a transport error.
88
- errors: [Forbidden],
89
- before: authorize,
90
- },
91
- UserApp,
92
- AuditApp,
93
- ).pipe(Layer.provide(authentication.layer));
45
+ const publicMcp = ActionMcp.layerHttp([PublicApp], {
46
+ protocols: [McpProtocol.v2026_07_28],
47
+ name: "app-public",
48
+ version: "1.0.0",
49
+ path: "/mcp/public",
50
+ allowedOrigins,
51
+ });
52
+
53
+ const mcp = ActionMcp.layerHttp([UserApp, AuditApp], {
54
+ protocols: [McpProtocol.v2026_07_28],
55
+ name: "app",
56
+ version: "1.0.0",
57
+ path: "/mcp",
58
+ allowedOrigins,
59
+ // The same rule the HTTP binding runs, declared here so a refusal is an
60
+ // ordinary tool failure rather than a transport error.
61
+ errors: [Forbidden],
62
+ before: authorize,
63
+ }).pipe(Layer.provide(authentication.layer));
94
64
 
95
65
  export const layer = Layer.mergeAll(publicMcp, mcp);
96
66
  ```
97
67
 
98
- Subprocess:
68
+ ### Cross-origin browsers
69
+
70
+ `allowedOrigins` alone does not configure CORS. For a stateless browser endpoint, mount
71
+ native router CORS outside the route middleware. This CORS layer is global to the router;
72
+ choose its policy for every route it covers.
73
+
74
+ ```ts
75
+ import { Effect, Layer } from "effect";
76
+ import { McpProtocol } from "effect/unstable/ai";
77
+ import { HttpRouter } from "effect/unstable/http";
78
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
79
+ import { Actions } from "./quickstart.js";
80
+
81
+ const allowedOrigins = ["https://ui.example.com"];
82
+
83
+ const app = Actions.implement({ greet: ({ name }) => Effect.succeed(`Hello, ${name}!`) });
84
+
85
+ const mcp = ActionMcp.layerHttp([app], {
86
+ name: "greetings",
87
+ version: "1.0.0",
88
+ path: "/mcp",
89
+ protocols: [McpProtocol.v2026_07_28],
90
+ allowedOrigins,
91
+ });
92
+
93
+ // Global router CORS handles preflight outside route-level authentication.
94
+ // This example is public; protected endpoints still need authentication and a hook.
95
+ export const routes = Layer.mergeAll(
96
+ mcp,
97
+ HttpRouter.cors({
98
+ allowedOrigins,
99
+ allowedMethods: ["POST"],
100
+ allowedHeaders: [
101
+ "Content-Type",
102
+ "Authorization",
103
+ "MCP-Protocol-Version",
104
+ "MCP-Method",
105
+ "MCP-Name",
106
+ ],
107
+ exposedHeaders: ["WWW-Authenticate", "MCP-Protocol-Version"],
108
+ }),
109
+ );
110
+ ```
111
+
112
+ ### Subprocess
99
113
 
100
114
  ```ts
101
115
  import { NodeRuntime, NodeStdio } from "@effect/platform-node";
102
- import { Console, Effect, Layer, Logger } from "effect";
116
+ import { Console, Effect, Layer, Logger, Schema } from "effect";
103
117
  import { McpProtocol } from "effect/unstable/ai";
118
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
119
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
104
120
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
105
121
 
106
- const layer = ActionMcp.layerStdio(
107
- { name: "app-stdio", version: "1.0.0", protocols: [McpProtocol.v2026_07_28] },
108
- app,
109
- ).pipe(Layer.provide(NodeStdio.layer));
122
+ const Status = Action.make("status", {
123
+ description: "Report whether the subprocess is ready.",
124
+ success: Schema.Struct({ ready: Schema.Boolean }),
125
+ access: "read",
126
+ });
127
+
128
+ const app = ActionGroup.make({ name: "stdio" }, Status).implement({
129
+ status: () => Effect.log("status called").pipe(Effect.as({ ready: true })),
130
+ });
131
+
132
+ const layer = ActionMcp.layerStdio([app], {
133
+ name: "effect-actions-stdio",
134
+ version: "0.1.0",
135
+ protocols: [McpProtocol.v2026_07_28],
136
+ }).pipe(Layer.provide(NodeStdio.layer));
110
137
 
111
- // stdout is protocol-only; logs go to stderr.
138
+ // Protocol messages use stdout exclusively. Runtime diagnostics remain on stderr.
112
139
  Layer.launch(layer).pipe(
113
140
  Effect.tapCause((cause) => Console.error(cause)),
114
141
  Effect.provideService(Logger.LogToStderr, true),
@@ -118,11 +145,13 @@ Layer.launch(layer).pipe(
118
145
 
119
146
  ## Rules
120
147
 
121
- - `protocols` is required and takes Effect's native `McpProtocol` adapters. Effect owns negotiation, revision rejection, and sessions. `[McpProtocol.v2026_07_28]` gives a stateless endpoint that needs no initialize handshake.
148
+ - `protocols` is required and must be a nonempty list of Effect's native `McpProtocol.ProtocolAdapter` values. Effect owns negotiation, revision rejection, and sessions. `[McpProtocol.v2026_07_28]` gives a stateless endpoint that needs no initialize handshake.
122
149
  - `path` has no default. `layerHttp` uses the single-endpoint Streamable HTTP transport, never the two-endpoint HTTP+SSE form, whatever revision is negotiated.
150
+ - Requests reaching the native MCP handler with an `Origin` header receive **403** unless that exact origin is listed in `allowedOrigins`. Requests without `Origin` pass this check. Authentication middleware wrapping the endpoint runs first and may reject the request before native Origin validation; the allowlist does not protect authentication from untrusted-origin requests.
151
+ - `allowedOrigins` is an Origin allowlist, not CORS configuration. Cross-origin browser clients also need outer CORS middleware or a proxy to handle preflight and add response headers. Without it, an allowed-origin `OPTIONS` request receives **405** and even a successful `POST` has no `Access-Control-Allow-Origin`. Keep preflight outside authentication and apply CORS headers to refusals too.
123
152
  - An endpoint is one route. Middleware provided to `layerHttp` covers all of its tools. To serve tools under different middleware, mount them on different paths with separate `layerHttp` calls.
124
153
  - Only MCP-enabled actions become tools, under `mcp.name` with the resolved hints. A group with no tools is not built.
125
- - Every MCP-enabled action must have object-root input. Checked synchronously when `layerHttp` or `layerStdio` is called, before any handler is acquired; a violation throws. The `IllegalArgumentError` in the layer's error channel comes from the native transport, not from this check.
154
+ - Every MCP-enabled action must have object-root input; the native server refuses anything else when the layer is built. Omit `input` for a tool with no arguments.
126
155
  - Success is `structuredContent: { value: <encoded success> }`. A declared error is an `isError` result whose text content is the error's JSON encoding, the same bytes HTTP sends as the body, and no `structuredContent`.
127
156
  - Invalid arguments and unencodable results are answered by the native `McpServer`: an `isError` result with a message for the model. Group `schemaError` policies do not apply.
128
157
  - Defects and encoding failures produce the generic `isError` text `Tool execution failed due to an internal server error.`; the cause is logged, not sent.
@@ -132,14 +161,18 @@ Layer.launch(layer).pipe(
132
161
  - stdio: the host supplies `Stdio` (`NodeStdio.layer`) and any request-time services explicitly, including the trusted principal. There is no authentication middleware. Tool arguments never establish identity. Keep stdout for protocol messages only and route logs to stderr.
133
162
  - Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
134
163
  - `errors` are the failures this transport answers with rather than a handler. They join every tool's declared failures, so a refusal is returned exactly like an action's own error and no caller sees a protocol-level error instead. A schema an action already declares is not repeated.
135
- - `before` runs once per tool call, with the selected action contract, before the handler. The native server decodes the tool arguments first, so unlike HTTP the hook runs after input decoding; it still runs on every call and the handler never runs when it fails. Its services are request-time requirements, joined with the handlers'.
164
+ - `before` runs after successful native argument decoding and before the selected handler, with its action contract. Invalid arguments skip the hook and handler. A hook failure prevents the handler from running; its services join handler request requirements.
136
165
 
137
166
  ## Failure modes
138
167
 
139
- - `<action>: MCP input must have an object root; omit input for no arguments` thrown at the `layerHttp` or `layerStdio` call: the action has scalar or array input. Wrap it in a struct or set `mcp: false`.
168
+ - Layer build dies while registering tools, with a defect whose `SchemaError` message says `Expected "object"` or `Missing key`: an MCP-enabled action has scalar, array, or empty-struct input, which the native server refuses. It is not in the layer's error channel, so it cannot be caught by tag. Wrap the input in a struct with at least one field, omit `input` for no arguments, or set `mcp: false`.
140
169
  - `Duplicate MCP tool: <name>` thrown at the `layerHttp` or `layerStdio` call: two apps on one endpoint expose the same tool name. Rename with `mcp.name` or split the endpoint.
141
170
  - Type error listing `HttpRouter.Request.From<"Requires", ...>`: a handler yields a request service and the endpoint has no middleware providing it. Provide it with `Layer.provide(middleware.layer)` on that `layerHttp`.
142
171
  - Public tool requires a token: it shares an endpoint with protected tools. Give it its own path.
143
172
  - Client reports a broken transport from a stdio subprocess: something printed to stdout. Set `Logger.LogToStderr` and remove `console.log`.
144
173
  - Older MCP client cannot connect: the client expects a revision not listed in `protocols`. Add the adapter for that revision.
174
+ - An Origin-bearing request reaches the native handler and gets an empty 403: its `Origin` is not in `allowedOrigins`. Add the exact origin only if the deployment trusts it.
175
+ - A disallowed Origin receives 401 instead: wrapping authentication rejected it before the native Origin check. Put any required pre-authentication Host/Origin policy in outer host middleware.
176
+ - Browser calls fail despite an allowed Origin: configure CORS outside authentication and the MCP handler (see the browser example above). The native allowlist alone neither handles preflight nor adds CORS response headers.
177
+ - Type error on an empty `protocols` array: provide at least one native protocol adapter.
145
178
  - A refusal arrives as a generic internal-error result: the hook failed with an error this transport does not declare, which is a defect. Add its schema to `errors`.
@@ -5,71 +5,53 @@ with `LanguageModel` or by hand. No server, no MCP envelope.
5
5
 
6
6
  ## API
7
7
 
8
- ```ts
9
- import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
8
+ Import `@gjermundgaraba/effect-actions/ActionToolkit`.
10
9
 
11
- function make<const Apps extends ReadonlyArray<AnyImplementation>, Errors = [], RB = never>(
12
- options: Options<Errors, RB>,
13
- ...apps: Apps
14
- ): Binding<
15
- ToolkitTools<Apps, Errors[number], RB>,
16
- BuildError<Apps[number]>,
17
- BuildContext<Apps[number]>
18
- >;
19
-
20
- interface Options<Errors = [], RB = never> {
21
- readonly errors?: Errors; // declared on every tool of this binding
22
- readonly before?: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
23
- }
24
-
25
- interface Binding<Tools, E, R> {
26
- readonly toolkit: Toolkit.Toolkit<Tools>; // native; tool names, schemas and requirements stay typed
27
- /** Acquires handlers once in the layer scope; handler requirements remain at invocation. */
28
- readonly layer: Layer.Layer<Tool.HandlersFor<Tools>, E, R>;
29
- }
30
-
31
- // Each MCP-enabled action becomes:
32
- Tool.Tool<
33
- McpName,
34
- {
35
- parameters: A["input"];
36
- success: A["success"];
37
- failure: Schema.Union<ReadonlyArray<A["errors"][number] | Errors[number]>>;
38
- failureMode: "return";
39
- },
40
- HandlerRequirements | RB
41
- >;
42
- ```
10
+ | API | Purpose |
11
+ | ---------------------- | --------------------------------------------------------------------------------------------- |
12
+ | `make(apps, options?)` | Project a readonly collection of implementations; omit options for an unguarded binding. |
13
+ | `binding.toolkit` | Native `Toolkit` with typed tool names, schemas and per-tool request requirements. |
14
+ | `binding.layer` | Acquire handlers in the layer's scope; requires their startup services, not request identity. |
15
+ | `Options`, `Binding` | Public configuration and binding types. |
16
+
17
+ Options: `errors` declares surface error codecs on every tool; `before` is an Effectful hook
18
+ receiving the selected `Action.Any`, failing only with those errors. Both are optional.
19
+
20
+ Each tool keeps the action's native input/success codecs, combines action and surface failures
21
+ in a native union, and uses `failureMode: "return"`. Its request requirements are its handler's
22
+ plus the hook's; build failures and services belong to the binding layer.
43
23
 
44
24
  ## Canonical
45
25
 
46
26
  ```ts
47
- import { Effect, Stream } from "effect";
27
+ import { NodeRuntime } from "@effect/platform-node";
28
+ import { Console, Effect, Layer, Stream } from "effect";
48
29
  import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
49
- import { authorize, Forbidden } from "./auth.js";
30
+ import { actors, authorize, CurrentActor, Forbidden } from "./auth.js";
50
31
  import { UserApp } from "./handlers.js";
32
+ import { Users } from "./users.js";
51
33
 
52
- // The in-process caller is not trusted more than a remote one: it binds the same
53
- // rule, and a refusal is a declared tool failure.
54
- const binding = ActionToolkit.make({ errors: [Forbidden], before: authorize }, UserApp);
34
+ // The in-process caller binds the same rule as the guarded servers.
35
+ const binding = ActionToolkit.make([UserApp], { errors: [Forbidden], before: authorize });
55
36
 
56
37
  const program = Effect.gen(function* () {
57
- const tools = yield* binding.toolkit; // bound handlers
38
+ const tools = yield* binding.toolkit;
58
39
  const calls = yield* tools.handle("get_user", { id: "1" }); // encoded arguments
59
40
  const results = yield* Stream.runCollect(calls);
60
-
61
- return results;
41
+ yield* Console.log(results);
62
42
  }).pipe(
63
- Effect.provideService(CurrentActor, actor), // request service, around the whole call
64
- Effect.provide(binding.layer), // build services resolved here
43
+ Effect.provideService(CurrentActor, actors.alice), // identity around the whole invocation
44
+ Effect.provide(binding.layer.pipe(Layer.provide(Users.layerMemory))), // startup services only
65
45
  );
46
+
47
+ program.pipe(NodeRuntime.runMain);
66
48
  ```
67
49
 
68
50
  With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText` and provide `binding.layer`.
69
51
 
70
52
  ## Rules
71
53
 
72
- - Selects MCP-enabled actions only, named by `mcp.name`, with the resolved hints. Groups with no selected tools are not built. The options object is required; pass `{}` for a binding with no hook.
54
+ - Selects MCP-enabled actions only, named by `mcp.name`, with resolved hints. Groups with no selected tools are not built. `make(apps, options?)` accepts a readonly implementation collection; options may be omitted.
73
55
  - Successes are the action's native values. There is no `{ value }` wrapper. Declared failures are returned as native tool results (`failureMode: "return"`), not raised.
74
56
  - `tools.handle(name, encodedInput)` takes encoded arguments and returns an Effect producing a result stream. The handler starts while that stream is constructed, so request services must be provided around the entire `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not only around the stream.
75
57
  - `binding.layer` acquires each implementation once, in the layer's scope. Build-time requirements belong to the whole selected implementation.
@@ -77,7 +59,7 @@ With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText
77
59
  - Supply identity at invocation, never when building the layer. Native context capture is not a security boundary.
78
60
  - This is not an MCP server. Use `ActionMcp` to expose the same actions to external clients.
79
61
  - `errors` are the failures the caller answers with rather than a handler. They join every tool's declared failures, so a refusal is an ordinary returned tool failure. A schema an action already declares is not repeated.
80
- - `before` runs once per call, with the selected action contract, after the arguments are decoded and before the handler. Its services join each tool's requirements, and the caller supplies them as it does a handler's.
62
+ - `before` runs after successful argument decoding and before the selected handler, with its action contract. Invalid arguments skip both. Its services join each tool's requirements, supplied by the caller.
81
63
 
82
64
  ## Failure modes
83
65