@gjermundgaraba/effect-actions 0.1.0-rc.1 → 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.
package/README.md CHANGED
@@ -1,31 +1,23 @@
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
- **Snapshot-dependent preview.** This release requires Effect snapshot
6
- [`9ad9891`](https://pkg.pr.new/Effect-TS/effect/effect@9ad9891). The published npm
7
- version `effect@4.0.0-rc.115` is not compatible: it lacks APIs required by the MCP
8
- adapter. Install both packages explicitly:
5
+ ## Install
9
6
 
10
7
  ```sh
11
- pnpm add @gjermundgaraba/effect-actions@rc \
12
- 'effect@https://pkg.pr.new/Effect-TS/effect/effect@9ad9891'
8
+ pnpm add @gjermundgaraba/effect-actions@rc effect@4.0.0-rc.116
13
9
  ```
14
10
 
15
- The snapshot reports version `4.0.0-rc.115`, so the package retains that nominal
16
- peer declaration. A semver peer cannot distinguish the snapshot from the npm
17
- build; satisfying the peer declaration alone does **not** ensure compatibility.
18
- Installation requires access to `pkg.pr.new`, not just the npm registry.
19
-
20
- For Node server wiring, use the matching platform snapshot too:
11
+ For Node server wiring:
21
12
 
22
13
  ```sh
23
- pnpm add '@effect/platform-node@https://pkg.pr.new/Effect-TS/effect/@effect/platform-node@9ad9891'
14
+ pnpm add @effect/platform-node@4.0.0-rc.116
24
15
  ```
25
16
 
26
17
  ## Quickstart
27
18
 
28
19
  ```ts
20
+ import { McpProtocol } from "effect/unstable/ai";
29
21
  import { Effect, Layer, Schema } from "effect";
30
22
  import * as Action from "@gjermundgaraba/effect-actions/Action";
31
23
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
@@ -49,13 +41,17 @@ const app = Actions.implement({
49
41
 
50
42
  export const routes = Layer.mergeAll(
51
43
  Http.layer(app),
52
- ActionMcp.layer({ name: "greetings", version: "1.0.0", path: "/mcp" }, app),
44
+ ActionMcp.layerHttp(
45
+ { protocols: [McpProtocol.v2026_07_28], name: "greetings", version: "1.0.0", path: "/mcp" },
46
+ app,
47
+ ),
53
48
  );
54
49
  ```
55
50
 
56
- Serve `routes` with Effect's `HttpRouter`. This creates `POST /api/actions/greet` and an
51
+ Serve `routes` with Effect's `HttpRouter`. This creates `POST /api/actions/greetings/greet` and an
57
52
  MCP endpoint at `/mcp`. The HTTP response is `"Hello, Ada!"`; MCP returns
58
- `structuredContent: { value: "Hello, Ada!" }`.
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.
59
55
  See [examples/server.ts](examples/server.ts) for Node server wiring and the
60
56
  [authenticated demo](examples/README.md) for a runnable application.
61
57
 
@@ -65,14 +61,19 @@ See [examples/server.ts](examples/server.ts) for Node server wiring and the
65
61
  - `errors` is a list of failure schemas, defaulting to none. Each schema retains
66
62
  its own `httpApiStatus` annotation.
67
63
  - A group's `errors` are added to every one of its actions, so a shared set, such as
68
- authorization failures, is declared once. Its `schemaError` policy decides how both
69
- transports answer failed decoding or encoding; see
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
70
66
  [schema-error policies](docs/behavior.md#schema-error-policies).
71
67
  - Actions default to both transports; use `http: false` or `mcp: false` to opt out.
72
- An action exposed on neither is rejected.
73
- - MCP input must have an object-root JSON Schema; declared errors must encode to
74
- objects. The MCP adapter checks this at Layer construction. HTTP allows scalars.
75
- - `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.
76
77
  - Schemas must be service-free. Handlers may require services.
77
78
  - The group name is the OpenAPI tag and operation-ID prefix (`greetings.greet`).
78
79
 
@@ -81,22 +82,24 @@ See [examples/server.ts](examples/server.ts) for Node server wiring and the
81
82
  Save the quickstart above as `quickstart.ts`. Once its routes are served:
82
83
 
83
84
  ```ts
85
+ import { HttpApiClient } from "effect/unstable/httpapi";
84
86
  import { Effect } from "effect";
85
87
  import { FetchHttpClient } from "effect/unstable/http";
86
88
  import { Http } from "./quickstart.js";
87
89
 
88
90
  export const greeting = Effect.gen(function* () {
89
- const client = yield* Http.client({ baseUrl: "http://127.0.0.1:3000" });
91
+ const client = yield* HttpApiClient.make(Http.api, { baseUrl: "http://127.0.0.1:3000" });
90
92
 
91
- return yield* client.greet({ name: "Ada" });
93
+ return yield* client.greetings.greet({ payload: { name: "Ada" } });
92
94
  }).pipe(Effect.provide(FetchHttpClient.layer));
93
95
  ```
94
96
 
95
- Run `greeting` with `Effect.runPromise`. Methods take decoded inputs and return
96
- decoded results; MCP-only actions are excluded. `ActionHttp.make` binds the mount path the
97
- server and its clients must agree on, so `client` accepts only connection options. Authentication headers can be added with
98
- `transformClient`. See [client details](docs/behavior.md#http-client-details)
99
- 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.
100
103
 
101
104
  ## Multiple groups
102
105
 
@@ -104,28 +107,35 @@ An application can split its contracts and handlers into groups without splittin
104
107
  endpoints. Configuration comes first, then the groups or implementations:
105
108
 
106
109
  ```ts
110
+ import { McpProtocol } from "effect/unstable/ai";
111
+
107
112
  const Http = ActionHttp.make({ apiPath: "/api/actions" }, PublicActions, UserActions);
108
113
 
109
114
  const routes = Layer.mergeAll(
110
115
  Http.layer(PublicApp),
111
116
  Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
112
- ActionMcp.layer({ name: "my-app", version: "1.0.0", path: "/mcp" }, UserApp, AuditApp),
117
+ ActionMcp.layerHttp(
118
+ { protocols: [McpProtocol.v2026_07_28], name: "my-app", version: "1.0.0", path: "/mcp" },
119
+ UserApp,
120
+ AuditApp,
121
+ ),
113
122
  );
114
123
  ```
115
124
 
116
- - **One layer per group.** `Http.layer(app)` registers the routes of one group, and router
125
+ - **Explicit mounting.** `Http.layer(...apps)` registers the routes of the supplied groups, and router
117
126
  middleware provided to a layer applies to that layer alone: above, only the user group
118
- requires authentication. A group that is never mounted has no routes.
127
+ requires authentication. Use separate calls when groups need different middleware. A group that is never mounted has no routes.
119
128
  - **Each adapter reads only what it serves.** A group without HTTP actions registers nothing
120
- and is not built by `Http.layer`, nor one without tools by `ActionMcp.layer`.
121
- - **Names.** Routes and client methods are flat (`POST /api/actions/<action>`,
122
- `client.<action>()`), so group names and HTTP action names must be unique within one
123
- `ActionHttp.make`, and tool names within one `ActionMcp.layer`. Duplicates throw at
124
- construction; neither adapter looks at the other's names.
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.
125
135
  - **MCP middleware is per endpoint.** An MCP endpoint is one route, so middleware provided
126
- to `ActionMcp.layer`, authentication included, covers all of its tools. Handlers can still
136
+ to `ActionMcp.layerHttp`, authentication included, covers all of its tools. Handlers can still
127
137
  authorize each tool differently. Only tools that need different middleware, such as none
128
- at all, need their own endpoint: one `ActionMcp.layer` per `path`.
138
+ at all, need their own endpoint: one `ActionMcp.layerHttp` per `path`.
129
139
 
130
140
  Each group is its own native `HttpApiGroup`, so a host can also combine separately made
131
141
  `Http.api` values with `HttpApi.addHttpApi`. See [examples/app.ts](examples/app.ts).
@@ -149,27 +159,71 @@ const documentation = Layer.mergeAll(
149
159
  Each is an ordinary route layer, so it takes whatever middleware it is provided. The same
150
160
  value gives Effect's grouped client: `HttpApiClient.make(Http.api)`.
151
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.
204
+
152
205
  ## Adapter options
153
206
 
154
- | API | Options |
155
- | ------------------------------------- | ------------------------------------------------------------------------ |
156
- | `ActionGroup.make(options, …actions)` | `name`, `errors`, `schemaError` |
157
- | `ActionHttp.make(options, …groups)` | `apiPath` |
158
- | `Http.layer(app)` | None |
159
- | `Http.client(options?)` | `baseUrl`, `transformClient`, `transformResponse` |
160
- | `ActionMcp.layer(options, …apps)` | `name`, `version`, `path`, `protocols`, `allowedOrigins`, `instructions` |
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` |
161
214
 
162
- `apiPath` and MCP `path` have no defaults. `Http` has three members: `api`, `layer` and
163
- `client`.
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.
164
217
 
165
218
  See [adapter behavior](docs/behavior.md) for shared schema-error policies,
166
219
  dependency lifetimes, wire formats, and MCP protocol support.
167
220
 
168
221
  ## Scope
169
222
 
170
- HTTP means JSON POST endpoints, not Effect's RPC wire protocol. Both adapters use
171
- Effect's servers; there is no MCP SDK runtime dependency. Actions are unary:
172
- 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.
173
227
 
174
228
  Authentication and authorization belong to the application. Tool discovery is
175
229
  not filtered by actor. The example's bearer tokens are **demo-only**; production
@@ -190,25 +244,30 @@ vp run example
190
244
 
191
245
  `vp run dev` watches the library build. `vp check` also verifies the compile-time
192
246
  assertions in `tests/types.spec.ts`. Tests cover both transports, official MCP clients,
193
- context isolation, schema-error policies, and cancellation.
247
+ request-context handling, schema-error policies, and cancellation.
194
248
 
195
- `vp run build` emits ESM and declarations into `dist/`. The entry points are one subpath per
196
- module: `/Action`, `/ActionGroup`, `/ActionHttp`, `/ActionMcp`, `/Authentication`, `/Testing`,
197
- and `/TestingClient`. There is no package root, so a contracts-only or browser bundle never
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
198
253
  loads the MCP server or the optional client peer.
199
- `vp run test:package` builds and checks a tarball in an isolated consumer using the pinned
200
- Effect snapshot; it does not establish compatibility with the published peer.
254
+ `vp run test:package` builds and checks a tarball in an isolated consumer with
255
+ `skipLibCheck: false`.
201
256
 
202
257
  ## Authentication and OAuth discovery
203
258
 
204
- `Authentication.middleware(CurrentActor, { authenticate, errors, headers })` provides
205
- an application-defined context service for each request. `authenticate` is an Effect;
206
- `errors` lists its error schemas in precedence order. The first matching schema owns
207
- both serialization and `httpApiStatus` (500 when unannotated). If its encoder fails,
208
- the request fails as a server error; another schema is not tried. `headers(error)`
209
- can add a Bearer challenge.
210
- Responses use `Cache-Control: no-store`, including failures handled by enclosing middleware. Provide the returned middleware's `.layer` to
211
- 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.
212
271
 
213
272
  Authentication dependencies are request requirements, as with native `HttpRouter.middleware`.
214
273
  Use `.combine(...)` with middleware that provides them. Acquired resources stay alive
@@ -222,11 +281,11 @@ at `/.well-known/oauth-protected-resource` followed by the resource path and que
222
281
  matches that literal path and query for GET and HEAD; other requests reach the host router.
223
282
  Caching policy belongs to the host. `challenge()`
224
283
  returns the `WWW-Authenticate` value; pass `{ error: "invalid_token" }` or
225
- `{ error: "insufficient_scope", scope: "admin" }` where appropriate. Supported scopes
226
- are advertised in metadata; the challenge names only scopes needed for that request.
227
- The helper supports HTTPS URLs and loopback HTTP for development, with no credentials
228
- or fragment. Resource identifiers may include a query; authorization-server issuers may not.
229
- 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.
230
289
 
231
290
  ```ts
232
291
  const discovery = Authentication.protectedResource({
@@ -234,11 +293,20 @@ const discovery = Authentication.protectedResource({
234
293
  authorizationServers: ["https://auth.example.com"],
235
294
  });
236
295
 
237
- const authentication = Authentication.middleware(CurrentActor, {
238
- authenticate,
239
- errors: [Unauthenticated],
240
- headers: () => ({ "www-authenticate": discovery.challenge({ error: "invalid_token" }) }),
241
- });
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>
306
+
307
+ return Option.isNone(actor) ? yield* Effect.flip(unauthenticated) : actor.value;
308
+ }),
309
+ );
242
310
 
243
311
  const routes = Layer.mergeAll(
244
312
  discovery.layer,
@@ -256,13 +324,13 @@ peer `@modelcontextprotocol/client@^2.0.0`. Only that entry point loads the peer
256
324
  const web = HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)));
257
325
 
258
326
  const greeting = Effect.gen(function* () {
259
- const client = yield* httpClient(Http, web.handler);
327
+ const client = yield* httpClient(Http.api, web.handler);
260
328
 
261
- return yield* client.greet({ name: "Ada" });
329
+ return yield* client.greetings.greet({ payload: { name: "Ada" } });
262
330
  });
263
331
  ```
264
332
 
265
- - `httpClient(Http, handler, options?)` is the typed HTTP client calling a web handler in
333
+ - `httpClient(Http.api, handler, options?)` is the native grouped HTTP client calling a web handler in
266
334
  memory. `options` are the client's connection options; `baseUrl` defaults to `http://localhost`.
267
335
  - `mcpRequest({ url, method, params?, headers? })` builds a stateless 2026-07-28 request.
268
336
  `params` takes whatever `JSON.stringify` accepts, `undefined` fields included, so tests can
@@ -270,6 +338,7 @@ const greeting = Effect.gen(function* () {
270
338
  Caller `params._meta` fields override the default client capabilities
271
339
  and information; application metadata is preserved. The protocol version stays pinned to
272
340
  2026-07-28 in both the request header and metadata. Metadata is merged shallowly.
273
- - `withMcpClient({ fetch, path, mode?, baseUrl?, headers? }, async client => ...)`
341
+ - `withMcpClient({ fetch, path, versionNegotiation?, baseUrl?, headers? }, async client => ...)`
274
342
  connects the official client and closes it in a `finally` block.
275
- `mode` defaults to `"modern"` (2026-07-28); use `"legacy"` to exercise session negotiation.
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,2 +1,62 @@
1
- import { a as Handler, c as SchemaErrorPolicy, i as Codec, l as SchemaFailure, o as McpOptions, r as Any, s as Options, t as Action, u as make } from "./Action-CXsfTEhx.mjs";
2
- export { Action, Any, Codec, Handler, McpOptions, Options, SchemaErrorPolicy, SchemaFailure, make };
1
+ import { Effect, Schema } from "effect";
2
+ declare namespace Action_d_exports {
3
+ export { Action, Any, Codec, Handler, McpOptions, Options, make };
4
+ }
5
+ /** Any service-free schema. Only handlers may require services. */
6
+ export type Codec = Schema.Codec<unknown, unknown, never, never>;
7
+ /** MCP tool metadata; every field has a default derived from the action. */
8
+ export interface McpOptions {
9
+ /** Tool name; defaults to the action name. Must match `^[A-Za-z0-9_-]{1,128}$`. */
10
+ readonly name?: string;
11
+ /** `readOnlyHint`; defaults to `false`. */
12
+ readonly readOnly?: boolean;
13
+ /** `destructiveHint`; defaults to `!readOnly`, as the MCP spec only defines it for writes. */
14
+ readonly destructive?: boolean;
15
+ /** `idempotentHint`; defaults to `false`. */
16
+ readonly idempotent?: boolean;
17
+ /** `openWorldHint`; defaults to `true`. */
18
+ readonly openWorld?: boolean;
19
+ }
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> {
29
+ readonly description: string;
30
+ /** Omit for an action without arguments. */
31
+ readonly input?: Input;
32
+ readonly success: Output;
33
+ /** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
34
+ readonly errors?: Errors;
35
+ /** `false` hides the action from HTTP routes and clients. */
36
+ readonly http?: Http;
37
+ /** `false` hides the action from MCP; otherwise tool metadata. */
38
+ readonly mcp?: Mcp;
39
+ }
40
+ /** A pure contract: schemas and transport metadata. Implementations are bound by `ActionGroup.implement`. */
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> {
42
+ readonly name: Name;
43
+ readonly description: string;
44
+ readonly input: Input;
45
+ readonly success: Output;
46
+ readonly errors: Errors;
47
+ readonly http: Http;
48
+ readonly mcp: ResolvedMcp<Name, Mcp>;
49
+ }
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>;
52
+ /** Receives decoded input; may fail only with the declared errors. */
53
+ export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
54
+ /** An empty object schema that also produces the object root MCP requires. */
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>;
61
+ //#endregion
62
+ export { Action_d_exports };
package/dist/Action.mjs CHANGED
@@ -1,17 +1,20 @@
1
+ import { assertName } from "./internal/actions.mjs";
1
2
  import { Schema } from "effect";
2
3
  //#region src/Action.ts
3
4
  /** An empty object schema that also produces the object root MCP requires. */
4
5
  const NoInput = Schema.Record(Schema.String, Schema.Never);
5
6
  function make(name, options) {
6
- if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) throw new Error(`Invalid action name: ${name}`);
7
- if (options.http === false && options.mcp === false) throw new Error(`Action ${name} is exposed on no transport`);
7
+ assertName("action name", name);
8
8
  const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? false;
9
9
  const mcp = options.mcp === false ? false : {
10
10
  name: options.mcp?.name ?? name,
11
11
  readOnly,
12
- destructive: options.mcp?.destructive ?? !readOnly
12
+ destructive: options.mcp?.destructive ?? !readOnly,
13
+ idempotent: options.mcp?.idempotent ?? false,
14
+ openWorld: options.mcp?.openWorld ?? true
13
15
  };
14
- 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);
15
18
  return {
16
19
  name,
17
20
  description: options.description,
@@ -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 };
@@ -0,0 +1,27 @@
1
+ import { Action_d_exports, Any, Handler } from "./Action.mjs";
2
+ import { Actions } from "./internal/actions.mjs";
3
+ import { HandlerContext, Handlers, HandlersContext, Implementation } from "./internal/implementation.mjs";
4
+ import { Options } from "./internal/cli.mjs";
5
+ import { Scope } from "effect";
6
+ import { Command } from "effect/unstable/cli";
7
+ //#region src/ActionCli.d.ts
8
+ /** Configuration for an aggregate group command. */
9
+ export interface GroupOptions {
10
+ /** Override the group command name. */
11
+ readonly name?: string;
12
+ }
13
+ type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Extract<G["actions"][number], {
14
+ readonly name: Name;
15
+ }>;
16
+ type BoundHandler<Name extends string, A extends Any, R> = Readonly<Record<Name, Handler<A, R>>>;
17
+ /**
18
+ * Project one explicitly selected local action into a native Effect CLI command.
19
+ * The action name resolves against the bound group; no HTTP fallback exists.
20
+ */
21
+ export declare const command: <G extends Actions, Name extends G["actions"][number]["name"], H extends BoundHandler<Name, Selected<G, Name>, HandlerContext<H, Name>>, EX, RX, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], Parameters>) => Command.Command<string, never, {}, EX | import("effect/Schema").SchemaError | Extract<G["actions"][number], {
22
+ readonly name: Name;
23
+ }>["errors"][number]["Type"], Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
24
+ /** Project every local action below its group namespace with default CLI options. */
25
+ export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX>(app: Implementation<G, H, EX, RX>, options?: GroupOptions) => Command.Command<string, {}, {}, EX | import("effect/Schema").SchemaError | G["actions"][number]["errors"][number]["Type"], Exclude<Exclude<RX, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<HandlersContext<H>, Scope.Scope>, Command.CommandContext<string>>>;
26
+ //#endregion
27
+ export type { Options };
@@ -0,0 +1,26 @@
1
+ import { dispatch } from "./internal/implementation.mjs";
2
+ import { command as command$1 } from "./internal/cli.mjs";
3
+ import { Effect } from "effect";
4
+ import { Command } from "effect/unstable/cli";
5
+ //#region src/ActionCli.ts
6
+ const select = (group, name) => {
7
+ const action = group.actions.find((candidate) => candidate.name === name);
8
+ if (action === void 0) throw new Error(`Unknown action "${group.name}.${name}"`);
9
+ return action;
10
+ };
11
+ const local = (app, action, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)));
12
+ /**
13
+ * Project one explicitly selected local action into a native Effect CLI command.
14
+ * The action name resolves against the bound group; no HTTP fallback exists.
15
+ */
16
+ const command = (app, name, options) => {
17
+ const action = select(app.group, name);
18
+ return command$1(action, (input) => local(app, action, input), options);
19
+ };
20
+ /** Project every local action below its group namespace with default CLI options. */
21
+ const group = (app, options) => {
22
+ const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)))));
23
+ return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
24
+ };
25
+ //#endregion
26
+ export { command, group };
@@ -0,0 +1,45 @@
1
+ import { Actions } from "./internal/actions.mjs";
2
+ import { Options as Options$1 } from "./internal/cli.mjs";
3
+ import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
4
+ import { Effect } from "effect";
5
+ import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
6
+ import { Command } from "effect/unstable/cli";
7
+ import { HttpClient } from "effect/unstable/http";
8
+ //#region src/ActionCliClient.d.ts
9
+ /** Parsing, rendering and native HTTP-endpoint configuration for one remote action. */
10
+ export type Options<Output, ParsedParameters extends Command.Command.Config = never> = Options$1<Output, ParsedParameters> & {
11
+ /** Passed directly to Effect's native `HttpApiClient.endpoint`. */
12
+ readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
13
+ };
14
+ /** Configuration for a remote aggregate group command. */
15
+ export interface GroupOptions {
16
+ /** Override the group command name. */
17
+ readonly name?: string;
18
+ /** Shared native HTTP-endpoint configuration for every remote action. */
19
+ readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
20
+ }
21
+ type Group<Groups extends ReadonlyArray<Actions>, Name extends Groups[number]["name"]> = Extract<Groups[number], {
22
+ readonly name: Name;
23
+ }>;
24
+ type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Extract<G["actions"][number], {
25
+ readonly name: Name;
26
+ }>;
27
+ type HttpNames<G extends Actions> = Extract<G["actions"][number], {
28
+ readonly http: true;
29
+ }>["name"];
30
+ type ApiGroups<Groups extends ReadonlyArray<Actions>> = Api<Groups[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
31
+ type Endpoint<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups>>>>, {
32
+ readonly identifier: ActionName;
33
+ }>;
34
+ type NativeMethod<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = HttpApiClient.Client.Method<Extract<Endpoint<Groups, GroupName, ActionName>, HttpApiEndpoint.ConstraintRequest>, never, never>;
35
+ type RemoteCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Command.Command<string, never, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>>;
36
+ type RemoteGroupCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"]> = Command.Command<string, {}, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>>;
37
+ /**
38
+ * Project one HTTP action retained by the binding into a native Effect CLI command.
39
+ * Group and action selectors resolve exclusively from `http.groups`; runtime guards
40
+ * keep dynamically supplied selector strings from reaching the native client.
41
+ */
42
+ export declare const command: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"], const ActionName extends HttpNames<Group<Groups, GroupName>>, ParsedParameters extends Command.Command.Config = never>(http: Http<Groups>, groupName: GroupName, actionName: ActionName, options?: Options<Selected<Group<Groups, GroupName>, ActionName>["success"]["Type"], ParsedParameters>) => RemoteCommand<Groups, GroupName, ActionName>;
43
+ /** Project all HTTP-enabled actions retained by one group below its group namespace. */
44
+ export declare const group: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"]>(http: Http<Groups>, groupName: GroupName, options?: GroupOptions) => RemoteGroupCommand<Groups, GroupName>;
45
+ //#endregion