@gjermundgaraba/effect-actions 0.1.0-rc.2 → 0.2.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 +47 -281
- package/dist/ActionHttp.mjs +41 -28
- package/dist/ActionMcp.d.mts +5 -3
- package/dist/internal/tools.mjs +3 -2
- package/docs/Action.md +117 -0
- package/docs/ActionCatalog.md +56 -0
- package/docs/ActionCli.md +91 -0
- package/docs/ActionCliClient.md +77 -0
- package/docs/ActionGroup.md +141 -0
- package/docs/ActionHttp.md +106 -0
- package/docs/ActionMcp.md +132 -0
- package/docs/ActionToolkit.md +73 -0
- package/docs/Authentication.md +114 -0
- package/docs/CONTEXT.md +36 -0
- package/docs/README.md +85 -0
- package/docs/Testing.md +91 -0
- package/docs/guarantees.md +55 -0
- package/docs/setup.md +63 -0
- package/package.json +11 -10
package/README.md
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# effect-actions
|
|
2
2
|
|
|
3
|
-
Define an Effect action once
|
|
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
|
-
##
|
|
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,73 @@ export const routes = Layer.mergeAll(
|
|
|
48
38
|
);
|
|
49
39
|
```
|
|
50
40
|
|
|
51
|
-
Serve `routes` with Effect's `HttpRouter
|
|
52
|
-
MCP
|
|
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:
|
|
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:
|
|
83
43
|
|
|
84
44
|
```ts
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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));
|
|
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
|
|
95
49
|
```
|
|
96
50
|
|
|
97
|
-
|
|
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:
|
|
51
|
+
And the client is Effect's own, typed from the same contract:
|
|
147
52
|
|
|
148
53
|
```ts
|
|
149
|
-
|
|
150
|
-
|
|
54
|
+
const greeting = Effect.gen(function* () {
|
|
55
|
+
const client = yield* HttpApiClient.make(Http.api, { baseUrl });
|
|
151
56
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
HttpApiSwagger.layer(Http.api, { path: "/docs" }),
|
|
155
|
-
HttpApiScalar.layer(Http.api, { path: "/reference" }),
|
|
156
|
-
);
|
|
57
|
+
return yield* client.greetings.greet({ payload: { name: "Ada" } });
|
|
58
|
+
});
|
|
157
59
|
```
|
|
158
60
|
|
|
159
|
-
|
|
160
|
-
value gives Effect's grouped client: `HttpApiClient.make(Http.api)`.
|
|
161
|
-
|
|
162
|
-
## More projections
|
|
61
|
+
## Why
|
|
163
62
|
|
|
164
|
-
|
|
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.
|
|
165
68
|
|
|
166
|
-
|
|
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";
|
|
69
|
+
## Install
|
|
172
70
|
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
71
|
+
```sh
|
|
72
|
+
pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.116
|
|
182
73
|
```
|
|
183
74
|
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
205
|
-
## Adapter options
|
|
206
|
-
|
|
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` |
|
|
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.
|
|
214
78
|
|
|
215
|
-
|
|
216
|
-
Effect owns protocol negotiation and sessions.
|
|
79
|
+
## Docs
|
|
217
80
|
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
220
83
|
|
|
221
|
-
|
|
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.
|
|
222
88
|
|
|
223
|
-
|
|
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).
|
|
89
|
+
## For agents
|
|
236
90
|
|
|
237
91
|
```sh
|
|
238
|
-
|
|
239
|
-
vp check
|
|
240
|
-
vp test
|
|
241
|
-
vp run test:package
|
|
242
|
-
vp run example
|
|
92
|
+
npx skills add gjermundgaraba/effect-actions --skill effect-actions
|
|
243
93
|
```
|
|
244
94
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
request-context handling, schema-error policies, and cancellation.
|
|
248
|
-
|
|
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`.
|
|
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.
|
|
256
97
|
|
|
257
|
-
##
|
|
98
|
+
## Status
|
|
258
99
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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).
|
|
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).
|
|
266
103
|
|
|
267
|
-
|
|
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.
|
|
104
|
+
## Acknowledgements
|
|
271
105
|
|
|
272
|
-
|
|
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
|
-
});
|
|
106
|
+
A few idea (like the native Toolkit, CLI, and catalog projections) are inspired by the excellent [rat-stack](https://github.com/joelhooks/rat-stack) by Joel Hooks.
|
|
295
107
|
|
|
296
|
-
|
|
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
|
-
);
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
## Testing
|
|
318
|
-
|
|
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
|
-
```
|
|
108
|
+
## Contributing
|
|
332
109
|
|
|
333
|
-
|
|
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" } }`.
|
|
110
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
|
package/dist/ActionHttp.mjs
CHANGED
|
@@ -7,23 +7,35 @@ const httpActions = (group) => group.actions.filter((action) => action.http);
|
|
|
7
7
|
const endpoint = (apiPath, group, action) => HttpApiEndpoint.post(action.name, `${apiPath}/${group.name}/${action.name}`, {
|
|
8
8
|
payload: action.input,
|
|
9
9
|
success: action.success,
|
|
10
|
-
error:
|
|
10
|
+
error: action.errors
|
|
11
11
|
}).annotate(OpenApi.Description, action.description);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return first === void 0 ? [] : [HttpApiGroup.make(group.name).add(first, ...rest)];
|
|
16
|
-
});
|
|
17
|
-
const empty = HttpApi.make("actions");
|
|
18
|
-
return first === void 0 ? empty : empty.add(first, ...rest);
|
|
19
|
-
}
|
|
20
|
-
const schemaErrorMiddleware = (policy) => {
|
|
21
|
-
class SchemaErrors extends HttpApiMiddleware.Service()("effect-actions/http/SchemaErrors", { error: policy.errors }) {}
|
|
12
|
+
/** The native middleware declares the policy's errors on every endpoint it wraps. */
|
|
13
|
+
const schemaErrorMiddleware = (name, policy) => {
|
|
14
|
+
class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policy.errors }) {}
|
|
22
15
|
return {
|
|
23
16
|
SchemaErrors,
|
|
24
17
|
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy.map(failure)))
|
|
25
18
|
};
|
|
26
19
|
};
|
|
20
|
+
function bind(options, group) {
|
|
21
|
+
const [first, ...rest] = httpActions(group).map((action) => endpoint(options.apiPath, group, action));
|
|
22
|
+
if (first === void 0) return void 0;
|
|
23
|
+
const native = HttpApiGroup.make(group.name).add(first, ...rest);
|
|
24
|
+
if (group.schemaError === void 0) return {
|
|
25
|
+
native,
|
|
26
|
+
policy: Layer.empty
|
|
27
|
+
};
|
|
28
|
+
const policy = schemaErrorMiddleware(group.name, group.schemaError);
|
|
29
|
+
return {
|
|
30
|
+
native: native.middleware(policy.SchemaErrors),
|
|
31
|
+
policy: policy.layer
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function apiOf(groups) {
|
|
35
|
+
const [first, ...rest] = groups;
|
|
36
|
+
const empty = HttpApi.make("actions").annotate(HttpApi.ParseOptions, { errors: "all" });
|
|
37
|
+
return first === void 0 ? empty : empty.add(first, ...rest);
|
|
38
|
+
}
|
|
27
39
|
/**
|
|
28
40
|
* `HttpApiBuilder.handleAll` registers a dynamically assembled record. The
|
|
29
41
|
* action selected by the native router determines the input before `dispatch`
|
|
@@ -34,32 +46,33 @@ const schemaErrorMiddleware = (policy) => {
|
|
|
34
46
|
const erasedHandlers = (handlers) => {
|
|
35
47
|
return handlers;
|
|
36
48
|
};
|
|
37
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const api = erasedApi([app.group], options);
|
|
42
|
-
const entries = (record) => Object.fromEntries(actions.map((action) => [action.name, (request) => dispatch(app.group, action, record)(request.payload)]));
|
|
43
|
-
const handlers = (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(record)));
|
|
44
|
-
if (app.group.schemaError === void 0) return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.layer(api).pipe(Layer.provide(handlers(erasedHandlers(record))))));
|
|
45
|
-
const schemaErrors = schemaErrorMiddleware(app.group.schemaError);
|
|
46
|
-
const policyApi = api.middleware(schemaErrors.SchemaErrors);
|
|
47
|
-
const policyHandlers = (record) => HttpApiBuilder.group(policyApi, app.group.name, (builder) => {
|
|
48
|
-
const dynamicEntries = entries(record);
|
|
49
|
-
return builder.handleAll(dynamicEntries);
|
|
50
|
-
});
|
|
51
|
-
return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.layer(policyApi).pipe(Layer.provide(policyHandlers(erasedHandlers(record))), Layer.provide(schemaErrors.layer))));
|
|
49
|
+
/** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
|
|
50
|
+
const groupHandlers = (api, app, policy) => {
|
|
51
|
+
const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record)(request.payload)]));
|
|
52
|
+
return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(policy));
|
|
52
53
|
};
|
|
53
54
|
function make(options, ...groups) {
|
|
54
55
|
assertDistinct("action group", groups.map((group) => group.name));
|
|
56
|
+
const bound = new Map(groups.flatMap((group) => {
|
|
57
|
+
const binding = bind(options, group);
|
|
58
|
+
return binding === void 0 ? [] : [[group, binding]];
|
|
59
|
+
}));
|
|
55
60
|
const layer = (...apps) => {
|
|
56
61
|
assertDistinct("implementation group", apps.map((app) => app.group.name));
|
|
57
62
|
for (const app of apps) if (!groups.includes(app.group)) throw new Error(`Implementation of group "${app.group.name}" is not served by this adapter`);
|
|
58
|
-
|
|
63
|
+
const served = apps.flatMap((app) => {
|
|
64
|
+
const binding = bound.get(app.group);
|
|
65
|
+
return binding === void 0 ? [] : [{
|
|
66
|
+
app,
|
|
67
|
+
...binding
|
|
68
|
+
}];
|
|
69
|
+
});
|
|
70
|
+
const api = apiOf(served.map(({ native }) => native));
|
|
71
|
+
return served.reduce((layer, { app, policy }) => layer.pipe(Layer.provide(groupHandlers(api, app, policy))), HttpApiBuilder.layer(api));
|
|
59
72
|
};
|
|
60
73
|
return {
|
|
61
74
|
groups,
|
|
62
|
-
api:
|
|
75
|
+
api: apiOf([...bound.values()].map(({ native }) => native)),
|
|
63
76
|
layer
|
|
64
77
|
};
|
|
65
78
|
}
|
package/dist/ActionMcp.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
|
|
2
2
|
import { Cause, Layer } from "effect";
|
|
3
|
-
import { McpServer } from "effect/unstable/ai";
|
|
3
|
+
import { McpSchema, McpServer } from "effect/unstable/ai";
|
|
4
4
|
import { HttpRouter } from "effect/unstable/http";
|
|
5
5
|
import { Stdio } from "effect/Stdio";
|
|
6
6
|
//#region src/ActionMcp.d.ts
|
|
@@ -24,18 +24,20 @@ export interface StdioOptions {
|
|
|
24
24
|
readonly protocols: Parameters<typeof McpServer.layerStdio>[0]["protocols"];
|
|
25
25
|
readonly instructions?: string;
|
|
26
26
|
}
|
|
27
|
+
/** The native server supplies its own request context to every tool call. */
|
|
28
|
+
type ToolRequestContext<App> = Exclude<RequestContext<App>, McpSchema.McpRequestContext>;
|
|
27
29
|
/**
|
|
28
30
|
* Serve MCP tools over one Streamable HTTP endpoint.
|
|
29
31
|
*
|
|
30
32
|
* Middleware provided around this layer has the normal HTTP lifetime. Native
|
|
31
33
|
* context capture applies: never provide request-identity tags at startup.
|
|
32
34
|
*/
|
|
33
|
-
export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(options: Options, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires",
|
|
35
|
+
export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(options: Options, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ToolRequestContext<Apps[number]>>>;
|
|
34
36
|
/**
|
|
35
37
|
* Serve MCP tools through newline-delimited JSON-RPC on standard I/O.
|
|
36
38
|
*
|
|
37
39
|
* The host supplies the `Stdio` service. Arguments are tool input only and
|
|
38
40
|
* never establish request identity or authority.
|
|
39
41
|
*/
|
|
40
|
-
export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(options: StdioOptions, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio |
|
|
42
|
+
export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(options: StdioOptions, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio | ToolRequestContext<Apps[number]>>;
|
|
41
43
|
//#endregion
|
package/dist/internal/tools.mjs
CHANGED
|
@@ -55,8 +55,9 @@ const assertMcpObjectInput = (action, tool) => {
|
|
|
55
55
|
};
|
|
56
56
|
const project = (projection, entry) => {
|
|
57
57
|
const tool = projection === "native" ? nativeTool(entry.action) : mcpTool(entry.action);
|
|
58
|
-
if (projection === "
|
|
59
|
-
|
|
58
|
+
if (projection === "native") return tool;
|
|
59
|
+
assertMcpObjectInput(entry.action, tool);
|
|
60
|
+
return tool.annotate(Tool.Strict, true);
|
|
60
61
|
};
|
|
61
62
|
const handler = (projection, app, action, handlers) => {
|
|
62
63
|
const run = (input) => dispatch(app.group, action, handlers)(input);
|
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
|
+
- HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP cannot be strict natively: `HttpApi.ParseOptions` also governs error encoding, where `onExcessProperty: "error"` rejects an error's own `stack`.
|
|
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`.
|