@gjermundgaraba/effect-actions 0.1.0-rc.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -281
- package/dist/ActionHttp.mjs +41 -28
- package/dist/ActionMcp.d.mts +5 -3
- package/dist/internal/tools.mjs +3 -2
- package/docs/Action.md +117 -0
- package/docs/ActionCatalog.md +56 -0
- package/docs/ActionCli.md +91 -0
- package/docs/ActionCliClient.md +77 -0
- package/docs/ActionGroup.md +141 -0
- package/docs/ActionHttp.md +106 -0
- package/docs/ActionMcp.md +132 -0
- package/docs/ActionToolkit.md +73 -0
- package/docs/Authentication.md +114 -0
- package/docs/CONTEXT.md +36 -0
- package/docs/README.md +85 -0
- package/docs/Testing.md +91 -0
- package/docs/guarantees.md +55 -0
- package/docs/setup.md +63 -0
- package/package.json +11 -10
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# ActionCatalog
|
|
2
|
+
|
|
3
|
+
An offline JSON document describing groups: identities, metadata, and standalone JSON
|
|
4
|
+
schemas. Built from contracts alone. No implementation, service, or server is involved.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
|
|
10
|
+
|
|
11
|
+
const make: (...groups: ReadonlyArray<ActionGroup.Any>) => Catalog;
|
|
12
|
+
|
|
13
|
+
interface Catalog {
|
|
14
|
+
readonly version: "1";
|
|
15
|
+
readonly actions: ReadonlyArray<Entry>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Entry {
|
|
19
|
+
readonly id: string; // "<group>.<action>"
|
|
20
|
+
readonly group: string;
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
readonly http: boolean;
|
|
24
|
+
readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
|
|
25
|
+
readonly input: JsonSchema.JsonSchema;
|
|
26
|
+
readonly success: JsonSchema.JsonSchema;
|
|
27
|
+
readonly errors: ReadonlyArray<JsonSchema.JsonSchema>; // own + inherited group errors
|
|
28
|
+
readonly httpSchemaErrors: ReadonlyArray<JsonSchema.JsonSchema>; // policy errors, HTTP only
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Canonical
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
|
|
36
|
+
import { AuditActions, PublicActions, UserActions } from "./contracts.js";
|
|
37
|
+
|
|
38
|
+
const catalog = ActionCatalog.make(PublicActions, UserActions, AuditActions);
|
|
39
|
+
|
|
40
|
+
process.stdout.write(JSON.stringify(catalog, null, 2));
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Rules
|
|
44
|
+
|
|
45
|
+
- Takes groups, not implementations. Nothing is acquired or started.
|
|
46
|
+
- `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
|
|
47
|
+
- Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
|
|
48
|
+
- Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
|
|
49
|
+
- `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
|
|
50
|
+
- Local-only actions (`http: false`, `mcp: false`) are included. Presence in a catalog is descriptive. It is not authorization, tool publication, or proof that a route is mounted. The host decides what to publish.
|
|
51
|
+
- The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
|
|
52
|
+
|
|
53
|
+
## Failure modes
|
|
54
|
+
|
|
55
|
+
- Type error passing an implementation: `make` takes groups. Pass `app.group` or the group value itself.
|
|
56
|
+
- Consumers see `{ value }` in MCP responses but not in the catalog: expected. The catalog describes action values; MCP wraps them.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# ActionCli
|
|
2
|
+
|
|
3
|
+
Native Effect CLI commands that run handlers in-process. The host provides every service;
|
|
4
|
+
nothing is fetched over HTTP.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
10
|
+
|
|
11
|
+
/** One explicitly selected action. */
|
|
12
|
+
const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, Parameters = never>(
|
|
13
|
+
app: Implementation<G, H, EX, RX>,
|
|
14
|
+
name: Name,
|
|
15
|
+
options?: Options<SuccessType, Parameters>,
|
|
16
|
+
) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | HandlerContext, Scope>>;
|
|
17
|
+
|
|
18
|
+
/** Every action of the group, including local-only ones, under the group name. */
|
|
19
|
+
const group: <G, H, EX, RX>(
|
|
20
|
+
app: Implementation<G, H, EX, RX>,
|
|
21
|
+
options?: GroupOptions,
|
|
22
|
+
) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
|
|
23
|
+
|
|
24
|
+
interface GroupOptions {
|
|
25
|
+
readonly name?: string; // override the group command name
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Options<Output, Parameters extends Command.Command.Config = never> =
|
|
29
|
+
| JsonOptions<Output>
|
|
30
|
+
| ParametersOptions<Output, Parameters>;
|
|
31
|
+
|
|
32
|
+
interface JsonOptions<Output> {
|
|
33
|
+
readonly name?: string; // override the command name; default is the action name
|
|
34
|
+
readonly render?: (output: Output) => string; // human output; --json still prints JSON
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParametersOptions<Output, Parameters> {
|
|
38
|
+
readonly name?: string;
|
|
39
|
+
readonly render?: (output: Output) => string;
|
|
40
|
+
readonly parameters: Parameters; // native Effect Flag / Argument config
|
|
41
|
+
readonly input: (parsed: Command.Command.Config.Infer<Parameters>) => Schema.Json; // encoded action input
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Canonical
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Console, Effect, Logger } from "effect";
|
|
49
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
50
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
51
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
52
|
+
import { UserApp } from "./handlers.js";
|
|
53
|
+
import { Users } from "./users.js";
|
|
54
|
+
|
|
55
|
+
// Explicit syntax: `double --value 21`. No `--input` on this command.
|
|
56
|
+
const command = ActionCli.command(UserApp, "double", {
|
|
57
|
+
parameters: { value: Flag.String("value") },
|
|
58
|
+
input: ({ value }) => ({ value }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Default syntax for a whole group: `users double --input '{"value":"21"}'`.
|
|
62
|
+
const all = ActionCli.group(UserApp);
|
|
63
|
+
|
|
64
|
+
Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
65
|
+
Effect.tapCause((cause) => Console.error(cause)),
|
|
66
|
+
Effect.provideService(Logger.LogToStderr, true),
|
|
67
|
+
Effect.provide(Users.layerMemory),
|
|
68
|
+
Effect.provide(NodeServices.layer),
|
|
69
|
+
NodeRuntime.runMain({ disableErrorReporting: true }),
|
|
70
|
+
);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Rules
|
|
74
|
+
|
|
75
|
+
- `command(app, name)` selects one action by name from the bound group; the name is checked at the type level. `group(app)` puts every action under the group command, including actions with `http: false` and `mcp: false`.
|
|
76
|
+
- Default syntax is `--input '<json>'` carrying the entire encoded input: nested objects, arrays, scalars. Omitting `--input` supplies `{}`, which must still satisfy the input schema.
|
|
77
|
+
- `parameters` and `input` are supplied together or not at all. With them, the command has explicit native flags and arguments and no `--input`, so its syntax does not change when the schema changes.
|
|
78
|
+
- `input(parsed)` returns encoded JSON. The action schema decodes it before dispatch; a mismatch is a `SchemaError` at runtime. Action fields are never turned into flags automatically.
|
|
79
|
+
- Native `Flag` and `Argument` own names, aliases, ordering, defaults, and optionality. Use `Flag.optional` when omission must stay distinct from a default, and map the `Option` to a present or omitted field.
|
|
80
|
+
- Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output; `--json` selects JSON again. Rendering cannot bypass validation.
|
|
81
|
+
- Explicit flag names must not collide with the renderer's `--json` flag. A payload field named `json` is ordinary data; only the flag name is reserved.
|
|
82
|
+
- Each invocation acquires the implementation in a scope and releases it after the call. Domain services and authority come from the host's provided layers. There is no HTTP fallback.
|
|
83
|
+
- Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
|
|
84
|
+
|
|
85
|
+
## Failure modes
|
|
86
|
+
|
|
87
|
+
- `SchemaError` on a valid-looking `--input`: the JSON is decoded input, so transforming codecs expect the encoded form (`"21"` for `FiniteFromString`, not `21`).
|
|
88
|
+
- Command rejects `--input`: the command was built with `parameters`. Use its flags.
|
|
89
|
+
- Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
|
|
90
|
+
- Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
|
|
91
|
+
- `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
|
|
@@ -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) and `Body` (success encoding). Actions declare no parameters, query, or headers, so no other kind occurs.
|
|
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
|
+
- HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
|
|
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,132 @@
|
|
|
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
|
+
- Handlers may yield `McpSchema.McpRequestContext` for the client's declared information; the native server supplies it to every tool call, so it is never a router or host requirement.
|
|
122
|
+
- 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.
|
|
123
|
+
- Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
|
|
124
|
+
|
|
125
|
+
## Failure modes
|
|
126
|
+
|
|
127
|
+
- `<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`.
|
|
128
|
+
- `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.
|
|
129
|
+
- 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`.
|
|
130
|
+
- Public tool requires a token: it shares an endpoint with protected tools. Give it its own path.
|
|
131
|
+
- Client reports a broken transport from a stdio subprocess: something printed to stdout. Set `Logger.LogToStderr` and remove `console.log`.
|
|
132
|
+
- Older MCP client cannot connect: the client expects a revision not listed in `protocols`. Add the adapter for that revision.
|