@gjermundgaraba/effect-actions 0.1.0-rc.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -283
- 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 +11 -10
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# ActionCliClient
|
|
2
|
+
|
|
3
|
+
Native Effect CLI commands that call the HTTP API through Effect's `HttpApiClient`. Runs no
|
|
4
|
+
handler locally and manages no credentials.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as ActionCliClient from "@gjermundgaraba/effect-actions/ActionCliClient";
|
|
10
|
+
|
|
11
|
+
/** One HTTP-enabled action retained by the binding. */
|
|
12
|
+
const command: <const Groups, const GroupName, const ActionName, Parameters = never>(
|
|
13
|
+
http: Http<Groups>,
|
|
14
|
+
groupName: GroupName,
|
|
15
|
+
actionName: ActionName, // only actions with http: true
|
|
16
|
+
options?: Options<SuccessType, Parameters>,
|
|
17
|
+
) => Command.Command<string, never, {}, ClientErrors, HttpClient.HttpClient | ClientServices>;
|
|
18
|
+
|
|
19
|
+
/** Every HTTP-enabled action of one group under the group name. */
|
|
20
|
+
const group: <const Groups, const GroupName>(
|
|
21
|
+
http: Http<Groups>,
|
|
22
|
+
groupName: GroupName,
|
|
23
|
+
options?: GroupOptions,
|
|
24
|
+
) => Command.Command<string, {}, {}, ClientErrors, HttpClient.HttpClient | ClientServices>;
|
|
25
|
+
|
|
26
|
+
type Options<Output, Parameters> = ActionCli.Options<Output, Parameters> & {
|
|
27
|
+
/** Passed directly to HttpApiClient.make: baseUrl, transformClient, transformResponse. */
|
|
28
|
+
readonly connection?: Parameters<typeof HttpApiClient.make>[1];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface GroupOptions {
|
|
32
|
+
readonly name?: string;
|
|
33
|
+
readonly connection?: Parameters<typeof HttpApiClient.make>[1]; // shared by every action
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Parsing and rendering options (`name`, `render`, `parameters`, `input`) are the same as
|
|
38
|
+
[ActionCli.md](ActionCli.md).
|
|
39
|
+
|
|
40
|
+
## Canonical
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { Command } from "effect/unstable/cli";
|
|
44
|
+
import { Console, Effect, Logger } from "effect";
|
|
45
|
+
import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
46
|
+
import * as ActionCliClient from "@gjermundgaraba/effect-actions/ActionCliClient";
|
|
47
|
+
import { Http } from "./contracts.js";
|
|
48
|
+
|
|
49
|
+
const command = ActionCliClient.command(Http, "public", "status", {
|
|
50
|
+
connection: { baseUrl: "http://127.0.0.1:3000" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
54
|
+
Effect.tapCause((cause) => Console.error(cause)),
|
|
55
|
+
Effect.provideService(Logger.LogToStderr, true),
|
|
56
|
+
Effect.provide(NodeHttpClient.layerUndici), // the host supplies HttpClient
|
|
57
|
+
Effect.provide(NodeServices.layer),
|
|
58
|
+
NodeRuntime.runMain({ disableErrorReporting: true }),
|
|
59
|
+
);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Authentication: `connection: { transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)) }`.
|
|
63
|
+
|
|
64
|
+
## Rules
|
|
65
|
+
|
|
66
|
+
- Selectors resolve from `http.groups` only. `groupName` must be a bound group; `actionName` must be an action of that group with `http: true`. Runtime guards reject dynamically supplied strings that the types would not accept.
|
|
67
|
+
- The host provides `HttpClient` and its configuration. Endpoint selection, credentials, and storage are the host's; nothing is inferred from action arguments.
|
|
68
|
+
- Input is decoded by the action schema before dispatch, then passed to the native client at its normal codec boundary. Do not pre-encode values.
|
|
69
|
+
- Output is validated and printed as JSON, or through `render` with `--json` available, exactly as in `ActionCli`.
|
|
70
|
+
- Errors are the client's: declared errors, policy errors, `SchemaError`, and `HttpClientError`.
|
|
71
|
+
|
|
72
|
+
## Failure modes
|
|
73
|
+
|
|
74
|
+
- Type error on `actionName`: the action is `http: false`, or the group is not bound in this `Http`.
|
|
75
|
+
- `HttpClient` missing at runtime: provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`) to the runtime.
|
|
76
|
+
- Connection refused: `connection.baseUrl` is absent or wrong. It has no default.
|
|
77
|
+
- 401 from the server: add `transformClient` to `connection`. The command adds no headers on its own.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ActionGroup
|
|
2
|
+
|
|
3
|
+
A named set of actions. The group is what adapters serve, what clients are grouped by, and
|
|
4
|
+
what `implement` binds handlers to. Shared errors and the HTTP schema-error policy live here.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
10
|
+
|
|
11
|
+
function make<Name, Actions, Errors = [], PolicyErrors = []>(
|
|
12
|
+
options: Options<Name, Errors, PolicyErrors>,
|
|
13
|
+
...actions: Actions
|
|
14
|
+
): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
|
|
15
|
+
|
|
16
|
+
interface Options<Name, Errors, PolicyErrors> {
|
|
17
|
+
readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
|
|
18
|
+
readonly errors?: Errors; // added to every action's own errors
|
|
19
|
+
readonly schemaError?: SchemaErrorPolicy<PolicyErrors>; // HTTP only; MCP keeps native answers
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
|
|
23
|
+
readonly errors: Errors;
|
|
24
|
+
readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Group<Name, Actions, PolicyErrors> {
|
|
28
|
+
readonly name: Name;
|
|
29
|
+
readonly actions: Actions;
|
|
30
|
+
readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
|
|
31
|
+
/** Bind every handler at once; build-time services resolve once per adapter layer. */
|
|
32
|
+
readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
|
|
33
|
+
build: H | Effect.Effect<H, EX, RX>,
|
|
34
|
+
) => Implementation<Group<Name, Actions, PolicyErrors>, H, EX, Exclude<RX, Scope.Scope>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
|
|
38
|
+
class Implementation<G, H, EX, RX> {
|
|
39
|
+
readonly group: G;
|
|
40
|
+
get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
|
|
45
|
+
all required.
|
|
46
|
+
|
|
47
|
+
## Canonical
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { Effect, Schema } from "effect";
|
|
51
|
+
import type { HttpApiError } from "effect/unstable/httpapi";
|
|
52
|
+
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
53
|
+
import { Forbidden, authorize, CurrentActor } from "./auth.js";
|
|
54
|
+
import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
|
|
55
|
+
import { Users } from "./users.js";
|
|
56
|
+
|
|
57
|
+
class InvalidRequest extends Schema.TaggedError<InvalidRequest>()(
|
|
58
|
+
"InvalidRequest",
|
|
59
|
+
{ message: Schema.String },
|
|
60
|
+
{ httpApiStatus: 400 },
|
|
61
|
+
) {}
|
|
62
|
+
class InternalError extends Schema.TaggedError<InternalError>()(
|
|
63
|
+
"InternalError",
|
|
64
|
+
{ message: Schema.String },
|
|
65
|
+
{ httpApiStatus: 500 },
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
// Shared across groups: annotate the parameter. Inline, `map` is inferred from `errors`.
|
|
69
|
+
const schemaError = {
|
|
70
|
+
errors: [InvalidRequest, InternalError],
|
|
71
|
+
map: ({ kind }: HttpApiError.HttpApiSchemaError) =>
|
|
72
|
+
kind === "Body" || kind === "ResponseHeaders"
|
|
73
|
+
? new InternalError({ message: "The request could not be completed." })
|
|
74
|
+
: new InvalidRequest({ message: "The request does not match the action's input." }),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// `Forbidden` is declared once and joins every action's errors.
|
|
78
|
+
export const UserActions = ActionGroup.make(
|
|
79
|
+
{ name: "users", errors: [Forbidden], schemaError },
|
|
80
|
+
GetUser,
|
|
81
|
+
RenameUser,
|
|
82
|
+
WhoAmI,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Build-time services (Users) are yielded in the builder, once per adapter layer.
|
|
86
|
+
// Request-time services (CurrentActor) are yielded inside handlers.
|
|
87
|
+
export const UserApp = UserActions.implement(
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const users = yield* Users;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
getUser: ({ id }) =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const actor = yield* authorize("users:read");
|
|
95
|
+
|
|
96
|
+
return yield* users.get(actor.tenantId, id);
|
|
97
|
+
}),
|
|
98
|
+
renameUser: ({ id, name }) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const actor = yield* authorize("users:write");
|
|
101
|
+
|
|
102
|
+
return yield* users.rename(actor, id, name);
|
|
103
|
+
}),
|
|
104
|
+
whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
|
|
105
|
+
};
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// No build-time services: pass the record directly.
|
|
110
|
+
export const MathApp = MathActions.implement({ double: ({ value }) => Effect.succeed(value * 2) });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Rules
|
|
114
|
+
|
|
115
|
+
- `name` matches `[A-Za-z0-9_-]+` and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
|
|
116
|
+
- Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them.
|
|
117
|
+
- `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error.
|
|
118
|
+
- The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
|
|
119
|
+
- Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
|
|
120
|
+
- `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
|
|
121
|
+
- `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and is not a substitute for adapter tests. There is no public handler service or Layer.
|
|
122
|
+
|
|
123
|
+
### Schema-error policy
|
|
124
|
+
|
|
125
|
+
- Without a policy, HTTP answers decoding and encoding failures with Effect's native empty 400.
|
|
126
|
+
- With a policy, `map` receives the native `HttpApiSchemaError` (`kind`, `cause`) and returns one of `errors`. HTTP uses that error's `httpApiStatus`, and the error appears in `Http.api`, in clients, and in OpenAPI.
|
|
127
|
+
- `kind` values: `Payload` (input decoding), `Body` (success encoding), plus `Parameters`, `RequestHeaders`, `Query`, `ResponseHeaders`.
|
|
128
|
+
- MCP is unaffected. The native `McpServer` answers invalid arguments and unencodable results itself.
|
|
129
|
+
- Policy errors extend the transport contract, not the handler contract. A handler cannot return them.
|
|
130
|
+
- The policy runs only on the server. Client-side codec failures stay `SchemaError`.
|
|
131
|
+
- `cause` may contain input values. Never reflect it into the response.
|
|
132
|
+
- Groups served by one adapter may have different policies; each action answers with its own group's.
|
|
133
|
+
- Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
|
|
134
|
+
|
|
135
|
+
## Failure modes
|
|
136
|
+
|
|
137
|
+
- Throws at `make`: duplicate action name, duplicate MCP tool name, invalid group name.
|
|
138
|
+
- `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
|
|
139
|
+
- Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
|
|
140
|
+
- `map` is not typed from `errors` when declared as a standalone constant: annotate its parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`).
|
|
141
|
+
- A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# ActionHttp
|
|
2
|
+
|
|
3
|
+
JSON `POST` routes on Effect's `HttpApi`. One `ActionHttp.make` binds a mount path and a set
|
|
4
|
+
of groups into a contract value shared by the server, every client, and the OpenAPI document.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
10
|
+
|
|
11
|
+
function make<const G extends ReadonlyArray<Actions>>(options: Options, ...groups: G): Http<G>;
|
|
12
|
+
|
|
13
|
+
interface Options {
|
|
14
|
+
readonly apiPath: `/${string}`; // no default
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Http<Groups> {
|
|
18
|
+
readonly groups: Groups; // the exact contracts bound, in declaration order
|
|
19
|
+
readonly api: HttpApi.HttpApi<"actions", ...>; // native HttpApi, one HttpApiGroup per group
|
|
20
|
+
/** Serve implementations. Errors and requirements are unions over exactly these apps. */
|
|
21
|
+
readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>>(
|
|
22
|
+
...apps: Apps
|
|
23
|
+
) => Layer.Layer<
|
|
24
|
+
never,
|
|
25
|
+
BuildError<Apps[number]>,
|
|
26
|
+
| BuildContext<Apps[number]>
|
|
27
|
+
| HttpRouter.HttpRouter
|
|
28
|
+
| HttpRouter.Request.From<"Requires", RequestContext<Apps[number]>>
|
|
29
|
+
| Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path
|
|
30
|
+
>;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Route shape: `POST <apiPath>/<group>/<action>`. Operation ID: `<group>.<action>`.
|
|
35
|
+
|
|
36
|
+
## Canonical
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { Layer } from "effect";
|
|
40
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
41
|
+
import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
|
|
42
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
43
|
+
import { PublicActions, UserActions } from "./contracts.js";
|
|
44
|
+
import { PublicApp, UserApp } from "./handlers.js";
|
|
45
|
+
import { authentication } from "./auth.js";
|
|
46
|
+
|
|
47
|
+
// Contract: shared by server and clients.
|
|
48
|
+
export const Http = ActionHttp.make({ apiPath: "/api/actions" }, PublicActions, UserActions);
|
|
49
|
+
|
|
50
|
+
// One layer per middleware set. Middleware provided to a layer applies to that layer only.
|
|
51
|
+
const routes = Layer.mergeAll(
|
|
52
|
+
Http.layer(PublicApp),
|
|
53
|
+
Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Documents are Effect's own, reading the same contract.
|
|
57
|
+
const documentation = Layer.mergeAll(
|
|
58
|
+
HttpRouter.add("GET", "/openapi.json", HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api))),
|
|
59
|
+
HttpApiSwagger.layer(Http.api, { path: "/docs" }),
|
|
60
|
+
HttpApiScalar.layer(Http.api, { path: "/reference" }),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const layer = Layer.mergeAll(routes, documentation);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Serve with `HttpRouter.serve(layer).pipe(Layer.provide(NodeHttpServer.layer(createServer, { port })), Layer.launch)`.
|
|
67
|
+
|
|
68
|
+
### Client
|
|
69
|
+
|
|
70
|
+
The client is Effect's native grouped `HttpApiClient` over `Http.api`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
74
|
+
import { Effect } from "effect";
|
|
75
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
76
|
+
import { Http } from "./quickstart.js";
|
|
77
|
+
|
|
78
|
+
export const greeting = Effect.gen(function* () {
|
|
79
|
+
const client = yield* HttpApiClient.make(Http.api, { baseUrl: "http://127.0.0.1:3000" });
|
|
80
|
+
|
|
81
|
+
return yield* client.greetings.greet({ payload: { name: "Ada" } });
|
|
82
|
+
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Rules
|
|
86
|
+
|
|
87
|
+
- `apiPath` has no default. Group names must be unique within one `make`. Action names need only be unique within their group; equal action names in different groups do not collide.
|
|
88
|
+
- `Http.layer(...apps)` mounts only the supplied implementations. A group that is never passed to `layer` has no routes. A group with no HTTP-enabled actions is not built at all.
|
|
89
|
+
- Middleware is per layer call. Groups that need different middleware go in separate `Http.layer` calls, merged with `Layer.mergeAll`.
|
|
90
|
+
- Request-time handler services are `HttpRouter.Request.From<"Requires", R>`. Supply them with router middleware (`Authentication.middleware`, `HttpRouter.middleware`), `HttpRouter.provideRequest`, or the request context. Build-time services are ordinary layer requirements.
|
|
91
|
+
- `Http.api` is a plain `HttpApi`. Anything Effect can do with an `HttpApi` works: `OpenApi.fromApi`, `HttpApiSwagger.layer`, `HttpApiScalar.layer`, `HttpApi.addHttpApi` to combine with other APIs, `HttpApiClient.make`.
|
|
92
|
+
- Client calls always take `{ payload }`. No-input actions take `{ payload: {} }`. Pass `null` or `undefined` only when the codec accepts it. There is no flat client.
|
|
93
|
+
- Client effects fail with the declared errors, the group's policy errors, `SchemaError` for local codec failures, and native `HttpClientError`. MCP-only actions are absent from the client.
|
|
94
|
+
- Add authentication headers with the native `transformClient` option. Use `HttpApiClient.makeWith` for custom error or service channels. Native per-call response modes are available.
|
|
95
|
+
- Wire format without a policy: input failure is an empty 400, success is the encoded body, a declared error is its JSON encoding with its `httpApiStatus`, an encoding failure is an empty 400, a defect is an empty 500. Full table in [guarantees.md](guarantees.md).
|
|
96
|
+
- Each handler runs in a span named `<group>.<action>`, a child of the request span. Decoding and encoding are outside it.
|
|
97
|
+
|
|
98
|
+
## Failure modes
|
|
99
|
+
|
|
100
|
+
- Route returns 404: the implementation was never passed to `Http.layer`, or the action has `http: false`, or the path lacks the group segment.
|
|
101
|
+
- Type error listing `HttpRouter.Request.From<"Requires", CurrentActor>` as unsatisfied: a handler yields a request service and no middleware provides it. Wrap that `Http.layer` call with the middleware's `.layer`.
|
|
102
|
+
- Two groups share a name: `make` throws. Rename one.
|
|
103
|
+
- `Implementation of group "x" is not served by this adapter`: `Http.layer` received an implementation whose group was not passed to `ActionHttp.make`. Add the group to `make` or drop the app.
|
|
104
|
+
- Client method missing for an action: the action is `http: false`.
|
|
105
|
+
- Empty 400 on a valid-looking request: input did not decode. Set a `schemaError` policy on the group to get a typed body, and check `Content-Type: application/json`.
|
|
106
|
+
- 415: wrong or missing content type.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# ActionMcp
|
|
2
|
+
|
|
3
|
+
MCP tools from implementations, on Effect's native `McpServer`. One `Tool` per MCP-enabled
|
|
4
|
+
action. Two transports: a Streamable HTTP endpoint mounted on the router, or newline-delimited
|
|
5
|
+
JSON-RPC on standard I/O for a subprocess.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
11
|
+
|
|
12
|
+
function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(
|
|
13
|
+
options: Options,
|
|
14
|
+
...apps: Apps
|
|
15
|
+
): Layer.Layer<
|
|
16
|
+
never,
|
|
17
|
+
BuildError<Apps[number]> | Cause.IllegalArgumentError,
|
|
18
|
+
| BuildContext<Apps[number]>
|
|
19
|
+
| HttpRouter.HttpRouter
|
|
20
|
+
| HttpRouter.Request.From<"Requires", RequestContext<Apps[number]>>
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(
|
|
24
|
+
options: StdioOptions,
|
|
25
|
+
...apps: Apps
|
|
26
|
+
): Layer.Layer<
|
|
27
|
+
never,
|
|
28
|
+
BuildError<Apps[number]> | Cause.IllegalArgumentError,
|
|
29
|
+
BuildContext<Apps[number]> | Stdio | RequestContext<Apps[number]>
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
interface Options {
|
|
33
|
+
readonly name: string; // server info
|
|
34
|
+
readonly version: string;
|
|
35
|
+
readonly protocols: ReadonlyArray<McpProtocol>; // required, e.g. [McpProtocol.v2026_07_28]
|
|
36
|
+
readonly path: HttpRouter.PathInput; // no default
|
|
37
|
+
readonly allowedOrigins?: ReadonlyArray<string>; // passed to McpServer.layerHttp
|
|
38
|
+
readonly instructions?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface StdioOptions {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly version: string;
|
|
44
|
+
readonly protocols: ReadonlyArray<McpProtocol>;
|
|
45
|
+
readonly instructions?: string;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Canonical
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { Layer } from "effect";
|
|
53
|
+
import { McpProtocol } from "effect/unstable/ai";
|
|
54
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
55
|
+
import { AuditApp, PublicApp, UserApp } from "./handlers.js";
|
|
56
|
+
import { authentication } from "./auth.js";
|
|
57
|
+
|
|
58
|
+
const allowedOrigins = ["http://localhost:3000"];
|
|
59
|
+
|
|
60
|
+
// One endpoint is one route: its middleware covers every tool on it.
|
|
61
|
+
// Tools with different middleware needs get their own endpoint.
|
|
62
|
+
const publicMcp = ActionMcp.layerHttp(
|
|
63
|
+
{
|
|
64
|
+
protocols: [McpProtocol.v2026_07_28],
|
|
65
|
+
name: "app-public",
|
|
66
|
+
version: "1.0.0",
|
|
67
|
+
path: "/mcp/public",
|
|
68
|
+
allowedOrigins,
|
|
69
|
+
},
|
|
70
|
+
PublicApp,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const mcp = ActionMcp.layerHttp(
|
|
74
|
+
{
|
|
75
|
+
protocols: [McpProtocol.v2026_07_28],
|
|
76
|
+
name: "app",
|
|
77
|
+
version: "1.0.0",
|
|
78
|
+
path: "/mcp",
|
|
79
|
+
allowedOrigins,
|
|
80
|
+
},
|
|
81
|
+
UserApp,
|
|
82
|
+
AuditApp,
|
|
83
|
+
).pipe(Layer.provide(authentication.layer));
|
|
84
|
+
|
|
85
|
+
export const layer = Layer.mergeAll(publicMcp, mcp);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Subprocess:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
92
|
+
import { Console, Effect, Layer, Logger } from "effect";
|
|
93
|
+
import { McpProtocol } from "effect/unstable/ai";
|
|
94
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
95
|
+
|
|
96
|
+
const layer = ActionMcp.layerStdio(
|
|
97
|
+
{ name: "app-stdio", version: "1.0.0", protocols: [McpProtocol.v2026_07_28] },
|
|
98
|
+
app,
|
|
99
|
+
).pipe(Layer.provide(NodeStdio.layer));
|
|
100
|
+
|
|
101
|
+
// stdout is protocol-only; logs go to stderr.
|
|
102
|
+
Layer.launch(layer).pipe(
|
|
103
|
+
Effect.tapCause((cause) => Console.error(cause)),
|
|
104
|
+
Effect.provideService(Logger.LogToStderr, true),
|
|
105
|
+
NodeRuntime.runMain({ disableErrorReporting: true }),
|
|
106
|
+
);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Rules
|
|
110
|
+
|
|
111
|
+
- `protocols` is required and takes Effect's native `McpProtocol` adapters. Effect owns negotiation, revision rejection, and sessions. `[McpProtocol.v2026_07_28]` gives a stateless endpoint that needs no initialize handshake.
|
|
112
|
+
- `path` has no default. `layerHttp` uses the single-endpoint Streamable HTTP transport, never the two-endpoint HTTP+SSE form, whatever revision is negotiated.
|
|
113
|
+
- An endpoint is one route. Middleware provided to `layerHttp` covers all of its tools. To serve tools under different middleware, mount them on different paths with separate `layerHttp` calls.
|
|
114
|
+
- Only MCP-enabled actions become tools, under `mcp.name` with the resolved hints. A group with no tools is not built.
|
|
115
|
+
- Every MCP-enabled action must have object-root input. Checked synchronously when `layerHttp` or `layerStdio` is called, before any handler is acquired; a violation throws. The `IllegalArgumentError` in the layer's error channel comes from the native transport, not from this check.
|
|
116
|
+
- Success is `structuredContent: { value: <encoded success> }`. A declared error is an `isError` result whose text content is the error's JSON encoding, the same bytes HTTP sends as the body, and no `structuredContent`.
|
|
117
|
+
- Invalid arguments and unencodable results are answered by the native `McpServer`: an `isError` result with a message for the model. Group `schemaError` policies do not apply.
|
|
118
|
+
- Defects and encoding failures produce the generic `isError` text `Tool execution failed due to an internal server error.`; the cause is logged, not sent.
|
|
119
|
+
- Tool discovery is not filtered by actor. Every caller sees every tool of the endpoint. Authorization happens in the handler.
|
|
120
|
+
- Cancellation is Effect's native RPC interruption.
|
|
121
|
+
- stdio: the host supplies `Stdio` (`NodeStdio.layer`) and any request-time services explicitly, including the trusted principal. There is no authentication middleware. Tool arguments never establish identity. Keep stdout for protocol messages only and route logs to stderr.
|
|
122
|
+
- Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
|
|
123
|
+
|
|
124
|
+
## Failure modes
|
|
125
|
+
|
|
126
|
+
- `<action>: MCP input must have an object root; omit input for no arguments` thrown at the `layerHttp` or `layerStdio` call: the action has scalar or array input. Wrap it in a struct or set `mcp: false`.
|
|
127
|
+
- `Duplicate MCP tool: <name>` thrown at the `layerHttp` or `layerStdio` call: two apps on one endpoint expose the same tool name. Rename with `mcp.name` or split the endpoint.
|
|
128
|
+
- Type error listing `HttpRouter.Request.From<"Requires", ...>`: a handler yields a request service and the endpoint has no middleware providing it. Provide it with `Layer.provide(middleware.layer)` on that `layerHttp`.
|
|
129
|
+
- Public tool requires a token: it shares an endpoint with protected tools. Give it its own path.
|
|
130
|
+
- Client reports a broken transport from a stdio subprocess: something printed to stdout. Set `Logger.LogToStderr` and remove `console.log`.
|
|
131
|
+
- Older MCP client cannot connect: the client expects a revision not listed in `protocols`. Add the adapter for that revision.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# ActionToolkit
|
|
2
|
+
|
|
3
|
+
Implementations as Effect's native AI `Toolkit`, for programs that call tools in-process
|
|
4
|
+
with `LanguageModel` or by hand. No server, no MCP envelope.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
10
|
+
|
|
11
|
+
function make<const Apps extends ReadonlyArray<AnyImplementation>>(
|
|
12
|
+
...apps: Apps
|
|
13
|
+
): Binding<ToolkitTools<Apps>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
|
|
14
|
+
|
|
15
|
+
interface Binding<Tools, E, R> {
|
|
16
|
+
readonly toolkit: Toolkit.Toolkit<Tools>; // native; tool names, schemas and requirements stay typed
|
|
17
|
+
/** Acquires handlers once in the layer scope; handler requirements remain at invocation. */
|
|
18
|
+
readonly layer: Layer.Layer<Tool.HandlersFor<Tools>, E, R>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Each MCP-enabled action becomes:
|
|
22
|
+
Tool.Tool<
|
|
23
|
+
McpName,
|
|
24
|
+
{
|
|
25
|
+
parameters: A["input"];
|
|
26
|
+
success: A["success"];
|
|
27
|
+
failure: Schema.Union<A["errors"]>;
|
|
28
|
+
failureMode: "return";
|
|
29
|
+
},
|
|
30
|
+
HandlerRequirements
|
|
31
|
+
>;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Canonical
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { Effect, Stream } from "effect";
|
|
38
|
+
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
39
|
+
import { UserApp } from "./handlers.js";
|
|
40
|
+
|
|
41
|
+
const binding = ActionToolkit.make(UserApp);
|
|
42
|
+
|
|
43
|
+
const program = Effect.gen(function* () {
|
|
44
|
+
const tools = yield* binding.toolkit; // bound handlers
|
|
45
|
+
const calls = yield* tools.handle("get_user", { id: "1" }); // encoded arguments
|
|
46
|
+
const results = yield* Stream.runCollect(calls);
|
|
47
|
+
|
|
48
|
+
return results;
|
|
49
|
+
}).pipe(
|
|
50
|
+
Effect.provideService(CurrentActor, actor), // request service, around the whole call
|
|
51
|
+
Effect.provide(binding.layer), // build services resolved here
|
|
52
|
+
);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText` and provide `binding.layer`.
|
|
56
|
+
|
|
57
|
+
## Rules
|
|
58
|
+
|
|
59
|
+
- Selects MCP-enabled actions only, named by `mcp.name`, with the resolved hints. Groups with no selected tools are not built.
|
|
60
|
+
- Successes are the action's native values. There is no `{ value }` wrapper. Declared failures are returned as native tool results (`failureMode: "return"`), not raised.
|
|
61
|
+
- `tools.handle(name, encodedInput)` takes encoded arguments and returns an Effect producing a result stream. The handler starts while that stream is constructed, so request services must be provided around the entire `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not only around the stream.
|
|
62
|
+
- `binding.layer` acquires each implementation once, in the layer's scope. Build-time requirements belong to the whole selected implementation.
|
|
63
|
+
- Each tool carries only its own handler's request requirements, not those of sibling or disabled tools.
|
|
64
|
+
- Supply identity at invocation, never when building the layer. Native context capture is not a security boundary.
|
|
65
|
+
- This is not an MCP server. Use `ActionMcp` to expose the same actions to external clients.
|
|
66
|
+
|
|
67
|
+
## Failure modes
|
|
68
|
+
|
|
69
|
+
- Tool missing from the toolkit: the action has `mcp: false`. The Toolkit selects by MCP enablement.
|
|
70
|
+
- `Duplicate MCP tool: <name>` thrown at `make`: two apps expose the same tool name. Rename with `mcp.name`.
|
|
71
|
+
- `Service not found` for a request tag at call time: it was provided only to the stream, or only to the layer. Provide it around the whole call effect.
|
|
72
|
+
- Type error on `binding.layer` requirements: a build-time service is missing. Provide its Layer before `binding.layer`.
|
|
73
|
+
- Result is `{ value: ... }` when a plain value was expected: you are reading an MCP response, not a Toolkit result. The Toolkit never wraps.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
Two helpers for hosts that own identity: router middleware that provides a request-scoped
|
|
4
|
+
identity service, and RFC 9728 protected-resource discovery with bearer challenges. Token
|
|
5
|
+
verification, login, and consent stay in the application.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
11
|
+
|
|
12
|
+
/** Provide `service` on every request, or send the response `authenticate` fails with. */
|
|
13
|
+
const middleware: <I, A, R>(
|
|
14
|
+
service: Context.Key<I, A>,
|
|
15
|
+
authenticate: Effect.Effect<A, HttpServerResponse.HttpServerResponse, R>,
|
|
16
|
+
) => HttpRouter.Middleware<{
|
|
17
|
+
provides: I;
|
|
18
|
+
handles: never;
|
|
19
|
+
error: never;
|
|
20
|
+
requires: Exclude<R, HttpRouter.Provided>;
|
|
21
|
+
layerError: never;
|
|
22
|
+
layerRequires: never;
|
|
23
|
+
}>;
|
|
24
|
+
|
|
25
|
+
const protectedResource: (options: ProtectedResourceOptions) => {
|
|
26
|
+
layer: Layer.Layer<never, never, HttpRouter.HttpRouter>; // GET/HEAD /.well-known/oauth-protected-resource<path>
|
|
27
|
+
metadataUrl: string;
|
|
28
|
+
challenge: (challenge?: BearerChallengeOptions) => string; // WWW-Authenticate value
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface ProtectedResourceOptions {
|
|
32
|
+
readonly resource: string; // exact OAuth resource identifier; its path and query select the discovery path
|
|
33
|
+
readonly authorizationServers: NonEmptyReadonlyArray<string>;
|
|
34
|
+
readonly scopesSupported?: ReadonlyArray<string>;
|
|
35
|
+
readonly resourceName?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BearerChallengeOptions {
|
|
39
|
+
readonly error?: "invalid_token" | "insufficient_scope";
|
|
40
|
+
readonly errorDescription?: string;
|
|
41
|
+
readonly scope?: string; // scopes needed for this request, space-separated
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Canonical
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
49
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
50
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
51
|
+
|
|
52
|
+
interface Actor {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly tenantId: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Request-scoped tag. Never provided at startup.
|
|
58
|
+
export class CurrentActor extends Context.Service<CurrentActor, Actor>()("app/CurrentActor") {}
|
|
59
|
+
|
|
60
|
+
class Unauthenticated extends Schema.TaggedError<Unauthenticated>()(
|
|
61
|
+
"Unauthenticated",
|
|
62
|
+
{ message: Schema.String },
|
|
63
|
+
{ httpApiStatus: 401 },
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
const discovery = Authentication.protectedResource({
|
|
67
|
+
resource: "https://api.example.com/mcp",
|
|
68
|
+
authorizationServers: ["https://auth.example.com"],
|
|
69
|
+
scopesSupported: ["users:read", "users:write"],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The host renders the failure response: status, body, challenge.
|
|
73
|
+
const unauthenticated = HttpServerResponse.schemaJson(Unauthenticated)(
|
|
74
|
+
new Unauthenticated({ message: "A bearer token is required." }),
|
|
75
|
+
{ status: 401, headers: { "www-authenticate": discovery.challenge({ error: "invalid_token" }) } },
|
|
76
|
+
).pipe(Effect.orDie);
|
|
77
|
+
|
|
78
|
+
export const authentication = Authentication.middleware(
|
|
79
|
+
CurrentActor,
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
82
|
+
const actor = yield* verifyToken(request); // Effect<Option<Actor>>
|
|
83
|
+
|
|
84
|
+
return Option.isNone(actor) ? yield* Effect.flip(unauthenticated) : actor.value;
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
export const routes = Layer.mergeAll(
|
|
89
|
+
discovery.layer, // public
|
|
90
|
+
Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
|
|
91
|
+
mcp.pipe(Layer.provide(authentication.layer)),
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Rules
|
|
96
|
+
|
|
97
|
+
- `authenticate` succeeds with the identity value or fails with the `HttpServerResponse` to send. The host owns status, body, and `WWW-Authenticate`.
|
|
98
|
+
- Provide the middleware's `.layer` to each HTTP route layer and MCP endpoint layer that needs identity. It is native `HttpRouter.middleware`; combine with `.combine(...)`.
|
|
99
|
+
- Its dependencies are request requirements. Resources it acquires live until the request scope closes, including while the handler runs.
|
|
100
|
+
- Every response through the middleware carries `Cache-Control: no-store`, including failures serialized by enclosing middleware.
|
|
101
|
+
- Downstream action errors are handled by their transport. They are never serialized as authentication failures.
|
|
102
|
+
- Use distinct tags for startup capabilities and request identities. Never provide `CurrentActor` or any identity or tenant tag in a startup layer or root context. Native context capture can let a startup value shadow the request value or satisfy a missing one, and the adapters add no isolation boundary. Types track that the tag is required, not where its value came from.
|
|
103
|
+
- `protectedResource` publishes what it is given. The deployment must ensure `resource` and `authorizationServers` are valid OAuth URLs (HTTPS, or loopback HTTP in development).
|
|
104
|
+
- Discovery is served at `/.well-known/oauth-protected-resource` followed by the resource's path and query, for `GET` and `HEAD`, matching that literal path and query. Other requests fall through to the host router. Mount `discovery.layer` outside the authenticated layers. Caching policy is the host's.
|
|
105
|
+
- `challenge()` quotes and escapes parameter values; it never rejects them. Name only the scopes needed for the request in `scope`; `scopesSupported` advertises the full set in metadata.
|
|
106
|
+
- Tool discovery is never filtered by actor. Authorization is a handler concern (`yield* CurrentActor`, then check).
|
|
107
|
+
|
|
108
|
+
## Failure modes
|
|
109
|
+
|
|
110
|
+
- Handler sees a stale or wrong actor: an identity tag was provided at startup. Remove it from every startup layer; provide it only through the middleware.
|
|
111
|
+
- Type error `HttpRouter.Request.From<"Requires", CurrentActor>` unsatisfied: the layer serving that implementation was not wrapped with `authentication.layer`.
|
|
112
|
+
- Discovery returns 404: the request path does not match `resource`'s path and query exactly, or `discovery.layer` is not merged into the served layer.
|
|
113
|
+
- Discovery requires a token: `discovery.layer` was placed under the authentication middleware. Mount it separately.
|
|
114
|
+
- 401 response lacks `WWW-Authenticate`: the host's failure response did not set it. Use `discovery.challenge(...)` in its headers.
|