@gjermundgaraba/effect-actions 0.9.0 → 0.11.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 +56 -50
- package/dist/Action.d.mts +286 -79
- package/dist/Action.mjs +125 -23
- package/dist/ActionCli.d.mts +102 -31
- package/dist/ActionCli.mjs +114 -16
- package/dist/ActionHttp.d.mts +170 -51
- package/dist/ActionHttp.mjs +130 -79
- package/dist/ActionMcp.d.mts +71 -21
- package/dist/ActionMcp.mjs +231 -80
- package/dist/ActionToolkit.d.mts +56 -27
- package/dist/ActionToolkit.mjs +16 -5
- package/dist/Authentication.d.mts +129 -33
- package/dist/Authentication.mjs +231 -38
- package/dist/Testing.d.mts +84 -57
- package/dist/Testing.mjs +121 -79
- package/dist/internal/actions.d.mts +5 -25
- package/dist/internal/actions.mjs +75 -27
- package/dist/internal/authentication.d.mts +102 -0
- package/dist/internal/authentication.mjs +11 -0
- package/dist/internal/call.d.mts +12 -0
- package/dist/internal/call.mjs +12 -0
- package/dist/internal/cli.d.mts +36 -22
- package/dist/internal/cli.mjs +311 -33
- package/dist/internal/client.d.mts +43 -0
- package/dist/internal/client.mjs +25 -0
- package/dist/internal/console.mjs +77 -0
- package/dist/internal/declared.d.mts +13 -0
- package/dist/internal/declared.mjs +34 -0
- package/dist/internal/errors.d.mts +77 -0
- package/dist/internal/errors.mjs +79 -0
- package/dist/internal/implementation.d.mts +171 -41
- package/dist/internal/implementation.mjs +147 -29
- package/dist/internal/mcp.d.mts +14 -0
- package/dist/internal/mcp.mjs +182 -0
- package/dist/internal/media.d.mts +8 -0
- package/dist/internal/media.mjs +26 -0
- package/dist/internal/memory.d.mts +6 -0
- package/dist/internal/memory.mjs +38 -0
- package/dist/internal/refusal.mjs +67 -0
- package/dist/internal/schema-errors.mjs +20 -0
- package/dist/internal/tools.mjs +27 -57
- package/docs/Action.md +403 -73
- package/docs/ActionCli.md +281 -51
- package/docs/ActionHttp.md +267 -82
- package/docs/ActionMcp.md +232 -102
- package/docs/ActionToolkit.md +99 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +24 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +82 -43
- package/docs/setup.md +95 -27
- package/package.json +12 -20
- package/dist/ActionCatalog.d.mts +0 -30
- package/dist/ActionCatalog.mjs +0 -39
- package/dist/ActionCliClient.d.mts +0 -48
- package/dist/ActionCliClient.mjs +0 -43
- package/dist/ActionGroup.d.mts +0 -44
- package/dist/ActionGroup.mjs +0 -35
- package/dist/ActionHttpClient.d.mts +0 -33
- package/dist/ActionHttpClient.mjs +0 -16
- package/dist/TestingClient.d.mts +0 -17
- package/dist/TestingClient.mjs +0 -22
- package/dist/internal/fetchClient.d.mts +0 -6
- package/dist/internal/fetchClient.mjs +0 -8
- package/dist/internal/tools.d.mts +0 -22
- package/docs/ActionCatalog.md +0 -54
- package/docs/ActionCliClient.md +0 -63
- package/docs/ActionGroup.md +0 -128
- package/docs/ActionHttpClient.md +0 -80
package/docs/CONTEXT.md
CHANGED
|
@@ -1,41 +1,29 @@
|
|
|
1
1
|
# Context
|
|
2
2
|
|
|
3
|
-
Vocabulary
|
|
4
|
-
|
|
3
|
+
Vocabulary for `@gjermundgaraba/effect-actions`. The docs, the code, and the tests use these
|
|
4
|
+
words with these meanings. Module names and Effect's own terms (`HttpApi`, `Tool`, `Toolkit`,
|
|
5
|
+
`McpServer`, `Command`) mean what their modules say.
|
|
5
6
|
|
|
6
7
|
## Terms
|
|
7
8
|
|
|
8
|
-
- **Action**: one contract. A name, a description, an input schema, a success schema, declared error schemas,
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **Handler**: a function from decoded input to an Effect of decoded success, failing only with the action's declared errors,
|
|
13
|
-
- **Implementation**:
|
|
14
|
-
- **Builder**: the
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **Promise client**: `ActionHttpClient.promise(Http)`, the native `HttpApiClient` as one Promise method per action of the binding, for code that does not run Effects. It rejects with what the native client fails with.
|
|
30
|
-
- **Catalog**: the offline JSON description of groups produced by `ActionCatalog.make`, with standalone JSON Schemas for encoded values. Descriptive only.
|
|
31
|
-
- **Identity**: the per-request principal, provided by `Authentication.middleware` under an application-owned tag. Never provided at startup.
|
|
32
|
-
|
|
33
|
-
## Seams
|
|
34
|
-
|
|
35
|
-
- `src/Action.ts` and `src/ActionGroup.ts` are contracts. They import no transport runtime. They carry only transport metadata (`mcp`, `httpApiStatus`, the HTTP schema-error policy).
|
|
36
|
-
- `src/internal/actions.ts` and `src/internal/implementation.ts` hold the shared shapes adapters consume: the contract half of a group, the schema-error policy, and the nominal `Implementation` class. Everything under `src/internal` is not public API; the public modules re-export the types consumers need.
|
|
37
|
-
- Each adapter owns its own mapping from contracts to its surface and never reaches into another adapter. HTTP does not know about tool names; MCP does not know about routes.
|
|
38
|
-
- Adapters build on Effect's own servers and clients: `HttpApi` for HTTP, `McpServer` for MCP, `Toolkit` for the AI toolkit, `Command` for the CLI. The library adds no protocol runtime.
|
|
39
|
-
- Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application. Authorization is the application's pre-handler hook, bound to each surface, which the library runs but never writes.
|
|
40
|
-
- `Testing` depends only on `effect`. `TestingClient` is the single module that loads the optional MCP client peer.
|
|
41
|
-
- Docs: `README.md` is the pitch for humans. `docs/` is the reference for agents, one card per module. `skills/effect-actions` is a copy of `docs/`, with this vocabulary included. `CONTRIBUTING.md` and `AGENTS.md` are for maintainers.
|
|
9
|
+
- **Action**: one contract. A name, a description, an input schema, a success schema, declared error schemas, `readOnly`, `caller`, and its `mcp` options: how its MCP tool presents itself. Holds no behavior. Made by `Action.make`.
|
|
10
|
+
- **Read-only**: `readOnly`, a boolean required on every action and kept as a literal. States whether the action leaves its resource unchanged. Read by an implementation's `authorize`, and the only source of a tool's read-only hint. It enforces nothing itself.
|
|
11
|
+
- **Caller**: who may call an action, `caller`, required on every contract: `Action.Anyone`, the exported value making it a **public action**, which anyone may call, signed in or not, or the identity service a caller must have, such as `caller: CurrentActor`, a **protected action**. Every surface enforces it: a remote one through authentication, a local one by requiring the host to provide that identity on every call.
|
|
12
|
+
- **Identity**: the service a protected action declares, `Context.Service` only, so a contract importing it stays browser-safe. One per action, and one per remote surface; a tenant or other request context is an ordinary request-time requirement. A **trusted identity** is one only a host supplies on a local surface, such as an operator's on an admin CLI, typed apart from what a verifier returns, so no token names it.
|
|
13
|
+
- **Handler**: a function from decoded input to an Effect of decoded success, failing only with the action's declared errors and the built-in ones. The services it yields, and an authorizer's per call, are **request-time requirements**, supplied per call: over HTTP by authentication and router middleware around the surface; on a local surface by the host.
|
|
14
|
+
- **Implementation**: actions bound to their handlers and, where any is protected, their authorizer, one per `Action.implement` call. Nominal. Every surface that runs handlers takes an implementation or a list; its [selection rules](guarantees.md#names) determine which actions are exposed, each keeping its implementation's handlers, builder and authorizer.
|
|
15
|
+
- **Builder**: the Effect passed to `implement` instead of a plain handler or record. It runs once per layer graph, however many surfaces serve its implementation, and once per invocation of a local CLI command: [guarantees.md](guarantees.md#dependency-lifetimes). The services it yields are **build-time requirements**, or startup services, resolved when its layer is built. An authorizer may be built the same way, the Effect returning it instead ([Action.md](Action.md#built-authorization)); so is a verifier, by `Authentication.layer`.
|
|
16
|
+
- **Layer graph**: the layers Effect builds together, sharing one memo map, so each is built once however many layers use it. `HttpRouter.serve` and `Testing.layer` build their routes in a graph of their own, which reuses what the graph around them has already built, and `Layer.fresh` builds its layer in a new one. `HttpRouter.provideRequest` also builds the layer it provides in a graph of its own, which reuses what the routes' graph has already built. Builders and your own services follow it alike: [dependency lifetimes](guarantees.md#dependency-lifetimes).
|
|
17
|
+
- **Host**: the application that serves the surfaces: it builds their layers, provides their services and middleware, and runs the server, subprocess or CLI.
|
|
18
|
+
- **Surface**: where callers reach actions: HTTP routes, an MCP endpoint, a Toolkit, a CLI command, a client in process. Each serves the implementations passed to it and validates only the names it serves. A **remote surface** has callers it authenticates (`ActionHttp.layer`, `ActionMcp.layerHttp`). A **local surface** has none (`ActionToolkit`, a local `ActionCli` command, `ActionMcp.runStdio`, `Action.client`): nothing authenticates, and the host provides the identity.
|
|
19
|
+
- **Binding**: the value `ActionHttp.make` returns, `{ actions, error, prefix, authentication, api }`, typed `ActionHttp.Binding`. Plain data shared by the server and every client. It decides what HTTP serves: `ActionHttp.layer` serves its actions among the implementations given. No other surface has one.
|
|
20
|
+
- **Client**: one method per action, `client.<action>(input)`, taking its input and answering its decoded success, or failing with its declared errors and the built-in ones as decoded values. `ActionHttp.client` calls a binding's routes and `Testing.mcpClient` an endpoint's tools; `Action.client` calls implementations in process, through the dispatch every surface runs, encoding then decoding the input, the success and the failure as a wire carries them: a local surface for code.
|
|
21
|
+
- **Authorizer**: `authorize`, an Effectful function of the selected action, which each implementation of protected actions states, `Action.implement(actions, handlers, { authorize })`, and every surface serving them runs. It runs only for protected actions, after authentication and input decoding, before the handler, and succeeds or fails with a refusal: [guarantees.md](guarantees.md#authorization). `Action.allowAll` lets every authenticated caller call. An implementation of public actions alone states none.
|
|
22
|
+
- **Authentication descriptor**: what `Authentication.make` returns: a named, browser-safe declaration of how a remote caller proves an identity, by one native scheme, Bearer unless it names another, and what its verifier may fail with besides a refusal. A binding and an MCP endpoint serving protected actions name it, `authentication: Login`. Its name identifies the one provider that satisfies it.
|
|
23
|
+
- **Authentication provider**: what `Authentication.layer` returns for a descriptor: the server-only verifier, built once per layer graph, and the discovery of an OAuth protected resource. Every HTTP or MCP layer serving protected actions requires it, and it authenticates their requests before decoding them. Action-level authorization is the authorizer's; record-level checks belong in handler data access ([authorization](guarantees.md#authorization)).
|
|
24
|
+
- **Layer middleware**: native `HttpApiMiddleware` given to `ActionHttp.layer(binding, apps, { middleware })`, run around every route the layer serves, inside authentication and outside decoding. HTTP only.
|
|
25
|
+
- **Mixed endpoint**: an MCP endpoint serving public and protected tools on one URL. Discovery and public tools' calls answer anyone; every other request authenticates before the endpoint decodes it ([ActionMcp.md](ActionMcp.md#rules)).
|
|
26
|
+
- **Declared error**: an error schema listed in an action's `error`. Its handler may fail with it, a limit such as a rate limit included. HTTP sends its JSON encoding with its `httpApiStatus`, 422 without one; MCP returns the same JSON as an `isError` result; the Toolkit returns it as a failure result; a CLI command prints the same JSON on stderr, failing with Effect CLI's `UserError`, whose `cause` it is.
|
|
27
|
+
- **Built-in errors**: `Action.InvalidInput` (400), `Action.Unauthenticated` (401) and `Action.Forbidden` (403), tagged errors with body `{ _tag, message }` and a default `message`; an `InvalidInput` also lists `issues`, empty when it names none, each an `Action.Issue`: a `path` into the input and a `message`, and a `Forbidden` the OAuth `scopes` a call lacks. The library produces them, and every surface declares them: [guarantees.md](guarantees.md#wire-behavior).
|
|
28
|
+
- **Refusal**: `Action.Refusal`, `Unauthenticated | Forbidden`, a schema and a type, as `Action.BuiltIn` is of every built-in error. What authentication or an authorizer refuses a caller with instead of letting a handler run, and all an authorizer may fail with. A **step-up refusal** is one an OAuth client acts on, `Unauthenticated` or a `Forbidden` naming `scopes`. Its HTTP status, challenge, and the exception for an MCP response that has already started follow the [authorization rules](guarantees.md#authorization).
|
|
29
|
+
- **Media field**: a field of an action's success whose schema is a media kind, `Action.Image`, `{ data: Uint8Array, mimeType: string }`: a top-level field of a struct success, optional or an array, or the whole success or an array of it. An MCP tool **lifts** it out of its result into a content block of its own, an image block, beside the rest of the success; every other surface sends it as JSON, the bytes in base64 ([ActionMcp.md](ActionMcp.md#media)).
|
package/docs/README.md
CHANGED
|
@@ -1,91 +1,47 @@
|
|
|
1
1
|
# effect-actions reference
|
|
2
2
|
|
|
3
|
-
Reference for `@gjermundgaraba/effect-actions`, written for coding agents.
|
|
4
|
-
card
|
|
3
|
+
Reference for `@gjermundgaraba/effect-actions`, written for coding agents. Each public-module
|
|
4
|
+
card has the same sections: **API** (inventory and options), **Canonical** (the one right way to write
|
|
5
5
|
it), **Rules** (must and never), **Failure modes** (what you see when it is wrong, and the fix).
|
|
6
|
-
|
|
6
|
+
Routing, setup, vocabulary, and shared guarantees use the structure their role needs.
|
|
7
7
|
Read the card for a module before writing code that uses it. Exported TypeScript declarations are the exact signature reference. Vocabulary is defined in
|
|
8
8
|
[CONTEXT.md](CONTEXT.md); the pages use those terms exactly.
|
|
9
9
|
|
|
10
10
|
Package facts that apply everywhere:
|
|
11
11
|
|
|
12
12
|
- Every module is a subpath import: `import * as Action from "@gjermundgaraba/effect-actions/Action"`. There is no package root.
|
|
13
|
-
- The `effect` peer
|
|
14
|
-
-
|
|
13
|
+
- The `effect` peer admits the patches of the Effect release the package is tested against, and no later minor until a release is tested against it ([setup.md](setup.md#install)).
|
|
14
|
+
- Action contracts are pure values. Defining contracts and implementations runs neither handlers nor builder Effects. Builder services are acquired when a layer is built, an `Action.client` is acquired, or a local CLI command runs; see [dependency lifetimes](guarantees.md#dependency-lifetimes).
|
|
15
15
|
|
|
16
16
|
## Pages
|
|
17
17
|
|
|
18
|
-
| Read
|
|
19
|
-
|
|
|
20
|
-
| [setup.md](setup.md)
|
|
21
|
-
| [Action.md](Action.md)
|
|
22
|
-
| [
|
|
23
|
-
| [
|
|
24
|
-
| [
|
|
25
|
-
| [
|
|
26
|
-
| [
|
|
27
|
-
| [
|
|
28
|
-
| [
|
|
29
|
-
| [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- Callers speak JSON over HTTP: `ActionHttp`. Clients use Effect's `HttpApiClient` on `Http.api`, or `ActionHttpClient.promise(Http)` where nothing runs Effects.
|
|
37
|
-
- Callers are MCP clients: `ActionMcp.layerHttp` for a hosted endpoint, `ActionMcp.layerStdio` for a subprocess.
|
|
18
|
+
| Read | When you need to |
|
|
19
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| [setup.md](setup.md) | install, run the minimal program, pick entry points, bundle a browser client, check package boundaries |
|
|
21
|
+
| [Action.md](Action.md) | define a contract and who may call it, bind its handler and authorization, use built-in errors, call or test implementations in process (`Action.client`) |
|
|
22
|
+
| [ActionHttp.md](ActionHttp.md) | serve JSON POST routes, answer bad input, publish OpenAPI, call the API with one Effect method per action |
|
|
23
|
+
| [ActionMcp.md](ActionMcp.md) | serve MCP tools over Streamable HTTP or stdio |
|
|
24
|
+
| [ActionToolkit.md](ActionToolkit.md) | use actions as a native Effect AI `Toolkit` without a server |
|
|
25
|
+
| [ActionCli.md](ActionCli.md) | run handlers in-process, or call the HTTP API, from a command with derived flags |
|
|
26
|
+
| [Authentication.md](Authentication.md) | declare how a remote caller proves its identity, verify it on HTTP and MCP surfaces, refuse with 401/403, publish RFC 9728 discovery |
|
|
27
|
+
| [Testing.md](Testing.md) | call routes and tools in memory through `HttpClient`, or test an implementation in process |
|
|
28
|
+
| [guarantees.md](guarantees.md) | rules every surface shares: builder lifetimes, authorization, wire formats, names, spans, the package's scope |
|
|
29
|
+
| [CONTEXT.md](CONTEXT.md) | look up a term the pages use |
|
|
30
|
+
|
|
31
|
+
## Choose a surface
|
|
32
|
+
|
|
33
|
+
- Callers speak JSON over HTTP: `ActionHttp`. Clients use `ActionHttp.client(Http)`.
|
|
34
|
+
- Callers are MCP clients: `ActionMcp.layerHttp` for a hosted endpoint, `ActionMcp.runStdio` for a subprocess.
|
|
38
35
|
- Callers are an Effect AI program in the same process: `ActionToolkit`.
|
|
39
|
-
- Callers are humans or scripts in a terminal
|
|
40
|
-
- Callers are
|
|
41
|
-
- Nothing runs, only the contract is published: `ActionCatalog`.
|
|
42
|
-
|
|
43
|
-
One implementation (`group.implement(...)`) feeds every adapter.
|
|
44
|
-
|
|
45
|
-
## Minimal program
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
import { Effect, Layer, Schema } from "effect";
|
|
49
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
50
|
-
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
51
|
-
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
52
|
-
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
36
|
+
- Callers are humans or scripts in a terminal: `ActionCli`, `make` and `command` from implementations to run handlers locally, or `remote` and `remoteCommand` from the HTTP binding to call a server.
|
|
37
|
+
- Callers are your own code in the same process, such as a test, a job or a command of your own: `Action.client`, with the methods of `ActionHttp.client`.
|
|
53
38
|
|
|
54
|
-
|
|
55
|
-
description: "Greet someone by name.",
|
|
56
|
-
input: Schema.Struct({ name: Schema.String }),
|
|
57
|
-
success: Schema.String,
|
|
58
|
-
access: "read",
|
|
59
|
-
});
|
|
39
|
+
Every surface that runs handlers takes the same implementations (`Action.implement(...)`), one or a list, with options last. A contract states who may call it, `caller`, and an implementation of protected actions carries its own `authorize`, which every surface runs; surfaces take only their transport's options. A remote surface serving protected actions names their authentication descriptor and requires its verifier, `Authentication.layer`; on a local surface the host provides the identity.
|
|
60
40
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
export const Http = ActionHttp.make({ apiPath: "/api/actions" }, Actions);
|
|
64
|
-
|
|
65
|
-
const app = Actions.implement({
|
|
66
|
-
greet: ({ name }) => Effect.succeed(`Hello, ${name}!`),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
export const routes = Layer.mergeAll(
|
|
70
|
-
Http.layer([app]),
|
|
71
|
-
ActionMcp.layerHttp([app], {
|
|
72
|
-
name: "greetings",
|
|
73
|
-
version: "1.0.0",
|
|
74
|
-
path: "/mcp",
|
|
75
|
-
}),
|
|
76
|
-
);
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Serve `routes` with `HttpRouter.serve` and a platform server layer. Result: `POST /api/actions/greetings/greet` and an MCP tool `greet` at `/mcp`.
|
|
41
|
+
For first-time setup, follow the [minimal program](setup.md#minimal-program).
|
|
80
42
|
|
|
81
43
|
## Runnable examples
|
|
82
44
|
|
|
83
|
-
Repository directory `examples
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
- `promise-client.ts`: the Promise client, with a declared error and an unreachable server.
|
|
87
|
-
- `contracts.ts`, `auth.ts`, `users.ts`, `handlers.ts`, `app.ts`, `server.ts`, `client.ts`: an authenticated application with three groups, per-group middleware, two MCP endpoints, OpenAPI and Swagger.
|
|
88
|
-
- `toolkit.ts`, `catalog.ts`, `cli.ts`, `cli-client.ts`, `mcp-stdio.ts`: one file per other projection.
|
|
89
|
-
- `mcp-browser.ts`: a stateless MCP endpoint with a separate browser CORS policy.
|
|
90
|
-
- `toolkit-authorized.ts`: native Toolkit invocation with authorization and correctly scoped identity.
|
|
91
|
-
- `testing.ts`: in-memory HTTP and MCP calls with cleanup.
|
|
45
|
+
Repository directory `examples/`: the minimal program, an authenticated application served on
|
|
46
|
+
every surface, and one file per other surface, each listed in its
|
|
47
|
+
[README](https://github.com/gjermundgaraba/effect-actions/tree/main/examples#readme).
|
package/docs/Testing.md
CHANGED
|
@@ -1,97 +1,174 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
In-memory calls against served layers
|
|
4
|
-
|
|
3
|
+
In-memory calls against served layers, through Effect's own `HttpClient`. Needs no extra
|
|
4
|
+
dependency and opens no port. It tests what the wire does: routes and tools, authentication,
|
|
5
|
+
statuses and codecs as sent. What an implementation does, its authorization and its handlers, is tested in process with `Action.client` ([Implementations](#implementations)).
|
|
5
6
|
|
|
6
7
|
## API
|
|
7
8
|
|
|
8
|
-
Import `@gjermundgaraba/effect-actions/Testing
|
|
9
|
-
|
|
9
|
+
Import `@gjermundgaraba/effect-actions/Testing`.
|
|
10
|
+
|
|
11
|
+
| API | Purpose |
|
|
12
|
+
| --------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
13
|
+
| `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory. |
|
|
14
|
+
| `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves. |
|
|
15
|
+
| `mcpClient(actions, options?)` | An Effect of a client calling each action's tool, like `ActionHttp.client`. |
|
|
16
|
+
| `mcpRequest(method, params?, options?)` | One request an `ActionMcp` endpoint serves, as the native request the test sends. |
|
|
17
|
+
| `McpCallError` | What a client method fails with for an answer it cannot decode; `message` holds it. |
|
|
18
|
+
|
|
19
|
+
| Option | Meaning |
|
|
20
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `url` | The endpoint, resolved by the `HttpClient` (relative under `layer`, or one prepending a base URL); default `/mcp`, the `ActionMcp.layerHttp` default. |
|
|
22
|
+
| `mcpClient`: `transformClient` | Wraps the native `HttpClient`, as `ActionHttp.client` takes it: `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. |
|
|
23
|
+
| `mcpRequest`: `headers` | Request headers. |
|
|
24
|
+
|
|
25
|
+
Exported types: `McpClient<Actions>`, a client's type; `McpClientOptions` of `mcpClient`; `McpRequestOptions` of `mcpRequest`, and `McpParams`, its `params`.
|
|
26
|
+
|
|
27
|
+
`mcpClient(actions, options)` builds a client on the `HttpClient` in context, one method per
|
|
28
|
+
action, called as a client method is: `mcp.getUser({ id })`. For every tool one implementation
|
|
29
|
+
serves, pass its actions: `Testing.mcpClient(userActions.actions)`. A method succeeds with the
|
|
30
|
+
action's decoded success. It fails with the action's declared errors and the built-in ones as
|
|
31
|
+
decoded values, `SchemaError`, `HttpClientError`, or `McpCallError` for any other answer.
|
|
32
|
+
Only a tool result carries the action's own errors; any other response decodes only as a
|
|
33
|
+
refusal, as authentication and an authorizer send. The input may be left out when `{}` is a valid
|
|
34
|
+
input, sending the input `{}` decodes to, and a given input is sent as given, as for a client's
|
|
35
|
+
method.
|
|
10
36
|
|
|
11
|
-
|
|
12
|
-
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
13
|
-
| `Testing.httpClient(api, handler, options?)` | Native grouped `HttpApiClient` over an in-memory web handler; retains client middleware requirements. |
|
|
14
|
-
| `Testing.mcpRequest(options)` | Build a stateless MCP JSON-RPC `Request`. |
|
|
15
|
-
| `Testing.mcpCall(handler, options)` | Call one tool in memory and return its outcome without the wire envelope. |
|
|
16
|
-
| `TestingClient.withMcpClient(options, run)` | Connect the official client, run an asynchronous callback, then close it. |
|
|
37
|
+
## Canonical
|
|
17
38
|
|
|
18
|
-
|
|
19
|
-
|
|
39
|
+
```ts example=testing.ts
|
|
40
|
+
import { Effect } from "effect";
|
|
41
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
42
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
43
|
+
import { Greet, Http } from "./quickstart.js";
|
|
44
|
+
import { routes } from "./quickstart-server.js";
|
|
20
45
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
| `mcpRequest`: `params`, `headers` | Optional parameters and request headers. |
|
|
25
|
-
| `mcpCall`: `url`, `name` | Required endpoint URL and tool name (`mcp.name`). |
|
|
26
|
-
| `mcpCall`: `arguments`, `headers` | Optional tool arguments (default `{}`) and request headers. |
|
|
27
|
-
| `withMcpClient`: `fetch`, `path` | Required in-memory request handler and endpoint path. |
|
|
28
|
-
| `withMcpClient`: `baseUrl`, `headers` | Optional base URL (default `http://localhost`) and headers. |
|
|
46
|
+
const program = Effect.gen(function* () {
|
|
47
|
+
const client = yield* ActionHttp.client(Http);
|
|
48
|
+
const greeting = yield* client.greet({ name: "Ada" });
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
// The same action as a tool of `/mcp`, called like the client's method.
|
|
51
|
+
const mcp = yield* Testing.mcpClient([Greet]);
|
|
52
|
+
const called = yield* mcp.greet({ name: "Ada" });
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
return { greeting, called };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The routes answer in memory for the program's scope, and are released after it.
|
|
58
|
+
console.log(await Effect.runPromise(program.pipe(Effect.provide(Testing.layer(routes)))));
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Implementations
|
|
62
|
+
|
|
63
|
+
What an implementation does, with no transport between: `Action.client` gives the methods
|
|
64
|
+
`ActionHttp.client` gives, and each call runs the authorizer and the handler, with every
|
|
65
|
+
check a surface makes on its input, its success and its failure ([Action.md](Action.md#clients)). Each
|
|
66
|
+
call takes its caller, so one test has several, and an action no binding holds, such as a
|
|
67
|
+
tool, has a method like any other.
|
|
68
|
+
|
|
69
|
+
```ts example=in-process.ts
|
|
70
|
+
import { Effect } from "effect";
|
|
71
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
72
|
+
import { actors, CurrentActor } from "./authorization.js";
|
|
73
|
+
import { userActions } from "./handlers.js";
|
|
74
|
+
import { Users } from "./users.js";
|
|
75
|
+
|
|
76
|
+
// Each call names its caller, as authentication names one per request.
|
|
77
|
+
const asAlice = Effect.provideService(CurrentActor, actors.alice);
|
|
78
|
+
|
|
79
|
+
const asReader = Effect.provideService(CurrentActor, actors.reader);
|
|
80
|
+
|
|
81
|
+
const program = Effect.gen(function* () {
|
|
82
|
+
// Acquired once, as a layer is built: the builders run here, not per call.
|
|
83
|
+
const users = yield* Action.client(userActions);
|
|
84
|
+
|
|
85
|
+
// The methods of `ActionHttp.client(Http)`, with no transport between: each call decodes
|
|
86
|
+
// its input, runs `authorize`, then the handler, and checks the success or the failure.
|
|
87
|
+
const renamed = yield* users.renameUser({ id: "1", name: "Bea" }).pipe(asAlice);
|
|
88
|
+
const refused = yield* Effect.flip(users.renameUser({ id: "1", name: "Cy" }).pipe(asReader));
|
|
89
|
+
const { changes } = yield* users.listChanges().pipe(asReader); // not an HTTP route
|
|
34
90
|
|
|
35
|
-
|
|
91
|
+
return { renamed, refused, changes };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// The builders are released with the program's scope, before the services they captured.
|
|
95
|
+
console.log(
|
|
96
|
+
await Effect.runPromise(program.pipe(Effect.scoped, Effect.provide(Users.layerMemory))),
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Signed-in callers
|
|
101
|
+
|
|
102
|
+
Protected routes on the wire: serve them behind their authentication, the real provider or a
|
|
103
|
+
test verifier of the same descriptor, and give each client its caller's credential. One `layer`
|
|
104
|
+
serves every caller; the test program shares what the routes read, such as `Users`.
|
|
105
|
+
|
|
106
|
+
```ts example=testing-caller.ts
|
|
36
107
|
import { Effect, Layer } from "effect";
|
|
37
|
-
import {
|
|
108
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
109
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
38
110
|
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
39
|
-
import
|
|
40
|
-
import { Http
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
arguments: { name: "Ada" },
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// The official client uses the same in-memory handler.
|
|
66
|
-
const result = await TestingClient.withMcpClient({ fetch: web.handler, path: "/mcp" }, (client) =>
|
|
67
|
-
client.callTool({ name: "greet", arguments: { name: "Ada" } }),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
console.log({ greeting, listStatus: listed.status, called, result });
|
|
71
|
-
} finally {
|
|
72
|
-
await web.dispose();
|
|
73
|
-
}
|
|
111
|
+
import { authenticate } from "./authentication.js";
|
|
112
|
+
import { Http } from "./binding.js";
|
|
113
|
+
import { userActions } from "./handlers.js";
|
|
114
|
+
import { Users } from "./users.js";
|
|
115
|
+
|
|
116
|
+
// The routes behind their real authentication; the caller is the token each client sends.
|
|
117
|
+
const routes = Testing.layer(
|
|
118
|
+
ActionHttp.layer(Http, userActions).pipe(Layer.provide(authenticate)),
|
|
119
|
+
).pipe(Layer.provideMerge(Users.layerMemory));
|
|
120
|
+
|
|
121
|
+
const as = (token: string) => ({
|
|
122
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const program = Effect.gen(function* () {
|
|
126
|
+
const reader = yield* ActionHttp.client(Http, as("reader"));
|
|
127
|
+
const refused = yield* Effect.flip(reader.renameUser({ id: "1", name: "Bea" })); // Forbidden
|
|
128
|
+
const users = yield* Users;
|
|
129
|
+
|
|
130
|
+
return { refused, unchanged: yield* users.get("acme", "1") };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log(await Effect.runPromise(program.pipe(Effect.provide(routes))));
|
|
74
134
|
```
|
|
75
135
|
|
|
136
|
+
A verifier needing infrastructure in production is replaced, for a test, by another `layer` of the
|
|
137
|
+
same descriptor: `Authentication.layer(Login, (token) => ...)`.
|
|
138
|
+
Any provider of that descriptor satisfies the routes; nothing else does.
|
|
139
|
+
|
|
76
140
|
## Rules
|
|
77
141
|
|
|
78
|
-
- `
|
|
79
|
-
- `
|
|
80
|
-
- `
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
- `
|
|
84
|
-
-
|
|
85
|
-
-
|
|
86
|
-
-
|
|
142
|
+
- `layer(routes)` builds the routes with request logging off, and releases them with the layer's scope. What the routes still require is the layer's, as under `HttpRouter.serve`: their builders' services, and any per-request service no middleware of theirs provides, including one a global middleware reads. Provide them around it, with `Layer.provideMerge` where the program reads them too, so the handlers and the program share one instance. A per-request service provided there, such as a tenant, reaches every request. A protected action's identity is not such a service: its routes require their authentication provider, which nothing provided around `layer` replaces ([Signed-in callers](#signed-in-callers)). Each `layer` builds the routes anew, builders included, unless `Action.layer` built them above it. Requests run in the context the layer is built in, as under `HttpRouter.serve`: a `TestClock` or a reference provided around the program reaches middleware and handlers.
|
|
143
|
+
- `layer` never requires the platform services `FileSystem`, `Path`, `HttpPlatform` and `Etag.Generator`, but they follow the same rule: at build and per request, the routes get the ones provided around `layer`, and `HttpServer.layerServices`' defaults for the rest, whose `FileSystem` is a no-op. The default `HttpPlatform` reads files through the `FileSystem` provided around `layer`.
|
|
144
|
+
- A relative URL resolves against `http://localhost`, once any `baseUrl` a client adds is applied, so `ActionHttp.client(Http)` needs no `baseUrl` under `layer`, and one given is kept. Every request on this client is answered by the routes, whatever its host, so the native `HttpApiClient` and a remote `ActionCli` command work in memory too.
|
|
145
|
+
- The client is `layer`'s own: the program's other HTTP clients get none of its requests, and it none of theirs, whether a `FetchHttpClient` is built before or after it or a `FetchHttpClient.Fetch` is provided around the program.
|
|
146
|
+
- `layer(handler)` gives the same client, answered by a web handler the test serves, such as `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))).handler` shared with tests that send it `Request`s: the routes are built once, by the handler, for every `layer` given it. The web handler needs the routes' platform services, which `HttpServer.layerServices` provides and `layer(routes)` supplies itself. The test owns the handler: `layer` neither builds nor disposes it, so the test calls `dispose` when it is done.
|
|
147
|
+
- A client method sends one stateless `tools/call` for the action's tool, at the protocol version `ActionMcp.layerHttp` serves, 2026-07-28, in both the header and `_meta`. It encodes the input with the action's schema, and decodes the success from `structuredContent`, as `ActionHttp.client` does for a route. A media field it takes only from the blocks whose `_meta` names it, `{ "effect-actions/field": "image" }`, in order, never from the structured content, and a success that is media, or whose every field is, from its blocks alone ([ActionMcp.md](ActionMcp.md#media)): the method succeeds with the success as the handler returned it, the image's `data` a `Uint8Array`, an optional image returned as `undefined` absent, as no block tells the two apart. It reads a JSON or an event-stream response.
|
|
148
|
+
- A declared error, the action's own or a built-in one, is a typed failure of its decoded value: an `isError` result from the tool, or a 401 or 403 from the endpoint's authentication or an authorizer, whose body is the same JSON. Match it with `Effect.catchTag`, exactly as on the HTTP client.
|
|
149
|
+
- Any other answer fails with `McpCallError`, whose `message` holds it: another status, the native server's own text (invalid arguments, a defect), no reply, a result without the `structuredContent` its success is sent as, or a JSON-RPC error (an unknown tool). Match it with `Effect.catchTag("McpCallError", ...)`. A success that does not decode, such as one missing the blocks of its media fields, fails with `SchemaError`, as it does from `ActionHttp.client`.
|
|
150
|
+
- The input is typed, so a malformed call cannot be sent through a client method. To assert on a malformed call, another MCP method or the response itself, such as a refusal's status and `WWW-Authenticate` challenge, send `mcpRequest(method, params, options)`.
|
|
151
|
+
- `mcpRequest` is one stateless request as a client method sends it, a native `HttpClientRequest` the test sends itself: with `mcp-name` from `params.uri` for `resources/read` and from `params.name` otherwise, and the client metadata in `_meta`. A `_meta` in `params`, such as a `progressToken`, is merged over the client metadata, and the protocol version is always the request's own.
|
|
152
|
+
- Under `layer`, send it with `HttpClient.execute(Testing.mcpRequest("tools/list"))`, which succeeds with the response whatever its status.
|
|
153
|
+
- A Promise test sends it as a web `Request`, to a web handler or to `fetch`, for a web `Response` back: `handler(Result.getOrThrow(HttpClientRequest.toWebResult(Testing.mcpRequest(method, params, { url: "http://localhost/mcp" }))))`. The `url` is absolute, the handler's origin or a listening server's, since a web `Request` has no `HttpClient` to resolve a relative one.
|
|
154
|
+
- A tool call the typed client would not send is `mcpRequest("tools/call", { name, arguments })`, its result read from the response's JSON: `result.structuredContent`, or `result.isError` and `result.content`.
|
|
155
|
+
- To assert on a result's exact bytes, such as a size bound, parse the text of an `mcpRequest`'s JSON response: `JSON.stringify` of the parsed message reproduces what the server wrote, less its final newline, so `JSON.stringify(message.result)` is the result as encoded.
|
|
156
|
+
- A request under `layer` carries the `Host` header of its URL, `localhost` for a relative one, unless it sets its own, so middleware checking the host answers as it would over the network.
|
|
157
|
+
- Add `Authorization` through `transformClient`, the same options for `mcpClient` and `ActionHttp.client`: one client per caller, `const alice = { transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken("alice")) }`. `mcpRequest` takes `headers`.
|
|
158
|
+
- A client names each tool by its action and holds no connection. Duplicate action names throw `Duplicate action: <name>`.
|
|
159
|
+
- Test what an implementation does in process, with `Action.client`: its authorizer, its handlers, and the checks every surface makes on input, success and failure, with several callers, an action no binding holds included. Test what a surface adds under `layer`: authentication, statuses, headers, bodies as sent and MCP results. The two call the same methods. Cover each surface the application exposes.
|
|
87
160
|
|
|
88
161
|
## Failure modes
|
|
89
162
|
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
- `
|
|
93
|
-
-
|
|
94
|
-
- `
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
163
|
+
- Fails with `Action.Unauthenticated`: the call reached authentication without a valid credential. Give the client a `transformClient` adding it, such as `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
|
|
164
|
+
- `MCP tools/call "<name>" returned an error: Invalid parameters for tool ...`: the endpoint serves a different contract under that name than the one passed. Call it with the served contract value.
|
|
165
|
+
- 404 from a client method: the action's implementation was not passed to any `ActionHttp.layer` call of its binding in the served routes, or `baseUrl` adds a path the routes do not have. Include `ActionHttp.layer(Http, implementations)` in the routes.
|
|
166
|
+
- The program reads a service the handlers never changed: it was provided inside the routes and again to the program, two instances. Provide it once, around `layer`, with `Layer.provideMerge`.
|
|
167
|
+
- `Type 'Provider<CurrentActor, "example.Login">' is not assignable to type 'never'` where the test runs, such as at `Effect.runPromise`: the routes serve a protected action and no provider of its descriptor is provided to them. Provide it to the routes, `ActionHttp.layer(Http, app).pipe(Layer.provide(authenticate))`, or a test verifier of the same descriptor. A caller provided around `layer` does not satisfy it ([Signed-in callers](#signed-in-callers)).
|
|
168
|
+
- `Type 'CurrentActor' is not assignable to type 'never'` where the test runs: a public action's handler reads the identity, which no route provides it. Make the action protected.
|
|
169
|
+
- A protected route answers 401 where the test expects a 415 or a 400: the request carries no credential that verifies, and authentication runs first. Send a token with requests about something else.
|
|
170
|
+
- `Argument of type 'Layer<…, FileSystem | Generator | HttpPlatform | HttpRouter | Path>' is not assignable` at `HttpRouter.toWebHandler(routes)`, then `No overload matches this call` at `layer(web.handler)`, its last overload naming `Layer<unknown, unknown, unknown>`, or `Expected 2 arguments, but got 1` at `web.handler(request)`: the web handler's routes lack their platform services. Provide them: `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)))`.
|
|
171
|
+
- A builder or handler finds no file where one exists, failing with `NotFound: FileSystem.<method> (<path>)`, or a file route answers 500: nothing provided a `FileSystem` around `layer`, so the routes read a no-op one. Provide `NodeFileSystem.layer` or `NodeServices.layer` around it, with `Layer.provideMerge` where the program reads files too.
|
|
172
|
+
- `MCP tools/call "<name>" answered 404`: the endpoint is not at `/mcp`. Pass its `url`.
|
|
173
|
+
- `HttpClientRequest.toWebResult` or `toWeb` of an `mcpRequest` fails with `UrlError`: its `url` is the relative default. Pass an absolute `url`.
|
|
174
|
+
- An `HttpClientError` whose reason is `InvalidUrlError`, from a client method or a sent `mcpRequest` on an `HttpClient` that prepends no base URL: a relative `url` resolves under `layer`, and under a client that prepends one, such as `NodeHttpServer.layerTest`'s. Pass an absolute `url`.
|