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