@gjermundgaraba/effect-actions 0.1.0-rc.0 → 0.1.0-rc.2

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 (45) hide show
  1. package/README.md +249 -75
  2. package/dist/Action.d.mts +35 -27
  3. package/dist/Action.mjs +9 -10
  4. package/dist/ActionCatalog.d.mts +31 -0
  5. package/dist/ActionCatalog.mjs +36 -0
  6. package/dist/ActionCli.d.mts +27 -0
  7. package/dist/ActionCli.mjs +26 -0
  8. package/dist/ActionCliClient.d.mts +45 -0
  9. package/dist/ActionCliClient.mjs +52 -0
  10. package/dist/ActionGroup.d.mts +25 -16
  11. package/dist/ActionGroup.mjs +18 -19
  12. package/dist/ActionHttp.d.mts +39 -0
  13. package/dist/ActionHttp.mjs +67 -0
  14. package/dist/ActionMcp.d.mts +41 -0
  15. package/dist/ActionMcp.mjs +25 -0
  16. package/dist/ActionToolkit.d.mts +37 -0
  17. package/dist/ActionToolkit.mjs +10 -0
  18. package/dist/Authentication.d.mts +46 -0
  19. package/dist/Authentication.mjs +55 -0
  20. package/dist/Testing.d.mts +36 -0
  21. package/dist/Testing.mjs +42 -0
  22. package/dist/TestingClient.d.mts +16 -0
  23. package/dist/{testing/client.mjs → TestingClient.mjs} +5 -6
  24. package/dist/{rolldown-runtime-D7D4PA-g.mjs → _virtual/_rolldown/runtime.mjs} +1 -1
  25. package/dist/internal/actions.d.mts +18 -0
  26. package/dist/internal/actions.mjs +16 -0
  27. package/dist/internal/cli.d.mts +29 -0
  28. package/dist/internal/cli.mjs +36 -0
  29. package/dist/internal/implementation.d.mts +39 -0
  30. package/dist/internal/implementation.mjs +37 -0
  31. package/dist/internal/tools.mjs +84 -0
  32. package/package.json +16 -12
  33. package/dist/authentication.d.mts +0 -27
  34. package/dist/authentication.mjs +0 -31
  35. package/dist/http.d.mts +0 -53
  36. package/dist/http.mjs +0 -74
  37. package/dist/implementation-BkLqMWtv.mjs +0 -32
  38. package/dist/implementation-D75QAcD3.d.mts +0 -21
  39. package/dist/index.d.mts +0 -6
  40. package/dist/index.mjs +0 -6
  41. package/dist/mcp.d.mts +0 -46
  42. package/dist/mcp.mjs +0 -189
  43. package/dist/testing/client.d.mts +0 -11
  44. package/dist/testing.d.mts +0 -10
  45. package/dist/testing.mjs +0 -34
package/README.md CHANGED
@@ -1,18 +1,28 @@
1
1
  # @gjermundgaraba/effect-actions
2
2
 
3
- Define an Effect action once, implement it once, expose it over **HTTP and MCP**.
3
+ Define an Effect action once, implement it once, and expose it through **HTTP, MCP, native Effect Toolkits, and CLIs**. Export an offline JSON catalog from the same contracts.
4
4
 
5
- **Experimental release candidate.** The repository currently uses Effect snapshot
6
- [`4e4a3a6`](https://pkg.pr.new/Effect-TS/effect/effect@4e4a3a6). The MCP adapter
7
- requires APIs missing from published `4.0.0-rc.115`, despite that being the current
8
- peer declaration. Until these versions are aligned, use the pinned snapshot;
9
- the declared peer version alone is not sufficient.
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @gjermundgaraba/effect-actions@rc effect@4.0.0-rc.116
9
+ ```
10
+
11
+ For Node server wiring:
12
+
13
+ ```sh
14
+ pnpm add @effect/platform-node@4.0.0-rc.116
15
+ ```
10
16
 
11
17
  ## Quickstart
12
18
 
13
19
  ```ts
20
+ import { McpProtocol } from "effect/unstable/ai";
14
21
  import { Effect, Layer, Schema } from "effect";
15
- import { Action, ActionGroup, ActionHttp, ActionMcp } from "@gjermundgaraba/effect-actions";
22
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
23
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
24
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
25
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
16
26
 
17
27
  const Greet = Action.make("greet", {
18
28
  description: "Greet someone by name.",
@@ -21,84 +31,199 @@ const Greet = Action.make("greet", {
21
31
  mcp: { readOnly: true },
22
32
  });
23
33
 
24
- export const Actions = ActionGroup.make(Greet);
34
+ export const Actions = ActionGroup.make({ name: "greetings" }, Greet);
35
+
36
+ export const Http = ActionHttp.make({ apiPath: "/api/actions" }, Actions);
37
+
25
38
  const app = Actions.implement({
26
39
  greet: ({ name }) => Effect.succeed(`Hello, ${name}!`),
27
40
  });
28
41
 
29
42
  export const routes = Layer.mergeAll(
30
- ActionHttp.layer(app, {
31
- apiPath: "/api/actions",
32
- openapiPath: "/openapi.json",
33
- }),
34
- ActionMcp.layer(app, { name: "greetings", version: "1.0.0", path: "/mcp" }),
43
+ Http.layer(app),
44
+ ActionMcp.layerHttp(
45
+ { protocols: [McpProtocol.v2026_07_28], name: "greetings", version: "1.0.0", path: "/mcp" },
46
+ app,
47
+ ),
35
48
  );
36
49
  ```
37
50
 
38
- Serve `routes` with Effect's `HttpRouter`. This creates `POST /api/actions/greet`,
39
- `GET /openapi.json`, and an MCP endpoint at `/mcp`. The HTTP response is
40
- `"Hello, Ada!"`; MCP returns `structuredContent: { value: "Hello, Ada!" }`.
51
+ Serve `routes` with Effect's `HttpRouter`. This creates `POST /api/actions/greetings/greet` and an
52
+ MCP endpoint at `/mcp`. The HTTP response is `"Hello, Ada!"`; MCP returns
53
+ `structuredContent: { value: "Hello, Ada!" }`; a declared error is an `isError` result
54
+ whose text is the same JSON encoding HTTP sends as the body.
41
55
  See [examples/server.ts](examples/server.ts) for Node server wiring and the
42
56
  [authenticated demo](examples/README.md) for a runnable application.
43
57
 
44
58
  ### Contracts
45
59
 
46
- - `input` defaults to `Action.NoInput` for actions without arguments.
47
- - `error` is a list of failure schemas, defaulting to none. Each schema retains
60
+ - Omit `input` for an action without arguments.
61
+ - `errors` is a list of failure schemas, defaulting to none. Each schema retains
48
62
  its own `httpApiStatus` annotation.
63
+ - A group's `errors` are added to every one of its actions, so a shared set, such as
64
+ authorization failures, is declared once. Its `schemaError` policy decides how HTTP
65
+ answers failed decoding or encoding; MCP keeps the native toolkit's answers. See
66
+ [schema-error policies](docs/behavior.md#schema-error-policies).
49
67
  - Actions default to both transports; use `http: false` or `mcp: false` to opt out.
50
- - MCP input must have an object-root JSON Schema; declared errors must encode to
51
- objects. The MCP adapter checks this at Layer construction. HTTP allows scalars.
52
- - `mcp.name` overrides the tool name. `destructive` defaults to `!readOnly`.
68
+ Actions disabled on both remain available for explicitly mounted local CLI commands.
69
+ - Action and group names match `[A-Za-z0-9_-]+` and are not `then`, which
70
+ would make a client thenable.
71
+ - MCP input must have an object-root JSON Schema; the MCP adapter checks this at Layer
72
+ construction. HTTP allows scalar input. Results and errors may be any shape: MCP wraps
73
+ results as `{ value }` and reports errors as text, the protocol's own error channel.
74
+ - `mcp.name` overrides the tool name (at most 128 characters). Tool hints default to
75
+ `readOnly: false`, `destructive: !readOnly`, `idempotent: false`, and `openWorld: true`.
76
+ Hints do not enforce authorization, approval, or retries.
53
77
  - Schemas must be service-free. Handlers may require services.
78
+ - The group name is the OpenAPI tag and operation-ID prefix (`greetings.greet`).
54
79
 
55
80
  ## HTTP client
56
81
 
57
82
  Save the quickstart above as `quickstart.ts`. Once its routes are served:
58
83
 
59
84
  ```ts
85
+ import { HttpApiClient } from "effect/unstable/httpapi";
60
86
  import { Effect } from "effect";
61
87
  import { FetchHttpClient } from "effect/unstable/http";
62
- import { ActionHttp } from "@gjermundgaraba/effect-actions";
63
- import { Actions } from "./quickstart.js";
88
+ import { Http } from "./quickstart.js";
64
89
 
65
90
  export const greeting = Effect.gen(function* () {
66
- const client = yield* ActionHttp.client(Actions, {
67
- apiPath: "/api/actions",
68
- baseUrl: "http://127.0.0.1:3000",
69
- });
70
- return yield* client.greet({ name: "Ada" });
91
+ const client = yield* HttpApiClient.make(Http.api, { baseUrl: "http://127.0.0.1:3000" });
92
+
93
+ return yield* client.greetings.greet({ payload: { name: "Ada" } });
71
94
  }).pipe(Effect.provide(FetchHttpClient.layer));
72
95
  ```
73
96
 
74
- Run `greeting` with `Effect.runPromise`. Methods take decoded inputs and return
75
- decoded results; MCP-only actions are excluded. Authentication
76
- headers can be added with `transformClient`. See [client details](docs/behavior.md#http-client-details)
77
- for error types, optional inputs, and native grouped clients.
97
+ Run `greeting` with `Effect.runPromise`. Use Effect's native `HttpApiClient.make(Http.api,
98
+ options)`: methods are grouped by action group and take explicit `{ payload: ... }`
99
+ arguments, including `{ payload: {} }` for no-input actions. Inputs and results are
100
+ decoded values; MCP-only actions are excluded. HTTP paths include the group: `<apiPath>/<group>/<action>`.
101
+ Authentication headers can be added with `transformClient`. See
102
+ [client details](docs/behavior.md#http-client-details) for error types and response modes.
103
+
104
+ ## Multiple groups
105
+
106
+ An application can split its contracts and handlers into groups without splitting its
107
+ endpoints. Configuration comes first, then the groups or implementations:
108
+
109
+ ```ts
110
+ import { McpProtocol } from "effect/unstable/ai";
111
+
112
+ const Http = ActionHttp.make({ apiPath: "/api/actions" }, PublicActions, UserActions);
113
+
114
+ const routes = Layer.mergeAll(
115
+ Http.layer(PublicApp),
116
+ Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
117
+ ActionMcp.layerHttp(
118
+ { protocols: [McpProtocol.v2026_07_28], name: "my-app", version: "1.0.0", path: "/mcp" },
119
+ UserApp,
120
+ AuditApp,
121
+ ),
122
+ );
123
+ ```
124
+
125
+ - **Explicit mounting.** `Http.layer(...apps)` registers the routes of the supplied groups, and router
126
+ middleware provided to a layer applies to that layer alone: above, only the user group
127
+ requires authentication. Use separate calls when groups need different middleware. A group that is never mounted has no routes.
128
+ - **Each adapter reads only what it serves.** A group without HTTP actions registers nothing
129
+ and is not built by `Http.layer`, nor one without tools by `ActionMcp.layerHttp`.
130
+ - **Names.** Routes are `POST /api/actions/<group>/<action>`, and native client methods
131
+ are `client.<group>.<action>({ payload: ... })`. Group names must be unique within
132
+ `ActionHttp.make`; action names need only be unique within their group. Tool names
133
+ must be unique within each Toolkit or MCP projection. Adapters validate the
134
+ namespaces they serve, not another transport's names.
135
+ - **MCP middleware is per endpoint.** An MCP endpoint is one route, so middleware provided
136
+ to `ActionMcp.layerHttp`, authentication included, covers all of its tools. Handlers can still
137
+ authorize each tool differently. Only tools that need different middleware, such as none
138
+ at all, need their own endpoint: one `ActionMcp.layerHttp` per `path`.
139
+
140
+ Each group is its own native `HttpApiGroup`, so a host can also combine separately made
141
+ `Http.api` values with `HttpApi.addHttpApi`. See [examples/app.ts](examples/app.ts).
142
+
143
+ ## Serving the document
144
+
145
+ `Http.api` is a native `HttpApi`, carrying every route, description, declared error and
146
+ policy error. Documents and documentation UIs are therefore Effect's own:
147
+
148
+ ```ts
149
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
150
+ import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
151
+
152
+ const documentation = Layer.mergeAll(
153
+ HttpRouter.add("GET", "/openapi.json", HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api))),
154
+ HttpApiSwagger.layer(Http.api, { path: "/docs" }),
155
+ HttpApiScalar.layer(Http.api, { path: "/reference" }),
156
+ );
157
+ ```
158
+
159
+ Each is an ordinary route layer, so it takes whatever middleware it is provided. The same
160
+ value gives Effect's grouped client: `HttpApiClient.make(Http.api)`.
161
+
162
+ ## More projections
163
+
164
+ The same implementation can be used without an HTTP server:
165
+
166
+ ```ts
167
+ import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
168
+ import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
169
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
170
+ import * as ActionCliClient from "@gjermundgaraba/effect-actions/ActionCliClient";
171
+ import { Argument } from "effect/unstable/cli";
172
+
173
+ // Using Actions, Http, and app from the quickstart:
174
+ const tools = ActionToolkit.make(app); // { toolkit, layer }, native Effect Toolkit
175
+ const catalog = ActionCatalog.make(Actions); // JSON-serializable; no handlers acquired
176
+ const command = ActionCli.group(app); // greetings greet --input '{"name":"Ada"}'
177
+ const local = ActionCli.command(app, "greet", {
178
+ parameters: { name: Argument.String("name") },
179
+ input: ({ name }) => ({ name }),
180
+ }); // greet Ada
181
+ const remote = ActionCliClient.command(Http, "greetings", "greet"); // host supplies HttpClient
182
+ ```
183
+
184
+ - **Toolkit:** MCP-enabled actions become native tools with their declared names and
185
+ hints. Calls retain invocation-service requirements and return native results, not
186
+ MCP envelopes. See [examples/toolkit.ts](examples/toolkit.ts).
187
+ - **MCP stdio:** `ActionMcp.layerStdio({ name, version, protocols }, ...apps)` runs a
188
+ subprocess server using a host-provided `Stdio` service and trusted process principal.
189
+ See [examples/mcp-stdio.ts](examples/mcp-stdio.ts). Stdout is protocol-only.
190
+ - **Catalog:** standalone input/output/error JSON schemas with local `$defs`, plus
191
+ metadata and group-qualified identities. See [examples/catalog.ts](examples/catalog.ts).
192
+ - **Local CLI:** `.command(app, "actionName", options?)` selects one action; `.group(app,
193
+ options?)` selects a group, including local-only actions. The host supplies services.
194
+ - **Remote CLI:** `ActionCliClient` projects HTTP contracts into commands using native
195
+ `HttpApiClient`; it never executes local handlers or manages credentials. See
196
+ [examples/cli.ts](examples/cli.ts) and [examples/cli-client.ts](examples/cli-client.ts).
197
+
198
+ Generated CLI commands accept whole-input `--input '<json>'`. For a human-oriented
199
+ command, supply native Effect flags/arguments through `parameters` and map them to
200
+ encoded action input with `input`. Explicitly configured commands do not also accept
201
+ `--input`; their syntax is independent of schema changes. Commands print validated
202
+ JSON by default; an optional renderer enables human output with `--json` available.
203
+ See [CLI boundaries](docs/behavior.md#cli-boundaries) for codecs and configuration.
78
204
 
79
205
  ## Adapter options
80
206
 
81
- | API | Options |
82
- | ---------------------------- | --------------------------------------------------------------------------------------- |
83
- | `ActionHttp.layer` | `apiPath`, `openapiPath`, `schemaError` |
84
- | `ActionHttp.api` / `openapi` | `apiPath`, `schemaError` |
85
- | `ActionHttp.client` | `apiPath`, `schemaError`, `baseUrl`, `transformClient`, `transformResponse` |
86
- | `ActionMcp.layer` | `name`, `version`, `path`, `protocols`, `allowedOrigins`, `instructions`, `schemaError` |
207
+ | API | Options |
208
+ | -------------------------------------- | ------------------------------------------------------------------------ |
209
+ | `ActionGroup.make(options, …actions)` | `name`, `errors`, `schemaError` |
210
+ | `ActionHttp.make(options, …groups)` | `apiPath` |
211
+ | `Http.layer(...apps)` | None |
212
+ | `ActionMcp.layerHttp(options, …apps)` | `name`, `version`, `path`, `protocols`, `allowedOrigins`, `instructions` |
213
+ | `ActionMcp.layerStdio(options, …apps)` | `name`, `version`, `protocols`, `instructions` |
87
214
 
88
- `apiPath`, `openapiPath` (on `layer` / `configure`), and MCP `path` are required —
89
- the library has no defaults. `ActionHttp.configure(options)` binds HTTP configuration
90
- for its `layer`, `api`, `openapi`, and `client` methods. Configured clients accept
91
- only connection options. Set `openapiPath: false` when the host serves a combined
92
- document.
215
+ `apiPath` and MCP `path` have no defaults. `Http` has two members: `api` and `layer`. MCP `protocols` is required and takes Effect’s native `McpProtocol` adapters.
216
+ Effect owns protocol negotiation and sessions.
93
217
 
94
218
  See [adapter behavior](docs/behavior.md) for shared schema-error policies,
95
219
  dependency lifetimes, wire formats, and MCP protocol support.
96
220
 
97
221
  ## Scope
98
222
 
99
- HTTP means JSON POST endpoints, not Effect's RPC wire protocol. Both adapters use
100
- Effect's servers; there is no MCP SDK runtime dependency. Actions are unary:
101
- no streaming, uploads, prompts, resources, or retries.
223
+ HTTP means JSON POST endpoints, not Effect's RPC wire protocol. HTTP and MCP use
224
+ Effect's servers: `HttpApi` and one `McpServer` `Tool` per action; there is no MCP SDK
225
+ runtime dependency. Actions are unary:
226
+ no streaming, uploads, prompts, resources, retries, or code-execution sandbox.
102
227
 
103
228
  Authentication and authorization belong to the application. Tool discovery is
104
229
  not filtered by actor. The example's bearer tokens are **demo-only**; production
@@ -118,53 +243,102 @@ vp run example
118
243
  ```
119
244
 
120
245
  `vp run dev` watches the library build. `vp check` also verifies the compile-time
121
- assertions in `tests/types.ts`. Tests cover both transports, official MCP clients,
122
- context isolation, schema-error policies, and cancellation.
246
+ assertions in `tests/types.spec.ts`. Tests cover both transports, official MCP clients,
247
+ request-context handling, schema-error policies, and cancellation.
123
248
 
124
- `vp run build` emits ESM and declarations into `dist/`. Public entry points are the
125
- package root, `/Action`, `/ActionGroup`, `/http`, `/mcp`, `/authentication`, `/testing`, and `/testing/client`.
126
- `vp run test:package` builds and checks a tarball in an isolated consumer using the pinned
127
- Effect snapshot; it does not establish compatibility with the published peer.
249
+ `vp run build` emits module-preserving ESM and declarations into `dist/`. The entry points
250
+ are one subpath per module: `/Action`, `/ActionGroup`, `/ActionHttp`, `/ActionMcp`,
251
+ `/ActionToolkit`, `/ActionCatalog`, `/ActionCli`, `/ActionCliClient`, `/Authentication`,
252
+ `/Testing`, and `/TestingClient`. There is no package root, so a contracts-only or browser bundle never
253
+ loads the MCP server or the optional client peer.
254
+ `vp run test:package` builds and checks a tarball in an isolated consumer with
255
+ `skipLibCheck: false`.
128
256
 
129
257
  ## Authentication and OAuth discovery
130
258
 
131
- `Authentication.middleware(CurrentActor, { authenticate, errors, headers })` provides
132
- an application-defined context service for each request. `authenticate` is an Effect;
133
- `errors` lists its error schemas in precedence order. The first matching schema owns
134
- both serialization and `httpApiStatus` (500 when unannotated). If its encoder fails,
135
- the request fails as a server error; another schema is not tried. `headers(error)`
136
- can add a Bearer challenge.
137
- Responses use `Cache-Control: no-store`, including failures handled by enclosing middleware. Provide the returned middleware's `.layer` to
138
- HTTP and MCP route layers. See [examples/app.ts](examples/app.ts).
259
+ `Authentication.middleware(CurrentActor, authenticate)` provides an application-defined
260
+ context service for each request. `authenticate` is an Effect that succeeds with the
261
+ identity or fails with the `HttpServerResponse` to send instead, so the host owns the
262
+ status, body and any Bearer challenge: `HttpServerResponse.schemaJson(Unauthenticated)(error,
263
+ { status: 401, headers })`. Responses use `Cache-Control: no-store`, including failures handled
264
+ by enclosing middleware. Provide the returned middleware's `.layer` to HTTP and MCP route
265
+ layers. See [examples/app.ts](examples/app.ts).
266
+
267
+ Use distinct service tags for startup capabilities and request identities. Never supply
268
+ `CurrentActor` or other request-identity tags through startup layers or root context:
269
+ native Effect context capture can shadow request values or supply missing runtime values.
270
+ The adapters do not provide an additional context-isolation boundary.
139
271
 
140
272
  Authentication dependencies are request requirements, as with native `HttpRouter.middleware`.
141
273
  Use `.combine(...)` with middleware that provides them. Acquired resources stay alive
142
274
  until the request scope closes. Downstream action errors are handled by their transport,
143
275
  not serialized as authentication failures.
144
276
 
145
- `ActionMcp.protectedResource({ resource, authorizationServers, scopesSupported })`
277
+ `Authentication.protectedResource({ resource, authorizationServers, scopesSupported })`
146
278
  returns `{ layer, metadataUrl, challenge }`. Mount its public `layer` separately from
147
279
  protected routes. It publishes [RFC 9728 metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
148
280
  at `/.well-known/oauth-protected-resource` followed by the resource path and query. Discovery
149
281
  matches that literal path and query for GET and HEAD; other requests reach the host router.
150
282
  Caching policy belongs to the host. `challenge()`
151
283
  returns the `WWW-Authenticate` value; pass `{ error: "invalid_token" }` or
152
- `{ error: "insufficient_scope", scope: "admin" }` where appropriate. Supported scopes
153
- are advertised in metadata; the challenge names only scopes needed for that request.
154
- The helper supports HTTPS URLs and loopback HTTP for development, with no credentials
155
- or fragment. Resource identifiers may include a query; authorization-server issuers may not.
156
- Your application owns login, consent, and token verification.
284
+ `{ error: "insufficient_scope", scope: "admin" }` where appropriate. Parameter values are
285
+ quoted and escaped, never rejected. Supported scopes are advertised in metadata; the
286
+ challenge names only scopes needed for that request. The helper publishes what it is given:
287
+ your deployment is responsible for these being valid OAuth URLs (HTTPS, or loopback HTTP in
288
+ development), and your application owns login, consent, and token verification.
289
+
290
+ ```ts
291
+ const discovery = Authentication.protectedResource({
292
+ resource: "https://api.example.com/mcp",
293
+ authorizationServers: ["https://auth.example.com"],
294
+ });
295
+
296
+ // The response sent when authentication fails; the host owns status, body and challenge.
297
+ const unauthenticated = HttpServerResponse.schemaJson(Unauthenticated)(
298
+ new Unauthenticated({ message: "A bearer token is required." }),
299
+ { status: 401, headers: { "www-authenticate": discovery.challenge({ error: "invalid_token" }) } },
300
+ ).pipe(Effect.orDie);
301
+
302
+ const authentication = Authentication.middleware(
303
+ CurrentActor,
304
+ Effect.gen(function* () {
305
+ const actor = yield* verifyToken; // Effect<Option<Actor>, never, HttpServerRequest>
157
306
 
158
- ## MCP testing
307
+ return Option.isNone(actor) ? yield* Effect.flip(unauthenticated) : actor.value;
308
+ }),
309
+ );
310
+
311
+ const routes = Layer.mergeAll(
312
+ discovery.layer,
313
+ protectedRoutes.pipe(Layer.provide(authentication.layer)),
314
+ );
315
+ ```
316
+
317
+ ## Testing
159
318
 
160
- Import `mcpRequest` from `@gjermundgaraba/effect-actions/testing`; it requires no MCP client dependency.
161
- Import `withMcpClient` from `@gjermundgaraba/effect-actions/testing/client` and install
162
- its optional peer `@modelcontextprotocol/client@^2.0.0`. Only the client entry point loads this peer.
319
+ Import from `@gjermundgaraba/effect-actions/Testing`; it requires no MCP client dependency.
320
+ `withMcpClient` lives in `@gjermundgaraba/effect-actions/TestingClient` and needs the optional
321
+ peer `@modelcontextprotocol/client@^2.0.0`. Only that entry point loads the peer.
322
+
323
+ ```ts
324
+ const web = HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)));
325
+
326
+ const greeting = Effect.gen(function* () {
327
+ const client = yield* httpClient(Http.api, web.handler);
328
+
329
+ return yield* client.greetings.greet({ payload: { name: "Ada" } });
330
+ });
331
+ ```
163
332
 
164
- - `mcpRequest(method, params?, { url, headers? })` builds a stateless 2026-07-28 request.
165
- `url` is required. Caller `params._meta` fields override the default client capabilities
333
+ - `httpClient(Http.api, handler, options?)` is the native grouped HTTP client calling a web handler in
334
+ memory. `options` are the client's connection options; `baseUrl` defaults to `http://localhost`.
335
+ - `mcpRequest({ url, method, params?, headers? })` builds a stateless 2026-07-28 request.
336
+ `params` takes whatever `JSON.stringify` accepts, `undefined` fields included, so tests can
337
+ send malformed arguments.
338
+ Caller `params._meta` fields override the default client capabilities
166
339
  and information; application metadata is preserved. The protocol version stays pinned to
167
340
  2026-07-28 in both the request header and metadata. Metadata is merged shallowly.
168
- - `withMcpClient(fetch, async client => ..., { path, mode?, baseUrl?, headers? })`
169
- connects the official client and closes it in a `finally` block. `path` is required.
170
- `mode` defaults to `"modern"` (2026-07-28); use `"legacy"` to exercise session negotiation.
341
+ - `withMcpClient({ fetch, path, versionNegotiation?, baseUrl?, headers? }, async client => ...)`
342
+ connects the official client and closes it in a `finally` block.
343
+ `versionNegotiation` is passed directly to the official client (whose default is legacy).
344
+ For a stateless endpoint, pass `versionNegotiation: { mode: { pin: "2026-07-28" } }`.
package/dist/Action.d.mts CHANGED
@@ -1,54 +1,62 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  declare namespace Action_d_exports {
3
- export { Action, Any, Codec, Handler, McpOptions, NoInput, Options, SchemaErrorPolicy, SchemaFailure, make };
3
+ export { Action, Any, Codec, Handler, McpOptions, Options, make };
4
4
  }
5
+ /** Any service-free schema. Only handlers may require services. */
5
6
  export type Codec = Schema.Codec<unknown, unknown, never, never>;
6
- /** Failed request decoding or successful-result encoding, independent of transport terminology. */
7
- export interface SchemaFailure {
8
- readonly phase: "input" | "output";
9
- /** May contain sensitive values. Do not reflect it in public error messages. */
10
- readonly cause: Schema.SchemaError;
11
- }
12
- /** Pure application-owned mapping, shared by the HTTP and MCP adapters. */
13
- export interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
14
- readonly errors: Errors;
15
- readonly map: (failure: SchemaFailure) => NoInfer<Errors[number]["Type"]>;
16
- }
7
+ /** MCP tool metadata; every field has a default derived from the action. */
17
8
  export interface McpOptions {
9
+ /** Tool name; defaults to the action name. Must match `^[A-Za-z0-9_-]{1,128}$`. */
18
10
  readonly name?: string;
11
+ /** `readOnlyHint`; defaults to `false`. */
19
12
  readonly readOnly?: boolean;
20
- /** Defaults to `!readOnly`; the MCP spec only defines it for non-read-only tools. */
13
+ /** `destructiveHint`; defaults to `!readOnly`, as the MCP spec only defines it for writes. */
21
14
  readonly destructive?: boolean;
15
+ /** `idempotentHint`; defaults to `false`. */
16
+ readonly idempotent?: boolean;
17
+ /** `openWorldHint`; defaults to `true`. */
18
+ readonly openWorld?: boolean;
22
19
  }
23
- export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean> {
20
+ type ResolvedMcp<Name extends string, Mcp extends false | McpOptions | undefined> = Mcp extends false ? false : {
21
+ readonly name: Mcp extends McpOptions ? "name" extends keyof Mcp ? Extract<Mcp["name"], string> extends never ? Name : Extract<Mcp["name"], string> | (undefined extends Mcp["name"] ? Name : never) : Name : Name;
22
+ readonly readOnly: boolean;
23
+ readonly destructive: boolean;
24
+ readonly idempotent: boolean;
25
+ readonly openWorld: boolean;
26
+ };
27
+ /** What `make` needs to define an action. */
28
+ export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
24
29
  readonly description: string;
25
- /** Defaults to `NoInput`. */
30
+ /** Omit for an action without arguments. */
26
31
  readonly input?: Input;
27
32
  readonly success: Output;
28
33
  /** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
29
- readonly error?: Errors;
34
+ readonly errors?: Errors;
35
+ /** `false` hides the action from HTTP routes and clients. */
30
36
  readonly http?: Http;
31
- readonly mcp?: false | McpOptions;
37
+ /** `false` hides the action from MCP; otherwise tool metadata. */
38
+ readonly mcp?: Mcp;
32
39
  }
33
40
  /** A pure contract: schemas and transport metadata. Implementations are bound by `ActionGroup.implement`. */
34
- export interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean> {
41
+ export interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
35
42
  readonly name: Name;
36
43
  readonly description: string;
37
44
  readonly input: Input;
38
45
  readonly success: Output;
39
46
  readonly errors: Errors;
40
47
  readonly http: Http;
41
- readonly mcp: false | {
42
- readonly name: string;
43
- readonly readOnly: boolean;
44
- readonly destructive: boolean;
45
- };
48
+ readonly mcp: ResolvedMcp<Name, Mcp>;
46
49
  }
47
- export type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>>;
50
+ /** Any action, with its schemas erased. */
51
+ export type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, false> | Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, McpOptions> | Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, undefined>;
48
52
  /** Receives decoded input; may fail only with the declared errors. */
49
53
  export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
50
54
  /** An empty object schema that also produces the object root MCP requires. */
51
- export declare const NoInput: Schema.$Record<Schema.String, Schema.Never>;
52
- export declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Http extends boolean = true>(name: Name, options: Options<Input, Output, Errors, Http>): Action<Name, Input, Output, Errors, Http>;
55
+ declare const NoInput: Schema.$Record<Schema.String, Schema.Never>;
56
+ /**
57
+ * Define an action contract. Names are `[A-Za-z0-9_-]+`, other than `then`.
58
+ * Actions can be local-only by setting both transports to `false`.
59
+ */
60
+ export declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Http extends boolean = true, const Mcp extends false | McpOptions | undefined = undefined>(name: Name, options: Options<Input, Output, Errors, Http, Mcp>): Action<Name, Input, Output, Errors, Http, Mcp>;
53
61
  //#endregion
54
- export { Action_d_exports as t };
62
+ export { Action_d_exports };
package/dist/Action.mjs CHANGED
@@ -1,30 +1,29 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
1
+ import { assertName } from "./internal/actions.mjs";
2
2
  import { Schema } from "effect";
3
3
  //#region src/Action.ts
4
- var Action_exports = /* @__PURE__ */ __exportAll({
5
- NoInput: () => NoInput,
6
- make: () => make
7
- });
8
4
  /** An empty object schema that also produces the object root MCP requires. */
9
5
  const NoInput = Schema.Record(Schema.String, Schema.Never);
10
6
  function make(name, options) {
11
- if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) throw new Error(`Invalid action name: ${name}`);
7
+ assertName("action name", name);
12
8
  const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? false;
13
9
  const mcp = options.mcp === false ? false : {
14
10
  name: options.mcp?.name ?? name,
15
11
  readOnly,
16
- destructive: options.mcp?.destructive ?? !readOnly
12
+ destructive: options.mcp?.destructive ?? !readOnly,
13
+ idempotent: options.mcp?.idempotent ?? false,
14
+ openWorld: options.mcp?.openWorld ?? true
17
15
  };
18
- if (mcp !== false && !/^[A-Za-z0-9_-]{1,128}$/.test(mcp.name)) throw new Error(`Invalid MCP name: ${mcp.name}`);
16
+ if (mcp !== false && mcp.name.length > 128) throw new Error(`Invalid MCP name: ${mcp.name}`);
17
+ if (mcp !== false) assertName("MCP name", mcp.name);
19
18
  return {
20
19
  name,
21
20
  description: options.description,
22
21
  input: options.input ?? NoInput,
23
22
  success: options.success,
24
- errors: options.error ?? [],
23
+ errors: options.errors ?? [],
25
24
  http: options.http !== false,
26
25
  mcp
27
26
  };
28
27
  }
29
28
  //#endregion
30
- export { NoInput, make, Action_exports as t };
29
+ export { make };
@@ -0,0 +1,31 @@
1
+ import { Action_d_exports, Any } from "./Action.mjs";
2
+ import { ActionGroup_d_exports, Any as Any$1 } from "./ActionGroup.mjs";
3
+ import { JsonSchema } from "effect";
4
+ //#region src/ActionCatalog.d.ts
5
+ /** One contract, with independently rooted JSON schemas for its encoded values. */
6
+ export interface Entry {
7
+ readonly id: string;
8
+ readonly group: string;
9
+ readonly name: string;
10
+ readonly description: string;
11
+ readonly http: boolean;
12
+ readonly mcp: Any["mcp"];
13
+ readonly input: JsonSchema.JsonSchema;
14
+ readonly success: JsonSchema.JsonSchema;
15
+ /** Includes the errors inherited from the group; not HTTP schema-policy errors. */
16
+ readonly errors: ReadonlyArray<JsonSchema.JsonSchema>;
17
+ /** HTTP-only decoding/encoding policy errors, separate from handler failures. */
18
+ readonly httpSchemaErrors: ReadonlyArray<JsonSchema.JsonSchema>;
19
+ }
20
+ /** An offline contract document, not an authorization decision or a mounted endpoint. */
21
+ export interface Catalog {
22
+ readonly version: "1";
23
+ readonly actions: ReadonlyArray<Entry>;
24
+ }
25
+ /**
26
+ * Describe the supplied groups without acquiring implementations. Schemas describe
27
+ * encoded action values, not MCP's `{ value }` envelope or deployment-specific URLs.
28
+ * Local-only actions are included; the host chooses what, if anything, to publish.
29
+ */
30
+ export declare const make: (...groups: ReadonlyArray<Any$1>) => Catalog;
31
+ //#endregion
@@ -0,0 +1,36 @@
1
+ import { assertDistinct } from "./internal/actions.mjs";
2
+ import { JsonSchema, Schema } from "effect";
3
+ //#region src/ActionCatalog.ts
4
+ const jsonSchema = (codec) => {
5
+ const document = Schema.toJsonSchemaDocument(codec);
6
+ return {
7
+ $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
8
+ ...document.schema,
9
+ ...Object.keys(document.definitions).length === 0 ? {} : { $defs: document.definitions }
10
+ };
11
+ };
12
+ /**
13
+ * Describe the supplied groups without acquiring implementations. Schemas describe
14
+ * encoded action values, not MCP's `{ value }` envelope or deployment-specific URLs.
15
+ * Local-only actions are included; the host chooses what, if anything, to publish.
16
+ */
17
+ const make = (...groups) => {
18
+ assertDistinct("catalog group", groups.map((group) => group.name));
19
+ return {
20
+ version: "1",
21
+ actions: groups.flatMap((group) => group.actions.map((action) => ({
22
+ id: `${group.name}.${action.name}`,
23
+ group: group.name,
24
+ name: action.name,
25
+ description: action.description,
26
+ http: action.http,
27
+ mcp: action.mcp,
28
+ input: jsonSchema(action.input),
29
+ success: jsonSchema(action.success),
30
+ errors: action.errors.map(jsonSchema),
31
+ httpSchemaErrors: action.http ? group.schemaError?.errors.map(jsonSchema) ?? [] : []
32
+ })))
33
+ };
34
+ };
35
+ //#endregion
36
+ export { make };