@gjermundgaraba/effect-actions 0.1.0-rc.1 → 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 +50 -219
- package/dist/Action.d.mts +62 -2
- package/dist/Action.mjs +7 -4
- 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 +15 -14
- package/dist/ActionGroup.mjs +3 -2
- package/dist/ActionHttp.d.mts +21 -34
- package/dist/ActionHttp.mjs +44 -51
- package/dist/ActionMcp.d.mts +28 -7
- package/dist/ActionMcp.mjs +20 -128
- package/dist/ActionToolkit.d.mts +37 -0
- package/dist/ActionToolkit.mjs +10 -0
- package/dist/Authentication.d.mts +11 -12
- package/dist/Authentication.mjs +24 -57
- package/dist/Testing.d.mts +7 -4
- package/dist/Testing.mjs +4 -2
- package/dist/TestingClient.d.mts +6 -3
- package/dist/TestingClient.mjs +2 -2
- package/dist/_virtual/_rolldown/runtime.mjs +13 -0
- 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/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 +131 -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 +19 -14
- package/dist/Action-CXsfTEhx.d.mts +0 -54
- package/dist/implementation-4Q2BESw5.mjs +0 -61
- package/dist/implementation-DTqp_0DW.d.mts +0 -61
package/README.md
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
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
|
-
[`9ad9891`](https://pkg.pr.new/Effect-TS/effect/effect@9ad9891). The published npm
|
|
7
|
-
version `effect@4.0.0-rc.115` is not compatible: it lacks APIs required by the MCP
|
|
8
|
-
adapter. Install both packages explicitly:
|
|
9
|
-
|
|
10
|
-
```sh
|
|
11
|
-
pnpm add @gjermundgaraba/effect-actions@rc \
|
|
12
|
-
'effect@https://pkg.pr.new/Effect-TS/effect/effect@9ad9891'
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
The snapshot reports version `4.0.0-rc.115`, so the package retains that nominal
|
|
16
|
-
peer declaration. A semver peer cannot distinguish the snapshot from the npm
|
|
17
|
-
build; satisfying the peer declaration alone does **not** ensure compatibility.
|
|
18
|
-
Installation requires access to `pkg.pr.new`, not just the npm registry.
|
|
19
|
-
|
|
20
|
-
For Node server wiring, use the matching platform snapshot too:
|
|
21
|
-
|
|
22
|
-
```sh
|
|
23
|
-
pnpm add '@effect/platform-node@https://pkg.pr.new/Effect-TS/effect/@effect/platform-node@9ad9891'
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Quickstart
|
|
7
|
+
## Looks like this
|
|
27
8
|
|
|
28
9
|
```ts
|
|
10
|
+
import { McpProtocol } from "effect/unstable/ai";
|
|
29
11
|
import { Effect, Layer, Schema } from "effect";
|
|
30
12
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
31
13
|
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
@@ -49,227 +31,76 @@ const app = Actions.implement({
|
|
|
49
31
|
|
|
50
32
|
export const routes = Layer.mergeAll(
|
|
51
33
|
Http.layer(app),
|
|
52
|
-
ActionMcp.
|
|
34
|
+
ActionMcp.layerHttp(
|
|
35
|
+
{ protocols: [McpProtocol.v2026_07_28], name: "greetings", version: "1.0.0", path: "/mcp" },
|
|
36
|
+
app,
|
|
37
|
+
),
|
|
53
38
|
);
|
|
54
39
|
```
|
|
55
40
|
|
|
56
|
-
Serve `routes` with Effect's `HttpRouter
|
|
57
|
-
MCP
|
|
58
|
-
`structuredContent: { value: "Hello, Ada!" }`.
|
|
59
|
-
See [examples/server.ts](examples/server.ts) for Node server wiring and the
|
|
60
|
-
[authenticated demo](examples/README.md) for a runnable application.
|
|
61
|
-
|
|
62
|
-
### Contracts
|
|
63
|
-
|
|
64
|
-
- Omit `input` for an action without arguments.
|
|
65
|
-
- `errors` is a list of failure schemas, defaulting to none. Each schema retains
|
|
66
|
-
its own `httpApiStatus` annotation.
|
|
67
|
-
- A group's `errors` are added to every one of its actions, so a shared set, such as
|
|
68
|
-
authorization failures, is declared once. Its `schemaError` policy decides how both
|
|
69
|
-
transports answer failed decoding or encoding; see
|
|
70
|
-
[schema-error policies](docs/behavior.md#schema-error-policies).
|
|
71
|
-
- Actions default to both transports; use `http: false` or `mcp: false` to opt out.
|
|
72
|
-
An action exposed on neither is rejected.
|
|
73
|
-
- MCP input must have an object-root JSON Schema; declared errors must encode to
|
|
74
|
-
objects. The MCP adapter checks this at Layer construction. HTTP allows scalars.
|
|
75
|
-
- `mcp.name` overrides the tool name. `destructive` defaults to `!readOnly`.
|
|
76
|
-
- Schemas must be service-free. Handlers may require services.
|
|
77
|
-
- The group name is the OpenAPI tag and operation-ID prefix (`greetings.greet`).
|
|
78
|
-
|
|
79
|
-
## HTTP client
|
|
80
|
-
|
|
81
|
-
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:
|
|
82
43
|
|
|
83
44
|
```ts
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
export const greeting = Effect.gen(function* () {
|
|
89
|
-
const client = yield* Http.client({ baseUrl: "http://127.0.0.1:3000" });
|
|
90
|
-
|
|
91
|
-
return yield* client.greet({ name: "Ada" });
|
|
92
|
-
}).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
|
|
93
49
|
```
|
|
94
50
|
|
|
95
|
-
|
|
96
|
-
decoded results; MCP-only actions are excluded. `ActionHttp.make` binds the mount path the
|
|
97
|
-
server and its clients must agree on, so `client` accepts only connection options. Authentication headers can be added with
|
|
98
|
-
`transformClient`. See [client details](docs/behavior.md#http-client-details)
|
|
99
|
-
for error types, optional inputs, and native grouped clients.
|
|
100
|
-
|
|
101
|
-
## Multiple groups
|
|
102
|
-
|
|
103
|
-
An application can split its contracts and handlers into groups without splitting its
|
|
104
|
-
endpoints. Configuration comes first, then the groups or implementations:
|
|
51
|
+
And the client is Effect's own, typed from the same contract:
|
|
105
52
|
|
|
106
53
|
```ts
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
const routes = Layer.mergeAll(
|
|
110
|
-
Http.layer(PublicApp),
|
|
111
|
-
Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
|
|
112
|
-
ActionMcp.layer({ name: "my-app", version: "1.0.0", path: "/mcp" }, UserApp, AuditApp),
|
|
113
|
-
);
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
- **One layer per group.** `Http.layer(app)` registers the routes of one group, and router
|
|
117
|
-
middleware provided to a layer applies to that layer alone: above, only the user group
|
|
118
|
-
requires authentication. A group that is never mounted has no routes.
|
|
119
|
-
- **Each adapter reads only what it serves.** A group without HTTP actions registers nothing
|
|
120
|
-
and is not built by `Http.layer`, nor one without tools by `ActionMcp.layer`.
|
|
121
|
-
- **Names.** Routes and client methods are flat (`POST /api/actions/<action>`,
|
|
122
|
-
`client.<action>()`), so group names and HTTP action names must be unique within one
|
|
123
|
-
`ActionHttp.make`, and tool names within one `ActionMcp.layer`. Duplicates throw at
|
|
124
|
-
construction; neither adapter looks at the other's names.
|
|
125
|
-
- **MCP middleware is per endpoint.** An MCP endpoint is one route, so middleware provided
|
|
126
|
-
to `ActionMcp.layer`, authentication included, covers all of its tools. Handlers can still
|
|
127
|
-
authorize each tool differently. Only tools that need different middleware, such as none
|
|
128
|
-
at all, need their own endpoint: one `ActionMcp.layer` per `path`.
|
|
129
|
-
|
|
130
|
-
Each group is its own native `HttpApiGroup`, so a host can also combine separately made
|
|
131
|
-
`Http.api` values with `HttpApi.addHttpApi`. See [examples/app.ts](examples/app.ts).
|
|
132
|
-
|
|
133
|
-
## Serving the document
|
|
134
|
-
|
|
135
|
-
`Http.api` is a native `HttpApi`, carrying every route, description, declared error and
|
|
136
|
-
policy error. Documents and documentation UIs are therefore Effect's own:
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
140
|
-
import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
|
|
54
|
+
const greeting = Effect.gen(function* () {
|
|
55
|
+
const client = yield* HttpApiClient.make(Http.api, { baseUrl });
|
|
141
56
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
HttpApiSwagger.layer(Http.api, { path: "/docs" }),
|
|
145
|
-
HttpApiScalar.layer(Http.api, { path: "/reference" }),
|
|
146
|
-
);
|
|
57
|
+
return yield* client.greetings.greet({ payload: { name: "Ada" } });
|
|
58
|
+
});
|
|
147
59
|
```
|
|
148
60
|
|
|
149
|
-
|
|
150
|
-
value gives Effect's grouped client: `HttpApiClient.make(Http.api)`.
|
|
151
|
-
|
|
152
|
-
## Adapter options
|
|
153
|
-
|
|
154
|
-
| API | Options |
|
|
155
|
-
| ------------------------------------- | ------------------------------------------------------------------------ |
|
|
156
|
-
| `ActionGroup.make(options, …actions)` | `name`, `errors`, `schemaError` |
|
|
157
|
-
| `ActionHttp.make(options, …groups)` | `apiPath` |
|
|
158
|
-
| `Http.layer(app)` | None |
|
|
159
|
-
| `Http.client(options?)` | `baseUrl`, `transformClient`, `transformResponse` |
|
|
160
|
-
| `ActionMcp.layer(options, …apps)` | `name`, `version`, `path`, `protocols`, `allowedOrigins`, `instructions` |
|
|
61
|
+
## Why
|
|
161
62
|
|
|
162
|
-
|
|
163
|
-
`
|
|
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.
|
|
164
68
|
|
|
165
|
-
|
|
166
|
-
dependency lifetimes, wire formats, and MCP protocol support.
|
|
167
|
-
|
|
168
|
-
## Scope
|
|
169
|
-
|
|
170
|
-
HTTP means JSON POST endpoints, not Effect's RPC wire protocol. Both adapters use
|
|
171
|
-
Effect's servers; there is no MCP SDK runtime dependency. Actions are unary:
|
|
172
|
-
no streaming, uploads, prompts, resources, or retries.
|
|
173
|
-
|
|
174
|
-
Authentication and authorization belong to the application. Tool discovery is
|
|
175
|
-
not filtered by actor. The example's bearer tokens are **demo-only**; production
|
|
176
|
-
needs real token validation, MCP authorization discovery, request limits, and
|
|
177
|
-
error reporting.
|
|
178
|
-
|
|
179
|
-
## Development and packaging
|
|
180
|
-
|
|
181
|
-
Requires a supported Node.js version (24 LTS recommended) and [Vite+](https://viteplus.dev).
|
|
69
|
+
## Install
|
|
182
70
|
|
|
183
71
|
```sh
|
|
184
|
-
|
|
185
|
-
vp check
|
|
186
|
-
vp test
|
|
187
|
-
vp run test:package
|
|
188
|
-
vp run example
|
|
72
|
+
pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.116
|
|
189
73
|
```
|
|
190
74
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`vp run build` emits ESM and declarations into `dist/`. The entry points are one subpath per
|
|
196
|
-
module: `/Action`, `/ActionGroup`, `/ActionHttp`, `/ActionMcp`, `/Authentication`, `/Testing`,
|
|
197
|
-
and `/TestingClient`. There is no package root, so a contracts-only or browser bundle never
|
|
198
|
-
loads the MCP server or the optional client peer.
|
|
199
|
-
`vp run test:package` builds and checks a tarball in an isolated consumer using the pinned
|
|
200
|
-
Effect snapshot; it does not establish compatibility with the published peer.
|
|
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.
|
|
201
78
|
|
|
202
|
-
##
|
|
79
|
+
## Docs
|
|
203
80
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
`errors` lists its error schemas in precedence order. The first matching schema owns
|
|
207
|
-
both serialization and `httpApiStatus` (500 when unannotated). If its encoder fails,
|
|
208
|
-
the request fails as a server error; another schema is not tried. `headers(error)`
|
|
209
|
-
can add a Bearer challenge.
|
|
210
|
-
Responses use `Cache-Control: no-store`, including failures handled by enclosing middleware. Provide the returned middleware's `.layer` to
|
|
211
|
-
HTTP and MCP route layers. See [examples/app.ts](examples/app.ts).
|
|
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.
|
|
212
83
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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.
|
|
217
88
|
|
|
218
|
-
|
|
219
|
-
returns `{ layer, metadataUrl, challenge }`. Mount its public `layer` separately from
|
|
220
|
-
protected routes. It publishes [RFC 9728 metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
|
221
|
-
at `/.well-known/oauth-protected-resource` followed by the resource path and query. Discovery
|
|
222
|
-
matches that literal path and query for GET and HEAD; other requests reach the host router.
|
|
223
|
-
Caching policy belongs to the host. `challenge()`
|
|
224
|
-
returns the `WWW-Authenticate` value; pass `{ error: "invalid_token" }` or
|
|
225
|
-
`{ error: "insufficient_scope", scope: "admin" }` where appropriate. Supported scopes
|
|
226
|
-
are advertised in metadata; the challenge names only scopes needed for that request.
|
|
227
|
-
The helper supports HTTPS URLs and loopback HTTP for development, with no credentials
|
|
228
|
-
or fragment. Resource identifiers may include a query; authorization-server issuers may not.
|
|
229
|
-
Your application owns login, consent, and token verification.
|
|
89
|
+
## For agents
|
|
230
90
|
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
resource: "https://api.example.com/mcp",
|
|
234
|
-
authorizationServers: ["https://auth.example.com"],
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
const authentication = Authentication.middleware(CurrentActor, {
|
|
238
|
-
authenticate,
|
|
239
|
-
errors: [Unauthenticated],
|
|
240
|
-
headers: () => ({ "www-authenticate": discovery.challenge({ error: "invalid_token" }) }),
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
const routes = Layer.mergeAll(
|
|
244
|
-
discovery.layer,
|
|
245
|
-
protectedRoutes.pipe(Layer.provide(authentication.layer)),
|
|
246
|
-
);
|
|
91
|
+
```sh
|
|
92
|
+
npx skills add gjermundgaraba/effect-actions --skill effect-actions
|
|
247
93
|
```
|
|
248
94
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
Import from `@gjermundgaraba/effect-actions/Testing`; it requires no MCP client dependency.
|
|
252
|
-
`withMcpClient` lives in `@gjermundgaraba/effect-actions/TestingClient` and needs the optional
|
|
253
|
-
peer `@modelcontextprotocol/client@^2.0.0`. Only that entry point loads the peer.
|
|
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.
|
|
254
97
|
|
|
255
|
-
|
|
256
|
-
const web = HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)));
|
|
98
|
+
## Status
|
|
257
99
|
|
|
258
|
-
|
|
259
|
-
|
|
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).
|
|
260
103
|
|
|
261
|
-
|
|
262
|
-
});
|
|
263
|
-
```
|
|
104
|
+
## Contributing
|
|
264
105
|
|
|
265
|
-
|
|
266
|
-
memory. `options` are the client's connection options; `baseUrl` defaults to `http://localhost`.
|
|
267
|
-
- `mcpRequest({ url, method, params?, headers? })` builds a stateless 2026-07-28 request.
|
|
268
|
-
`params` takes whatever `JSON.stringify` accepts, `undefined` fields included, so tests can
|
|
269
|
-
send malformed arguments.
|
|
270
|
-
Caller `params._meta` fields override the default client capabilities
|
|
271
|
-
and information; application metadata is preserved. The protocol version stays pinned to
|
|
272
|
-
2026-07-28 in both the request header and metadata. Metadata is merged shallowly.
|
|
273
|
-
- `withMcpClient({ fetch, path, mode?, baseUrl?, headers? }, async client => ...)`
|
|
274
|
-
connects the official client and closes it in a `finally` block.
|
|
275
|
-
`mode` defaults to `"modern"` (2026-07-28); use `"legacy"` to exercise session negotiation.
|
|
106
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
|
package/dist/Action.d.mts
CHANGED
|
@@ -1,2 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
declare namespace Action_d_exports {
|
|
3
|
+
export { Action, Any, Codec, Handler, McpOptions, Options, make };
|
|
4
|
+
}
|
|
5
|
+
/** Any service-free schema. Only handlers may require services. */
|
|
6
|
+
export type Codec = Schema.Codec<unknown, unknown, never, never>;
|
|
7
|
+
/** MCP tool metadata; every field has a default derived from the action. */
|
|
8
|
+
export interface McpOptions {
|
|
9
|
+
/** Tool name; defaults to the action name. Must match `^[A-Za-z0-9_-]{1,128}$`. */
|
|
10
|
+
readonly name?: string;
|
|
11
|
+
/** `readOnlyHint`; defaults to `false`. */
|
|
12
|
+
readonly readOnly?: boolean;
|
|
13
|
+
/** `destructiveHint`; defaults to `!readOnly`, as the MCP spec only defines it for writes. */
|
|
14
|
+
readonly destructive?: boolean;
|
|
15
|
+
/** `idempotentHint`; defaults to `false`. */
|
|
16
|
+
readonly idempotent?: boolean;
|
|
17
|
+
/** `openWorldHint`; defaults to `true`. */
|
|
18
|
+
readonly openWorld?: boolean;
|
|
19
|
+
}
|
|
20
|
+
type ResolvedMcp<Name extends string, Mcp extends false | McpOptions | undefined> = Mcp extends false ? false : {
|
|
21
|
+
readonly name: Mcp extends McpOptions ? "name" extends keyof Mcp ? Extract<Mcp["name"], string> extends never ? Name : Extract<Mcp["name"], string> | (undefined extends Mcp["name"] ? Name : never) : Name : Name;
|
|
22
|
+
readonly readOnly: boolean;
|
|
23
|
+
readonly destructive: boolean;
|
|
24
|
+
readonly idempotent: boolean;
|
|
25
|
+
readonly openWorld: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** What `make` needs to define an action. */
|
|
28
|
+
export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
|
|
29
|
+
readonly description: string;
|
|
30
|
+
/** Omit for an action without arguments. */
|
|
31
|
+
readonly input?: Input;
|
|
32
|
+
readonly success: Output;
|
|
33
|
+
/** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
|
|
34
|
+
readonly errors?: Errors;
|
|
35
|
+
/** `false` hides the action from HTTP routes and clients. */
|
|
36
|
+
readonly http?: Http;
|
|
37
|
+
/** `false` hides the action from MCP; otherwise tool metadata. */
|
|
38
|
+
readonly mcp?: Mcp;
|
|
39
|
+
}
|
|
40
|
+
/** A pure contract: schemas and transport metadata. Implementations are bound by `ActionGroup.implement`. */
|
|
41
|
+
export interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
|
|
42
|
+
readonly name: Name;
|
|
43
|
+
readonly description: string;
|
|
44
|
+
readonly input: Input;
|
|
45
|
+
readonly success: Output;
|
|
46
|
+
readonly errors: Errors;
|
|
47
|
+
readonly http: Http;
|
|
48
|
+
readonly mcp: ResolvedMcp<Name, Mcp>;
|
|
49
|
+
}
|
|
50
|
+
/** Any action, with its schemas erased. */
|
|
51
|
+
export type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, false> | Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, McpOptions> | Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, undefined>;
|
|
52
|
+
/** Receives decoded input; may fail only with the declared errors. */
|
|
53
|
+
export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
|
|
54
|
+
/** An empty object schema that also produces the object root MCP requires. */
|
|
55
|
+
declare const NoInput: Schema.$Record<Schema.String, Schema.Never>;
|
|
56
|
+
/**
|
|
57
|
+
* Define an action contract. Names are `[A-Za-z0-9_-]+`, other than `then`.
|
|
58
|
+
* Actions can be local-only by setting both transports to `false`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Http extends boolean = true, const Mcp extends false | McpOptions | undefined = undefined>(name: Name, options: Options<Input, Output, Errors, Http, Mcp>): Action<Name, Input, Output, Errors, Http, Mcp>;
|
|
61
|
+
//#endregion
|
|
62
|
+
export { Action_d_exports };
|
package/dist/Action.mjs
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import { assertName } from "./internal/actions.mjs";
|
|
1
2
|
import { Schema } from "effect";
|
|
2
3
|
//#region src/Action.ts
|
|
3
4
|
/** An empty object schema that also produces the object root MCP requires. */
|
|
4
5
|
const NoInput = Schema.Record(Schema.String, Schema.Never);
|
|
5
6
|
function make(name, options) {
|
|
6
|
-
|
|
7
|
-
if (options.http === false && options.mcp === false) throw new Error(`Action ${name} is exposed on no transport`);
|
|
7
|
+
assertName("action name", name);
|
|
8
8
|
const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? false;
|
|
9
9
|
const mcp = options.mcp === false ? false : {
|
|
10
10
|
name: options.mcp?.name ?? name,
|
|
11
11
|
readOnly,
|
|
12
|
-
destructive: options.mcp?.destructive ?? !readOnly
|
|
12
|
+
destructive: options.mcp?.destructive ?? !readOnly,
|
|
13
|
+
idempotent: options.mcp?.idempotent ?? false,
|
|
14
|
+
openWorld: options.mcp?.openWorld ?? true
|
|
13
15
|
};
|
|
14
|
-
if (mcp !== false &&
|
|
16
|
+
if (mcp !== false && mcp.name.length > 128) throw new Error(`Invalid MCP name: ${mcp.name}`);
|
|
17
|
+
if (mcp !== false) assertName("MCP name", mcp.name);
|
|
15
18
|
return {
|
|
16
19
|
name,
|
|
17
20
|
description: options.description,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Action_d_exports, Any } from "./Action.mjs";
|
|
2
|
+
import { ActionGroup_d_exports, Any as Any$1 } from "./ActionGroup.mjs";
|
|
3
|
+
import { JsonSchema } from "effect";
|
|
4
|
+
//#region src/ActionCatalog.d.ts
|
|
5
|
+
/** One contract, with independently rooted JSON schemas for its encoded values. */
|
|
6
|
+
export interface Entry {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly group: string;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly description: string;
|
|
11
|
+
readonly http: boolean;
|
|
12
|
+
readonly mcp: Any["mcp"];
|
|
13
|
+
readonly input: JsonSchema.JsonSchema;
|
|
14
|
+
readonly success: JsonSchema.JsonSchema;
|
|
15
|
+
/** Includes the errors inherited from the group; not HTTP schema-policy errors. */
|
|
16
|
+
readonly errors: ReadonlyArray<JsonSchema.JsonSchema>;
|
|
17
|
+
/** HTTP-only decoding/encoding policy errors, separate from handler failures. */
|
|
18
|
+
readonly httpSchemaErrors: ReadonlyArray<JsonSchema.JsonSchema>;
|
|
19
|
+
}
|
|
20
|
+
/** An offline contract document, not an authorization decision or a mounted endpoint. */
|
|
21
|
+
export interface Catalog {
|
|
22
|
+
readonly version: "1";
|
|
23
|
+
readonly actions: ReadonlyArray<Entry>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Describe the supplied groups without acquiring implementations. Schemas describe
|
|
27
|
+
* encoded action values, not MCP's `{ value }` envelope or deployment-specific URLs.
|
|
28
|
+
* Local-only actions are included; the host chooses what, if anything, to publish.
|
|
29
|
+
*/
|
|
30
|
+
export declare const make: (...groups: ReadonlyArray<Any$1>) => Catalog;
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { assertDistinct } from "./internal/actions.mjs";
|
|
2
|
+
import { JsonSchema, Schema } from "effect";
|
|
3
|
+
//#region src/ActionCatalog.ts
|
|
4
|
+
const jsonSchema = (codec) => {
|
|
5
|
+
const document = Schema.toJsonSchemaDocument(codec);
|
|
6
|
+
return {
|
|
7
|
+
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
|
|
8
|
+
...document.schema,
|
|
9
|
+
...Object.keys(document.definitions).length === 0 ? {} : { $defs: document.definitions }
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Describe the supplied groups without acquiring implementations. Schemas describe
|
|
14
|
+
* encoded action values, not MCP's `{ value }` envelope or deployment-specific URLs.
|
|
15
|
+
* Local-only actions are included; the host chooses what, if anything, to publish.
|
|
16
|
+
*/
|
|
17
|
+
const make = (...groups) => {
|
|
18
|
+
assertDistinct("catalog group", groups.map((group) => group.name));
|
|
19
|
+
return {
|
|
20
|
+
version: "1",
|
|
21
|
+
actions: groups.flatMap((group) => group.actions.map((action) => ({
|
|
22
|
+
id: `${group.name}.${action.name}`,
|
|
23
|
+
group: group.name,
|
|
24
|
+
name: action.name,
|
|
25
|
+
description: action.description,
|
|
26
|
+
http: action.http,
|
|
27
|
+
mcp: action.mcp,
|
|
28
|
+
input: jsonSchema(action.input),
|
|
29
|
+
success: jsonSchema(action.success),
|
|
30
|
+
errors: action.errors.map(jsonSchema),
|
|
31
|
+
httpSchemaErrors: action.http ? group.schemaError?.errors.map(jsonSchema) ?? [] : []
|
|
32
|
+
})))
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { make };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Action_d_exports, Any, Handler } from "./Action.mjs";
|
|
2
|
+
import { Actions } from "./internal/actions.mjs";
|
|
3
|
+
import { HandlerContext, Handlers, HandlersContext, Implementation } from "./internal/implementation.mjs";
|
|
4
|
+
import { Options } from "./internal/cli.mjs";
|
|
5
|
+
import { Scope } from "effect";
|
|
6
|
+
import { Command } from "effect/unstable/cli";
|
|
7
|
+
//#region src/ActionCli.d.ts
|
|
8
|
+
/** Configuration for an aggregate group command. */
|
|
9
|
+
export interface GroupOptions {
|
|
10
|
+
/** Override the group command name. */
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
}
|
|
13
|
+
type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Extract<G["actions"][number], {
|
|
14
|
+
readonly name: Name;
|
|
15
|
+
}>;
|
|
16
|
+
type BoundHandler<Name extends string, A extends Any, R> = Readonly<Record<Name, Handler<A, R>>>;
|
|
17
|
+
/**
|
|
18
|
+
* Project one explicitly selected local action into a native Effect CLI command.
|
|
19
|
+
* The action name resolves against the bound group; no HTTP fallback exists.
|
|
20
|
+
*/
|
|
21
|
+
export declare const command: <G extends Actions, Name extends G["actions"][number]["name"], H extends BoundHandler<Name, Selected<G, Name>, HandlerContext<H, Name>>, EX, RX, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], Parameters>) => Command.Command<string, never, {}, EX | import("effect/Schema").SchemaError | Extract<G["actions"][number], {
|
|
22
|
+
readonly name: Name;
|
|
23
|
+
}>["errors"][number]["Type"], Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
|
|
24
|
+
/** Project every local action below its group namespace with default CLI options. */
|
|
25
|
+
export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX>(app: Implementation<G, H, EX, RX>, options?: GroupOptions) => Command.Command<string, {}, {}, EX | import("effect/Schema").SchemaError | G["actions"][number]["errors"][number]["Type"], Exclude<Exclude<RX, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<HandlersContext<H>, Scope.Scope>, Command.CommandContext<string>>>;
|
|
26
|
+
//#endregion
|
|
27
|
+
export type { Options };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { dispatch } from "./internal/implementation.mjs";
|
|
2
|
+
import { command as command$1 } from "./internal/cli.mjs";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { Command } from "effect/unstable/cli";
|
|
5
|
+
//#region src/ActionCli.ts
|
|
6
|
+
const select = (group, name) => {
|
|
7
|
+
const action = group.actions.find((candidate) => candidate.name === name);
|
|
8
|
+
if (action === void 0) throw new Error(`Unknown action "${group.name}.${name}"`);
|
|
9
|
+
return action;
|
|
10
|
+
};
|
|
11
|
+
const local = (app, action, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)));
|
|
12
|
+
/**
|
|
13
|
+
* Project one explicitly selected local action into a native Effect CLI command.
|
|
14
|
+
* The action name resolves against the bound group; no HTTP fallback exists.
|
|
15
|
+
*/
|
|
16
|
+
const command = (app, name, options) => {
|
|
17
|
+
const action = select(app.group, name);
|
|
18
|
+
return command$1(action, (input) => local(app, action, input), options);
|
|
19
|
+
};
|
|
20
|
+
/** Project every local action below its group namespace with default CLI options. */
|
|
21
|
+
const group = (app, options) => {
|
|
22
|
+
const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)))));
|
|
23
|
+
return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { command, group };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Actions } from "./internal/actions.mjs";
|
|
2
|
+
import { Options as Options$1 } from "./internal/cli.mjs";
|
|
3
|
+
import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
|
|
6
|
+
import { Command } from "effect/unstable/cli";
|
|
7
|
+
import { HttpClient } from "effect/unstable/http";
|
|
8
|
+
//#region src/ActionCliClient.d.ts
|
|
9
|
+
/** Parsing, rendering and native HTTP-endpoint configuration for one remote action. */
|
|
10
|
+
export type Options<Output, ParsedParameters extends Command.Command.Config = never> = Options$1<Output, ParsedParameters> & {
|
|
11
|
+
/** Passed directly to Effect's native `HttpApiClient.endpoint`. */
|
|
12
|
+
readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
|
|
13
|
+
};
|
|
14
|
+
/** Configuration for a remote aggregate group command. */
|
|
15
|
+
export interface GroupOptions {
|
|
16
|
+
/** Override the group command name. */
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
/** Shared native HTTP-endpoint configuration for every remote action. */
|
|
19
|
+
readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
|
|
20
|
+
}
|
|
21
|
+
type Group<Groups extends ReadonlyArray<Actions>, Name extends Groups[number]["name"]> = Extract<Groups[number], {
|
|
22
|
+
readonly name: Name;
|
|
23
|
+
}>;
|
|
24
|
+
type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Extract<G["actions"][number], {
|
|
25
|
+
readonly name: Name;
|
|
26
|
+
}>;
|
|
27
|
+
type HttpNames<G extends Actions> = Extract<G["actions"][number], {
|
|
28
|
+
readonly http: true;
|
|
29
|
+
}>["name"];
|
|
30
|
+
type ApiGroups<Groups extends ReadonlyArray<Actions>> = Api<Groups[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
|
|
31
|
+
type Endpoint<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups>>>>, {
|
|
32
|
+
readonly identifier: ActionName;
|
|
33
|
+
}>;
|
|
34
|
+
type NativeMethod<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = HttpApiClient.Client.Method<Extract<Endpoint<Groups, GroupName, ActionName>, HttpApiEndpoint.ConstraintRequest>, never, never>;
|
|
35
|
+
type RemoteCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Command.Command<string, never, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>>;
|
|
36
|
+
type RemoteGroupCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"]> = Command.Command<string, {}, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>>;
|
|
37
|
+
/**
|
|
38
|
+
* Project one HTTP action retained by the binding into a native Effect CLI command.
|
|
39
|
+
* Group and action selectors resolve exclusively from `http.groups`; runtime guards
|
|
40
|
+
* keep dynamically supplied selector strings from reaching the native client.
|
|
41
|
+
*/
|
|
42
|
+
export declare const command: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"], const ActionName extends HttpNames<Group<Groups, GroupName>>, ParsedParameters extends Command.Command.Config = never>(http: Http<Groups>, groupName: GroupName, actionName: ActionName, options?: Options<Selected<Group<Groups, GroupName>, ActionName>["success"]["Type"], ParsedParameters>) => RemoteCommand<Groups, GroupName, ActionName>;
|
|
43
|
+
/** Project all HTTP-enabled actions retained by one group below its group namespace. */
|
|
44
|
+
export declare const group: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"]>(http: Http<Groups>, groupName: GroupName, options?: GroupOptions) => RemoteGroupCommand<Groups, GroupName>;
|
|
45
|
+
//#endregion
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { command as command$1 } from "./internal/cli.mjs";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
|
+
import { Command } from "effect/unstable/cli";
|
|
5
|
+
import { HttpClient } from "effect/unstable/http";
|
|
6
|
+
//#region src/ActionCliClient.ts
|
|
7
|
+
/**
|
|
8
|
+
* The native endpoint builder needs string selectors for a runtime-selected route.
|
|
9
|
+
* `http.groups` is checked before this widening, and `NativeMethod` restores the
|
|
10
|
+
* corresponding precise endpoint type at that one dynamic client boundary.
|
|
11
|
+
*/
|
|
12
|
+
const erasedApi = (api) => {
|
|
13
|
+
return api;
|
|
14
|
+
};
|
|
15
|
+
const selectGroup = (groups, name) => {
|
|
16
|
+
const group = groups.find((candidate) => candidate.name === name);
|
|
17
|
+
if (group === void 0) throw new Error(`Unknown HTTP group "${name}"`);
|
|
18
|
+
return group;
|
|
19
|
+
};
|
|
20
|
+
const selectAction = (group, name) => {
|
|
21
|
+
const action = group.actions.find((candidate) => candidate.name === name);
|
|
22
|
+
if (action === void 0 || !action.http) throw new Error(`Unknown HTTP action "${group.name}.${name}"`);
|
|
23
|
+
return action;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Project one HTTP action retained by the binding into a native Effect CLI command.
|
|
27
|
+
* Group and action selectors resolve exclusively from `http.groups`; runtime guards
|
|
28
|
+
* keep dynamically supplied selector strings from reaching the native client.
|
|
29
|
+
*/
|
|
30
|
+
const command = (http, groupName, actionName, options) => {
|
|
31
|
+
const group = selectGroup(http.groups, groupName);
|
|
32
|
+
const action = selectAction(group, actionName);
|
|
33
|
+
return command$1(action, (input) => Effect.flatMap(HttpClient.HttpClient, (httpClient) => {
|
|
34
|
+
const endpoint = HttpApiClient.endpoint(erasedApi(http.api), {
|
|
35
|
+
group: groupName,
|
|
36
|
+
endpoint: actionName,
|
|
37
|
+
httpClient,
|
|
38
|
+
...options?.connection
|
|
39
|
+
});
|
|
40
|
+
return Effect.flatMap(endpoint, (method) => {
|
|
41
|
+
return method({ payload: input });
|
|
42
|
+
});
|
|
43
|
+
}), options);
|
|
44
|
+
};
|
|
45
|
+
/** Project all HTTP-enabled actions retained by one group below its group namespace. */
|
|
46
|
+
const group = (http, groupName, options) => {
|
|
47
|
+
const actions = selectGroup(http.groups, groupName);
|
|
48
|
+
const commands = actions.actions.flatMap((action) => action.http ? [options?.connection === void 0 ? command(http, groupName, action.name) : command(http, groupName, action.name, { connection: options.connection })] : []);
|
|
49
|
+
return Command.make(options?.name ?? actions.name).pipe(Command.withSubcommands(commands));
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { command, group };
|