@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.
- package/README.md +10 -8
- package/dist/Action.mjs +1 -0
- package/dist/ActionCatalog.d.mts +2 -2
- package/dist/ActionCatalog.mjs +10 -9
- package/dist/ActionCli.d.mts +5 -3
- package/dist/ActionCli.mjs +3 -8
- package/dist/ActionCliClient.d.mts +9 -6
- package/dist/ActionCliClient.mjs +9 -11
- package/dist/ActionGroup.d.mts +3 -5
- package/dist/ActionHttp.d.mts +4 -5
- package/dist/ActionHttp.mjs +10 -33
- package/dist/ActionMcp.d.mts +6 -21
- package/dist/ActionMcp.mjs +4 -12
- package/dist/ActionToolkit.d.mts +5 -17
- package/dist/ActionToolkit.mjs +2 -7
- package/dist/internal/actions.mjs +11 -2
- package/dist/internal/cli.d.mts +2 -2
- package/dist/internal/cli.mjs +34 -20
- package/dist/internal/implementation.d.mts +17 -6
- package/dist/internal/implementation.mjs +9 -1
- package/dist/internal/tools.d.mts +22 -0
- package/dist/internal/tools.mjs +2 -17
- package/docs/Action.md +23 -55
- package/docs/ActionCatalog.md +32 -35
- package/docs/ActionCli.md +25 -52
- package/docs/ActionCliClient.md +16 -30
- package/docs/ActionGroup.md +16 -46
- package/docs/ActionHttp.md +27 -41
- package/docs/ActionMcp.md +115 -82
- package/docs/ActionToolkit.md +28 -46
- package/docs/Authentication.md +18 -36
- package/docs/CONTEXT.md +3 -3
- package/docs/README.md +13 -7
- package/docs/Testing.md +44 -52
- package/docs/guarantees.md +9 -9
- package/package.json +1 -1
package/docs/ActionGroup.md
CHANGED
|
@@ -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
|
-
|
|
10
|
-
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
9
|
+
Import `@gjermundgaraba/effect-actions/ActionGroup`.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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_-]
|
|
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.
|
|
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
|
-
-
|
|
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
|
package/docs/ActionHttp.md
CHANGED
|
@@ -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
|
-
|
|
9
|
-
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
8
|
+
Import `@gjermundgaraba/effect-actions/ActionHttp`.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
71
|
-
Http.layer({ before: authorize }
|
|
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(
|
|
110
|
-
- `before` runs
|
|
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
|
-
-
|
|
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`.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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`.
|
package/docs/ActionToolkit.md
CHANGED
|
@@ -5,71 +5,53 @@ with `LanguageModel` or by hand. No server, no MCP envelope.
|
|
|
5
5
|
|
|
6
6
|
## API
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
8
|
+
Import `@gjermundgaraba/effect-actions/ActionToolkit`.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
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
|
|
53
|
-
|
|
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;
|
|
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,
|
|
64
|
-
Effect.provide(binding.layer), //
|
|
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
|
|
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
|
|
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
|
|