@gjermundgaraba/effect-actions 0.1.0-rc.2 → 0.1.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 CHANGED
@@ -1,20 +1,10 @@
1
- # @gjermundgaraba/effect-actions
1
+ # effect-actions
2
2
 
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.
3
+ Define an Effect action once. Serve it over HTTP and MCP, hand it to a model as a native
4
+ Toolkit, run it from a CLI, and publish its contract as JSON. Same schemas, same handler,
5
+ Effect's own servers and clients underneath.
4
6
 
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
- ```
16
-
17
- ## Quickstart
7
+ ## Looks like this
18
8
 
19
9
  ```ts
20
10
  import { McpProtocol } from "effect/unstable/ai";
@@ -48,297 +38,69 @@ export const routes = Layer.mergeAll(
48
38
  );
49
39
  ```
50
40
 
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.
55
- See [examples/server.ts](examples/server.ts) for Node server wiring and the
56
- [authenticated demo](examples/README.md) for a runnable application.
57
-
58
- ### Contracts
59
-
60
- - Omit `input` for an action without arguments.
61
- - `errors` is a list of failure schemas, defaulting to none. Each schema retains
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).
67
- - Actions default to both transports; use `http: false` or `mcp: false` to opt out.
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.
77
- - Schemas must be service-free. Handlers may require services.
78
- - The group name is the OpenAPI tag and operation-ID prefix (`greetings.greet`).
79
-
80
- ## HTTP client
81
-
82
- Save the quickstart above as `quickstart.ts`. Once its routes are served:
83
-
84
- ```ts
85
- import { HttpApiClient } from "effect/unstable/httpapi";
86
- import { Effect } from "effect";
87
- import { FetchHttpClient } from "effect/unstable/http";
88
- import { Http } from "./quickstart.js";
89
-
90
- export const greeting = Effect.gen(function* () {
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" } });
94
- }).pipe(Effect.provide(FetchHttpClient.layer));
95
- ```
96
-
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:
41
+ Serve `routes` with Effect's `HttpRouter` and you have `POST /api/actions/greetings/greet`
42
+ and an MCP tool named `greet` at `/mcp`. The same `app` is also:
147
43
 
148
44
  ```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
- );
45
+ const { toolkit, layer } = ActionToolkit.make(app); // native Effect AI Toolkit and its handler layer
46
+ const cli = ActionCli.group(app); // greetings greet --input '{"name":"Ada"}'
47
+ const remote = ActionCliClient.command(Http, "greetings", "greet"); // same command, over HTTP
48
+ const catalog = ActionCatalog.make(Actions); // offline JSON contract, no handlers acquired
157
49
  ```
158
50
 
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:
51
+ And the client is Effect's own, typed from the same contract:
165
52
 
166
53
  ```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";
54
+ const greeting = Effect.gen(function* () {
55
+ const client = yield* HttpApiClient.make(Http.api, { baseUrl });
172
56
 
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
57
+ return yield* client.greetings.greet({ payload: { name: "Ada" } });
58
+ });
182
59
  ```
183
60
 
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.
61
+ ## Why
204
62
 
205
- ## Adapter options
63
+ - Input, success, and errors are declared once. Routes, tools, commands, clients, OpenAPI, and the catalog are derived from that declaration, so they cannot disagree.
64
+ - A handler may fail only with the errors its action declares. Shared failures such as `Forbidden` are declared once on the group.
65
+ - The pieces are Effect's own. `Http.api` is a native `HttpApi`, so `OpenApi.fromApi`, Swagger, Scalar, and `HttpApiClient` work on it unchanged. MCP is Effect's native `McpServer`, one `Tool` per action, with no SDK runtime dependency.
66
+ - Build-time services and per-request services are tracked separately in the types. Middleware is per group, so a public group and an authenticated group can share one mount path.
67
+ - Tests run in memory. Call the routes through a web handler with the same typed client, or drive the official MCP client, without opening a port.
206
68
 
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` |
214
-
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.
217
-
218
- See [adapter behavior](docs/behavior.md) for shared schema-error policies,
219
- dependency lifetimes, wire formats, and MCP protocol support.
220
-
221
- ## Scope
222
-
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.
227
-
228
- Authentication and authorization belong to the application. Tool discovery is
229
- not filtered by actor. The example's bearer tokens are **demo-only**; production
230
- needs real token validation, MCP authorization discovery, request limits, and
231
- error reporting.
232
-
233
- ## Development and packaging
234
-
235
- Requires a supported Node.js version (24 LTS recommended) and [Vite+](https://viteplus.dev).
69
+ ## Install
236
70
 
237
71
  ```sh
238
- vp install
239
- vp check
240
- vp test
241
- vp run test:package
242
- vp run example
72
+ pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.116
243
73
  ```
244
74
 
245
- `vp run dev` watches the library build. `vp check` also verifies the compile-time
246
- assertions in `tests/types.spec.ts`. Tests cover both transports, official MCP clients,
247
- request-context handling, schema-error policies, and cancellation.
75
+ Add `@effect/platform-node@4.0.0-rc.116` to serve from Node. Every module is a subpath import
76
+ (`.../Action`, `.../ActionHttp`, ...); there is no package root, so a contracts-only bundle
77
+ never loads a server.
248
78
 
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`.
79
+ ## Docs
256
80
 
257
- ## Authentication and OAuth discovery
81
+ The reference in [docs/](docs/README.md) is written for coding agents: one card per module
82
+ with the API, a canonical snippet, the rules, and the failure modes.
258
83
 
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).
84
+ - [docs/README.md](docs/README.md): start here, includes the adapter decision list.
85
+ - [docs/guarantees.md](docs/guarantees.md): cross-cutting rules for lifetimes, wire formats, and scope.
86
+ - [docs/CONTEXT.md](docs/CONTEXT.md): the vocabulary the docs and the code use.
87
+ - [examples/](examples/README.md): a runnable authenticated application with three groups, two MCP endpoints, OpenAPI, and every other projection.
266
88
 
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.
89
+ ## For agents
271
90
 
272
- Authentication dependencies are request requirements, as with native `HttpRouter.middleware`.
273
- Use `.combine(...)` with middleware that provides them. Acquired resources stay alive
274
- until the request scope closes. Downstream action errors are handled by their transport,
275
- not serialized as authentication failures.
276
-
277
- `Authentication.protectedResource({ resource, authorizationServers, scopesSupported })`
278
- returns `{ layer, metadataUrl, challenge }`. Mount its public `layer` separately from
279
- protected routes. It publishes [RFC 9728 metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
280
- at `/.well-known/oauth-protected-resource` followed by the resource path and query. Discovery
281
- matches that literal path and query for GET and HEAD; other requests reach the host router.
282
- Caching policy belongs to the host. `challenge()`
283
- returns the `WWW-Authenticate` value; pass `{ error: "invalid_token" }` or
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>
306
-
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
- );
91
+ ```sh
92
+ npx skills add gjermundgaraba/effect-actions --skill effect-actions
315
93
  ```
316
94
 
317
- ## Testing
95
+ The skill is generated from `docs/`, so pointing an agent at `docs/` (in this repository or
96
+ in `node_modules/@gjermundgaraba/effect-actions/docs`) gives the same content.
318
97
 
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)));
98
+ ## Status
325
99
 
326
- const greeting = Effect.gen(function* () {
327
- const client = yield* httpClient(Http.api, web.handler);
100
+ The `effect` peer is pinned to an exact Effect 4 release candidate and moves with it; expect a
101
+ release of this package for each Effect release candidate it adopts. Actions are unary JSON over HTTP and MCP: no streaming, uploads, prompts, or resources.
102
+ Authentication and authorization belong to the application. See [docs/setup.md](docs/setup.md).
328
103
 
329
- return yield* client.greetings.greet({ payload: { name: "Ada" } });
330
- });
331
- ```
104
+ ## Contributing
332
105
 
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
339
- and information; application metadata is preserved. The protocol version stays pinned to
340
- 2026-07-28 in both the request header and metadata. Metadata is merged shallowly.
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" } }`.
106
+ See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
package/docs/Action.md ADDED
@@ -0,0 +1,117 @@
1
+ # Action
2
+
3
+ One contract: a name, schemas for input, success and declared errors, and per-transport
4
+ metadata. An action holds no behavior; handlers are bound by `ActionGroup.implement`.
5
+
6
+ ## API
7
+
8
+ ```ts
9
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
10
+
11
+ /** Any service-free schema. */
12
+ type Codec = Schema.Codec<unknown, unknown, never, never>;
13
+
14
+ function make<Name, Input = NoInput, Output, Errors = [], Http = true, Mcp = undefined>(
15
+ name: Name,
16
+ options: Options<Input, Output, Errors, Http, Mcp>,
17
+ ): Action<Name, Input, Output, Errors, Http, Mcp>;
18
+
19
+ interface Options<Input, Output, Errors, Http, Mcp> {
20
+ readonly description: string;
21
+ readonly input?: Input; // omit for an action without arguments
22
+ readonly success: Output;
23
+ readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
24
+ readonly http?: Http; // false hides the action from HTTP routes and clients
25
+ readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
26
+ }
27
+
28
+ interface McpOptions {
29
+ readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
30
+ readonly readOnly?: boolean; // readOnlyHint, default false
31
+ readonly destructive?: boolean; // destructiveHint, default !readOnly
32
+ readonly idempotent?: boolean; // idempotentHint, default false
33
+ readonly openWorld?: boolean; // openWorldHint, default true
34
+ }
35
+
36
+ interface Action<Name, Input, Output, Errors, Http, Mcp> {
37
+ readonly name: Name;
38
+ readonly description: string;
39
+ readonly input: Input;
40
+ readonly success: Output;
41
+ readonly errors: Errors;
42
+ readonly http: Http;
43
+ readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
44
+ }
45
+
46
+ /** Receives decoded input; may fail only with the declared errors. */
47
+ type Handler<A extends Any, R = never> = (
48
+ input: A["input"]["Type"],
49
+ ) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
50
+
51
+ type Any; // any action with its schemas erased
52
+ ```
53
+
54
+ ## Canonical
55
+
56
+ ```ts
57
+ import { Schema } from "effect";
58
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
59
+
60
+ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
61
+ "UserNotFound",
62
+ { id: Schema.String },
63
+ { httpApiStatus: 404 },
64
+ ) {}
65
+
66
+ export const GetUser = Action.make("getUser", {
67
+ description: "Look up a user in your tenant.",
68
+ input: Schema.Struct({ id: Schema.String }),
69
+ success: Schema.Struct({ id: Schema.String, name: Schema.String }),
70
+ errors: [UserNotFound],
71
+ mcp: { name: "get_user", readOnly: true },
72
+ });
73
+
74
+ // No arguments: omit `input`. Identity comes from request context, never from input.
75
+ export const WhoAmI = Action.make("whoAmI", {
76
+ description: "Inspect the authenticated actor.",
77
+ success: Schema.Struct({ id: Schema.String, tenantId: Schema.String }),
78
+ mcp: { readOnly: true },
79
+ });
80
+
81
+ // MCP-only: no HTTP route, no client method.
82
+ export const ListChanges = Action.make("listChanges", {
83
+ description: "List renames in your tenant, oldest first.",
84
+ success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
85
+ http: false,
86
+ mcp: { name: "list_changes", readOnly: true },
87
+ });
88
+
89
+ // Input decoding is the schema's: "21" on the wire, 21 in the handler.
90
+ export const Double = Action.make("double", {
91
+ description: "Double a finite number supplied as a string.",
92
+ input: Schema.Struct({ value: Schema.FiniteFromString }),
93
+ success: Schema.Finite,
94
+ mcp: { readOnly: true },
95
+ });
96
+ ```
97
+
98
+ ## Rules
99
+
100
+ - Names match `[A-Za-z0-9_-]+`. `then` is rejected: it would make a client thenable.
101
+ - Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
102
+ - `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
103
+ - A handler may fail only with the declared errors. Anything else is a defect.
104
+ - Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
105
+ - MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
106
+ - `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
107
+ - Hint defaults: `readOnly: false`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
108
+ - Schemas must be service-free. Put service access in the handler.
109
+ - The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
110
+ - Object schemas strip excess fields on decode unless the schema is configured to reject them.
111
+
112
+ ## Failure modes
113
+
114
+ - Throws at `make`: invalid name, name `then`, `mcp.name` longer than 128 characters or not matching the pattern.
115
+ - Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
116
+ - `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
117
+ - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
@@ -0,0 +1,56 @@
1
+ # ActionCatalog
2
+
3
+ An offline JSON document describing groups: identities, metadata, and standalone JSON
4
+ schemas. Built from contracts alone. No implementation, service, or server is involved.
5
+
6
+ ## API
7
+
8
+ ```ts
9
+ import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
10
+
11
+ const make: (...groups: ReadonlyArray<ActionGroup.Any>) => Catalog;
12
+
13
+ interface Catalog {
14
+ readonly version: "1";
15
+ readonly actions: ReadonlyArray<Entry>;
16
+ }
17
+
18
+ interface Entry {
19
+ readonly id: string; // "<group>.<action>"
20
+ readonly group: string;
21
+ readonly name: string;
22
+ readonly description: string;
23
+ readonly http: boolean;
24
+ readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
25
+ readonly input: JsonSchema.JsonSchema;
26
+ readonly success: JsonSchema.JsonSchema;
27
+ readonly errors: ReadonlyArray<JsonSchema.JsonSchema>; // own + inherited group errors
28
+ readonly httpSchemaErrors: ReadonlyArray<JsonSchema.JsonSchema>; // policy errors, HTTP only
29
+ }
30
+ ```
31
+
32
+ ## Canonical
33
+
34
+ ```ts
35
+ import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
36
+ import { AuditActions, PublicActions, UserActions } from "./contracts.js";
37
+
38
+ const catalog = ActionCatalog.make(PublicActions, UserActions, AuditActions);
39
+
40
+ process.stdout.write(JSON.stringify(catalog, null, 2));
41
+ ```
42
+
43
+ ## Rules
44
+
45
+ - Takes groups, not implementations. Nothing is acquired or started.
46
+ - `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
47
+ - Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
48
+ - Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
49
+ - `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
50
+ - Local-only actions (`http: false`, `mcp: false`) are included. Presence in a catalog is descriptive. It is not authorization, tool publication, or proof that a route is mounted. The host decides what to publish.
51
+ - The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
52
+
53
+ ## Failure modes
54
+
55
+ - Type error passing an implementation: `make` takes groups. Pass `app.group` or the group value itself.
56
+ - Consumers see `{ value }` in MCP responses but not in the catalog: expected. The catalog describes action values; MCP wraps them.
@@ -0,0 +1,91 @@
1
+ # ActionCli
2
+
3
+ Native Effect CLI commands that run handlers in-process. The host provides every service;
4
+ nothing is fetched over HTTP.
5
+
6
+ ## API
7
+
8
+ ```ts
9
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
10
+
11
+ /** One explicitly selected action. */
12
+ const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, Parameters = never>(
13
+ app: Implementation<G, H, EX, RX>,
14
+ name: Name,
15
+ options?: Options<SuccessType, Parameters>,
16
+ ) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | HandlerContext, Scope>>;
17
+
18
+ /** Every action of the group, including local-only ones, under the group name. */
19
+ const group: <G, H, EX, RX>(
20
+ app: Implementation<G, H, EX, RX>,
21
+ options?: GroupOptions,
22
+ ) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
23
+
24
+ interface GroupOptions {
25
+ readonly name?: string; // override the group command name
26
+ }
27
+
28
+ type Options<Output, Parameters extends Command.Command.Config = never> =
29
+ | JsonOptions<Output>
30
+ | ParametersOptions<Output, Parameters>;
31
+
32
+ interface JsonOptions<Output> {
33
+ readonly name?: string; // override the command name; default is the action name
34
+ readonly render?: (output: Output) => string; // human output; --json still prints JSON
35
+ }
36
+
37
+ interface ParametersOptions<Output, Parameters> {
38
+ readonly name?: string;
39
+ readonly render?: (output: Output) => string;
40
+ readonly parameters: Parameters; // native Effect Flag / Argument config
41
+ readonly input: (parsed: Command.Command.Config.Infer<Parameters>) => Schema.Json; // encoded action input
42
+ }
43
+ ```
44
+
45
+ ## Canonical
46
+
47
+ ```ts
48
+ import { Console, Effect, Logger } from "effect";
49
+ import { Command, Flag } from "effect/unstable/cli";
50
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
51
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
52
+ import { UserApp } from "./handlers.js";
53
+ import { Users } from "./users.js";
54
+
55
+ // Explicit syntax: `double --value 21`. No `--input` on this command.
56
+ const command = ActionCli.command(UserApp, "double", {
57
+ parameters: { value: Flag.String("value") },
58
+ input: ({ value }) => ({ value }),
59
+ });
60
+
61
+ // Default syntax for a whole group: `users double --input '{"value":"21"}'`.
62
+ const all = ActionCli.group(UserApp);
63
+
64
+ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
65
+ Effect.tapCause((cause) => Console.error(cause)),
66
+ Effect.provideService(Logger.LogToStderr, true),
67
+ Effect.provide(Users.layerMemory),
68
+ Effect.provide(NodeServices.layer),
69
+ NodeRuntime.runMain({ disableErrorReporting: true }),
70
+ );
71
+ ```
72
+
73
+ ## Rules
74
+
75
+ - `command(app, name)` selects one action by name from the bound group; the name is checked at the type level. `group(app)` puts every action under the group command, including actions with `http: false` and `mcp: false`.
76
+ - Default syntax is `--input '<json>'` carrying the entire encoded input: nested objects, arrays, scalars. Omitting `--input` supplies `{}`, which must still satisfy the input schema.
77
+ - `parameters` and `input` are supplied together or not at all. With them, the command has explicit native flags and arguments and no `--input`, so its syntax does not change when the schema changes.
78
+ - `input(parsed)` returns encoded JSON. The action schema decodes it before dispatch; a mismatch is a `SchemaError` at runtime. Action fields are never turned into flags automatically.
79
+ - Native `Flag` and `Argument` own names, aliases, ordering, defaults, and optionality. Use `Flag.optional` when omission must stay distinct from a default, and map the `Option` to a present or omitted field.
80
+ - Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output; `--json` selects JSON again. Rendering cannot bypass validation.
81
+ - Explicit flag names must not collide with the renderer's `--json` flag. A payload field named `json` is ordinary data; only the flag name is reserved.
82
+ - Each invocation acquires the implementation in a scope and releases it after the call. Domain services and authority come from the host's provided layers. There is no HTTP fallback.
83
+ - Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
84
+
85
+ ## Failure modes
86
+
87
+ - `SchemaError` on a valid-looking `--input`: the JSON is decoded input, so transforming codecs expect the encoded form (`"21"` for `FiniteFromString`, not `21`).
88
+ - Command rejects `--input`: the command was built with `parameters`. Use its flags.
89
+ - Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
90
+ - Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
91
+ - `--json` flag conflict at definition: rename the native flag; the config property can keep its name.