@gjermundgaraba/effect-actions 0.1.0 → 0.3.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 +7 -2
- package/dist/Action.d.mts +14 -2
- package/dist/Action.mjs +3 -1
- package/dist/ActionCatalog.d.mts +3 -1
- package/dist/ActionCatalog.mjs +1 -0
- package/dist/ActionCli.d.mts +3 -3
- package/dist/ActionCli.mjs +2 -2
- package/dist/ActionCliClient.d.mts +8 -7
- package/dist/ActionGroup.d.mts +16 -6
- package/dist/ActionGroup.mjs +3 -2
- package/dist/ActionHttp.d.mts +14 -6
- package/dist/ActionHttp.mjs +44 -29
- package/dist/ActionMcp.d.mts +5 -3
- package/dist/ActionToolkit.d.mts +4 -3
- package/dist/internal/implementation.d.mts +16 -7
- package/dist/internal/implementation.mjs +15 -6
- package/dist/internal/tools.mjs +4 -3
- package/docs/Action.md +31 -7
- package/docs/ActionCatalog.md +2 -0
- package/docs/ActionCli.md +11 -5
- package/docs/ActionGroup.md +49 -21
- package/docs/ActionHttp.md +23 -8
- package/docs/ActionMcp.md +3 -0
- package/docs/ActionToolkit.md +2 -0
- package/docs/Authentication.md +5 -2
- package/docs/CONTEXT.md +5 -2
- package/docs/README.md +4 -4
- package/docs/guarantees.md +7 -3
- package/docs/setup.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ const Greet = Action.make("greet", {
|
|
|
18
18
|
description: "Greet someone by name.",
|
|
19
19
|
input: Schema.Struct({ name: Schema.String }),
|
|
20
20
|
success: Schema.String,
|
|
21
|
-
|
|
21
|
+
access: "read",
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
export const Actions = ActionGroup.make({ name: "greetings" }, Greet);
|
|
@@ -61,8 +61,9 @@ const greeting = Effect.gen(function* () {
|
|
|
61
61
|
## Why
|
|
62
62
|
|
|
63
63
|
- Input, success, and errors are declared once. Routes, tools, commands, clients, OpenAPI, and the catalog are derived from that declaration, so they cannot disagree.
|
|
64
|
-
- A handler may fail only with the errors its action declares. Shared failures such as `Forbidden` are declared once on the group.
|
|
64
|
+
- A handler may fail only with the errors its action declares. Shared failures such as `Forbidden` are declared once on the group, and one `before` hook runs on every surface before every handler, so a policy such as scope enforcement is written once and cannot be forgotten.
|
|
65
65
|
- The pieces are Effect's own. `Http.api` is a native `HttpApi`, so `OpenApi.fromApi`, Swagger, Scalar, and `HttpApiClient` work on it unchanged. MCP is Effect's native `McpServer`, one `Tool` per action, with no SDK runtime dependency.
|
|
66
|
+
- Every action states its `access` (`"read"` or `"write"`, defaulting to `"write"`), so authorization reads the contract instead of a hand-maintained list of mutation names.
|
|
66
67
|
- Build-time services and per-request services are tracked separately in the types. Middleware is per group, so a public group and an authenticated group can share one mount path.
|
|
67
68
|
- Tests run in memory. Call the routes through a web handler with the same typed client, or drive the official MCP client, without opening a port.
|
|
68
69
|
|
|
@@ -101,6 +102,10 @@ The `effect` peer is pinned to an exact Effect 4 release candidate and moves wit
|
|
|
101
102
|
release of this package for each Effect release candidate it adopts. Actions are unary JSON over HTTP and MCP: no streaming, uploads, prompts, or resources.
|
|
102
103
|
Authentication and authorization belong to the application. See [docs/setup.md](docs/setup.md).
|
|
103
104
|
|
|
105
|
+
## Acknowledgements
|
|
106
|
+
|
|
107
|
+
A few idea (like the native Toolkit, CLI, and catalog projections) are inspired by the excellent [rat-stack](https://github.com/joelhooks/rat-stack) by Joel Hooks.
|
|
108
|
+
|
|
104
109
|
## Contributing
|
|
105
110
|
|
|
106
111
|
See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
|
package/dist/Action.d.mts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
2
|
declare namespace Action_d_exports {
|
|
3
|
-
export { Action, Any, Codec, Handler, McpOptions, Options, make };
|
|
3
|
+
export { Access, Action, Any, Codec, Handler, McpOptions, Options, make };
|
|
4
4
|
}
|
|
5
5
|
/** Any service-free schema. Only handlers may require services. */
|
|
6
6
|
export type Codec = Schema.Codec<unknown, unknown, never, never>;
|
|
7
|
+
/**
|
|
8
|
+
* What an action does to the resource it serves: `"read"` observes, `"write"`
|
|
9
|
+
* may change it. Authorization metadata, not an MCP hint.
|
|
10
|
+
*/
|
|
11
|
+
export type Access = "read" | "write";
|
|
7
12
|
/** MCP tool metadata; every field has a default derived from the action. */
|
|
8
13
|
export interface McpOptions {
|
|
9
14
|
/** Tool name; defaults to the action name. Must match `^[A-Za-z0-9_-]{1,128}$`. */
|
|
10
15
|
readonly name?: string;
|
|
11
|
-
/** `readOnlyHint`; defaults to `
|
|
16
|
+
/** `readOnlyHint`; defaults to `access === "read"`. */
|
|
12
17
|
readonly readOnly?: boolean;
|
|
13
18
|
/** `destructiveHint`; defaults to `!readOnly`, as the MCP spec only defines it for writes. */
|
|
14
19
|
readonly destructive?: boolean;
|
|
@@ -32,6 +37,12 @@ export interface Options<Input extends Codec, Output extends Codec, Errors exten
|
|
|
32
37
|
readonly success: Output;
|
|
33
38
|
/** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
|
|
34
39
|
readonly errors?: Errors;
|
|
40
|
+
/**
|
|
41
|
+
* What the action does to its resource. Defaults to `"write"`, so an action
|
|
42
|
+
* nobody classified is treated as the more dangerous one. Read by a group's
|
|
43
|
+
* `before` hook; the library itself authorizes nothing.
|
|
44
|
+
*/
|
|
45
|
+
readonly access?: Access;
|
|
35
46
|
/** `false` hides the action from HTTP routes and clients. */
|
|
36
47
|
readonly http?: Http;
|
|
37
48
|
/** `false` hides the action from MCP; otherwise tool metadata. */
|
|
@@ -44,6 +55,7 @@ export interface Action<Name extends string, Input extends Codec, Output extends
|
|
|
44
55
|
readonly input: Input;
|
|
45
56
|
readonly success: Output;
|
|
46
57
|
readonly errors: Errors;
|
|
58
|
+
readonly access: Access;
|
|
47
59
|
readonly http: Http;
|
|
48
60
|
readonly mcp: ResolvedMcp<Name, Mcp>;
|
|
49
61
|
}
|
package/dist/Action.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { Schema } from "effect";
|
|
|
5
5
|
const NoInput = Schema.Record(Schema.String, Schema.Never);
|
|
6
6
|
function make(name, options) {
|
|
7
7
|
assertName("action name", name);
|
|
8
|
-
const
|
|
8
|
+
const access = options.access ?? "write";
|
|
9
|
+
const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? access === "read";
|
|
9
10
|
const mcp = options.mcp === false ? false : {
|
|
10
11
|
name: options.mcp?.name ?? name,
|
|
11
12
|
readOnly,
|
|
@@ -21,6 +22,7 @@ function make(name, options) {
|
|
|
21
22
|
input: options.input ?? NoInput,
|
|
22
23
|
success: options.success,
|
|
23
24
|
errors: options.errors ?? [],
|
|
25
|
+
access,
|
|
24
26
|
http: options.http !== false,
|
|
25
27
|
mcp
|
|
26
28
|
};
|
package/dist/ActionCatalog.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Action_d_exports, Any } from "./Action.mjs";
|
|
1
|
+
import { Access, Action_d_exports, Any } from "./Action.mjs";
|
|
2
2
|
import { ActionGroup_d_exports, Any as Any$1 } from "./ActionGroup.mjs";
|
|
3
3
|
import { JsonSchema } from "effect";
|
|
4
4
|
//#region src/ActionCatalog.d.ts
|
|
@@ -8,6 +8,8 @@ export interface Entry {
|
|
|
8
8
|
readonly group: string;
|
|
9
9
|
readonly name: string;
|
|
10
10
|
readonly description: string;
|
|
11
|
+
/** Authorization metadata, resolved: `"write"` unless the contract says `"read"`. */
|
|
12
|
+
readonly access: Access;
|
|
11
13
|
readonly http: boolean;
|
|
12
14
|
readonly mcp: Any["mcp"];
|
|
13
15
|
readonly input: JsonSchema.JsonSchema;
|
package/dist/ActionCatalog.mjs
CHANGED
package/dist/ActionCli.d.mts
CHANGED
|
@@ -18,10 +18,10 @@ type BoundHandler<Name extends string, A extends Any, R> = Readonly<Record<Name,
|
|
|
18
18
|
* Project one explicitly selected local action into a native Effect CLI command.
|
|
19
19
|
* The action name resolves against the bound group; no HTTP fallback exists.
|
|
20
20
|
*/
|
|
21
|
-
export declare const command: <G extends Actions, Name extends G["actions"][number]["name"], H extends BoundHandler<Name, Selected<G, Name>, HandlerContext<H, Name>>, EX, RX, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], Parameters>) => Command.Command<string, never, {}, EX | import("effect/Schema").SchemaError | Extract<G["actions"][number], {
|
|
21
|
+
export declare const command: <G extends Actions, Name extends G["actions"][number]["name"], H extends BoundHandler<Name, Selected<G, Name>, HandlerContext<H, Name>>, EX, RX, RB, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX, RB>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], Parameters>) => Command.Command<string, never, {}, EX | import("effect/Schema").SchemaError | Extract<G["actions"][number], {
|
|
22
22
|
readonly name: Name;
|
|
23
|
-
}>["errors"][number]["Type"], Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
|
|
23
|
+
}>["errors"][number]["Type"], Exclude<RB, Scope.Scope> | Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
|
|
24
24
|
/** Project every local action below its group namespace with default CLI options. */
|
|
25
|
-
export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX>(app: Implementation<G, H, EX, RX>, options?: GroupOptions) => Command.Command<string, {}, {}, EX | import("effect/Schema").SchemaError | G["actions"][number]["errors"][number]["Type"], Exclude<Exclude<RX, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<HandlersContext<H>, Scope.Scope>, Command.CommandContext<string>>>;
|
|
25
|
+
export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX, RB>(app: Implementation<G, H, EX, RX, RB>, options?: GroupOptions) => Command.Command<string, {}, {}, EX | import("effect/Schema").SchemaError | G["actions"][number]["errors"][number]["Type"], Exclude<Exclude<RB, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<RX, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<HandlersContext<H>, Scope.Scope>, Command.CommandContext<string>>>;
|
|
26
26
|
//#endregion
|
|
27
27
|
export type { Options };
|
package/dist/ActionCli.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const select = (group, name) => {
|
|
|
8
8
|
if (action === void 0) throw new Error(`Unknown action "${group.name}.${name}"`);
|
|
9
9
|
return action;
|
|
10
10
|
};
|
|
11
|
-
const local = (app, action, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)));
|
|
11
|
+
const local = (app, action, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, app.before)(input)));
|
|
12
12
|
/**
|
|
13
13
|
* Project one explicitly selected local action into a native Effect CLI command.
|
|
14
14
|
* The action name resolves against the bound group; no HTTP fallback exists.
|
|
@@ -19,7 +19,7 @@ const command = (app, name, options) => {
|
|
|
19
19
|
};
|
|
20
20
|
/** Project every local action below its group namespace with default CLI options. */
|
|
21
21
|
const group = (app, options) => {
|
|
22
|
-
const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)))));
|
|
22
|
+
const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, app.before)(input)))));
|
|
23
23
|
return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
|
|
24
24
|
};
|
|
25
25
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Action_d_exports, Codec } from "./Action.mjs";
|
|
1
2
|
import { Actions } from "./internal/actions.mjs";
|
|
2
3
|
import { Options as Options$1 } from "./internal/cli.mjs";
|
|
3
4
|
import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
|
|
@@ -27,19 +28,19 @@ type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Ex
|
|
|
27
28
|
type HttpNames<G extends Actions> = Extract<G["actions"][number], {
|
|
28
29
|
readonly http: true;
|
|
29
30
|
}>["name"];
|
|
30
|
-
type ApiGroups<Groups extends ReadonlyArray<Actions>> = Api<Groups[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
|
|
31
|
-
type Endpoint<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups>>>>, {
|
|
31
|
+
type ApiGroups<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>> = Api<Groups[number], Errors[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
|
|
32
|
+
type Endpoint<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups, Errors>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups, Errors>>>>, {
|
|
32
33
|
readonly identifier: ActionName;
|
|
33
34
|
}>;
|
|
34
|
-
type NativeMethod<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = HttpApiClient.Client.Method<Extract<Endpoint<Groups, GroupName, ActionName>, HttpApiEndpoint.ConstraintRequest>, never, never>;
|
|
35
|
-
type RemoteCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Command.Command<string, never, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>>;
|
|
36
|
-
type RemoteGroupCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"]> = Command.Command<string, {}, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>>;
|
|
35
|
+
type NativeMethod<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = HttpApiClient.Client.Method<Extract<Endpoint<Groups, Errors, GroupName, ActionName>, HttpApiEndpoint.ConstraintRequest>, never, never>;
|
|
36
|
+
type RemoteCommand<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Command.Command<string, never, {}, Effect.Error<ReturnType<NativeMethod<Groups, Errors, GroupName, ActionName>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, Errors, GroupName, ActionName>>>>;
|
|
37
|
+
type RemoteGroupCommand<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"]> = Command.Command<string, {}, {}, Effect.Error<ReturnType<NativeMethod<Groups, Errors, GroupName, HttpNames<Group<Groups, GroupName>>>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, Errors, GroupName, HttpNames<Group<Groups, GroupName>>>>>>;
|
|
37
38
|
/**
|
|
38
39
|
* Project one HTTP action retained by the binding into a native Effect CLI command.
|
|
39
40
|
* Group and action selectors resolve exclusively from `http.groups`; runtime guards
|
|
40
41
|
* keep dynamically supplied selector strings from reaching the native client.
|
|
41
42
|
*/
|
|
42
|
-
export declare const command: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"], const ActionName extends HttpNames<Group<Groups, GroupName>>, ParsedParameters extends Command.Command.Config = never>(http: Http<Groups>, groupName: GroupName, actionName: ActionName, options?: Options<Selected<Group<Groups, GroupName>, ActionName>["success"]["Type"], ParsedParameters>) => RemoteCommand<Groups, GroupName, ActionName>;
|
|
43
|
+
export declare const command: <const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>, const GroupName extends Groups[number]["name"], const ActionName extends HttpNames<Group<Groups, GroupName>>, ParsedParameters extends Command.Command.Config = never>(http: Http<Groups, Errors>, groupName: GroupName, actionName: ActionName, options?: Options<Selected<Group<Groups, GroupName>, ActionName>["success"]["Type"], ParsedParameters>) => RemoteCommand<Groups, Errors, GroupName, ActionName>;
|
|
43
44
|
/** Project all HTTP-enabled actions retained by one group below its group namespace. */
|
|
44
|
-
export declare const group: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"]>(http: Http<Groups>, groupName: GroupName, options?: GroupOptions) => RemoteGroupCommand<Groups, GroupName>;
|
|
45
|
+
export declare const group: <const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>, const GroupName extends Groups[number]["name"]>(http: Http<Groups, Errors>, groupName: GroupName, options?: GroupOptions) => RemoteGroupCommand<Groups, Errors, GroupName>;
|
|
45
46
|
//#endregion
|
package/dist/ActionGroup.d.mts
CHANGED
|
@@ -3,7 +3,7 @@ import { Actions, SchemaErrorPolicy } from "./internal/actions.mjs";
|
|
|
3
3
|
import { Implementation } from "./internal/implementation.mjs";
|
|
4
4
|
import { Effect, Scope } from "effect";
|
|
5
5
|
declare namespace ActionGroup_d_exports {
|
|
6
|
-
export { Any, Group, Implementation, Options, SchemaErrorPolicy, make };
|
|
6
|
+
export { Any, Group, ImplementOptions, Implementation, Options, SchemaErrorPolicy, make };
|
|
7
7
|
}
|
|
8
8
|
type HandlersFrom<Actions extends ReadonlyArray<Any$1>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
|
|
9
9
|
/** Group-level errors join each action's own, so a shared set is declared once. */
|
|
@@ -17,14 +17,24 @@ export interface Options<Name extends string, Errors extends ReadonlyArray<Codec
|
|
|
17
17
|
/** How HTTP answers failed decoding or encoding of these actions; MCP keeps its native answers. */
|
|
18
18
|
readonly schemaError?: SchemaErrorPolicy<PolicyErrors>;
|
|
19
19
|
}
|
|
20
|
+
/** What `implement` accepts besides the handlers themselves. */
|
|
21
|
+
export interface ImplementOptions<Errors extends ReadonlyArray<Codec>, R> {
|
|
22
|
+
/**
|
|
23
|
+
* Runs once per invocation before the selected handler, on every surface, with
|
|
24
|
+
* the action contract it is about to run. It may fail only with the group's own
|
|
25
|
+
* `errors`, because those are the failures every action of the group declares.
|
|
26
|
+
* Its services are request-time requirements, like a handler's.
|
|
27
|
+
*/
|
|
28
|
+
readonly before: (action: Any$1) => Effect.Effect<void, Errors[number]["Type"], R>;
|
|
29
|
+
}
|
|
20
30
|
/** A named set of action contracts: what adapters serve and what `implement` binds. */
|
|
21
|
-
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> extends Actions<Name, Actions$1, PolicyErrors> {
|
|
31
|
+
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>, Errors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> extends Actions<Name, Actions$1, PolicyErrors> {
|
|
22
32
|
/** Bind every handler at once, resolving build-time services once per adapter layer. */
|
|
23
|
-
readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1, PolicyErrors>, H, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope
|
|
33
|
+
readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never, RB = never>(build: H | Effect.Effect<H, EX, RX>, options?: ImplementOptions<Errors, RB>) => Implementation<Group<Name, Actions$1, PolicyErrors, Errors>, H, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope>>, NoInfer<RB>>;
|
|
24
34
|
}
|
|
25
|
-
/** Any group, with its actions erased. */
|
|
26
|
-
export type Any = Group<string, ReadonlyArray<Any$1
|
|
35
|
+
/** Any group, with its actions and error schemas erased. */
|
|
36
|
+
export type Any = Group<string, ReadonlyArray<Any$1>, ReadonlyArray<Codec>, any>;
|
|
27
37
|
/** Define a group. Invalid or duplicate action and MCP names fail here, at definition time. */
|
|
28
|
-
export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], const PolicyErrors extends ReadonlyArray<Codec> = []>(options: Options<Name, Errors, PolicyErrors>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
|
|
38
|
+
export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], const PolicyErrors extends ReadonlyArray<Codec> = []>(options: Options<Name, Errors, PolicyErrors>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, PolicyErrors, Errors>;
|
|
29
39
|
//#endregion
|
|
30
40
|
export { ActionGroup_d_exports, type Implementation, type SchemaErrorPolicy };
|
package/dist/ActionGroup.mjs
CHANGED
|
@@ -15,9 +15,10 @@ function make(options, ...declared) {
|
|
|
15
15
|
name,
|
|
16
16
|
actions,
|
|
17
17
|
schemaError: options.schemaError,
|
|
18
|
-
implement: (build) => {
|
|
18
|
+
implement: (build, options) => {
|
|
19
19
|
const built = Effect.isEffect(build) ? build : Effect.succeed(build);
|
|
20
|
-
|
|
20
|
+
const before = options?.before;
|
|
21
|
+
return Implementation.make(group, built, before);
|
|
21
22
|
}
|
|
22
23
|
};
|
|
23
24
|
return group;
|
package/dist/ActionHttp.d.mts
CHANGED
|
@@ -10,20 +10,28 @@ declare namespace ActionHttp_d_exports {
|
|
|
10
10
|
export { Api, Http, Options, make };
|
|
11
11
|
}
|
|
12
12
|
/** Contract-level configuration: servers and clients must agree on it. */
|
|
13
|
-
export interface Options {
|
|
13
|
+
export interface Options<Errors extends ReadonlyArray<Codec> = []> {
|
|
14
14
|
readonly apiPath: `/${string}`;
|
|
15
|
+
/**
|
|
16
|
+
* Failures the surface around these endpoints answers with instead of a
|
|
17
|
+
* handler: authentication, authorization, rate limits. Declared on every
|
|
18
|
+
* endpoint, so `HttpApiClient` and `ActionCliClient` decode them as typed
|
|
19
|
+
* failures rather than reporting a decode error. Each schema keeps its own
|
|
20
|
+
* `httpApiStatus`, and no two of them may share a status with an action error.
|
|
21
|
+
*/
|
|
22
|
+
readonly errors?: Errors;
|
|
15
23
|
}
|
|
16
24
|
type Endpoint<A extends Any, E extends Codec> = A extends {
|
|
17
25
|
readonly http: false;
|
|
18
26
|
} ? never : A extends Any ? HttpApiEndpoint.HttpApiEndpoint<A["name"], "POST", `/${string}`, never, never, Schema.toCodecJson<A["input"]>, never, Schema.toCodecJson<A["success"]>, Schema.toCodecJson<A["errors"][number] | E>, never> : never;
|
|
19
|
-
type ApiGroup<G extends Actions> = G extends Actions ? [Endpoint<G["actions"][number], never>] extends [never] ? never : HttpApiGroup.HttpApiGroup<G["name"], Endpoint<G["actions"][number], PolicyError<G
|
|
27
|
+
type ApiGroup<G extends Actions, E extends Codec> = G extends Actions ? [Endpoint<G["actions"][number], never>] extends [never] ? never : HttpApiGroup.HttpApiGroup<G["name"], Endpoint<G["actions"][number], PolicyError<G> | E>> : never;
|
|
20
28
|
/** The native `HttpApi` of every HTTP-enabled action of `G`, one group per action group. */
|
|
21
|
-
export type Api<G extends Actions> = HttpApi.HttpApi<"actions", ApiGroup<G>>;
|
|
29
|
+
export type Api<G extends Actions, E extends Codec = never> = HttpApi.HttpApi<"actions", ApiGroup<G, E>>;
|
|
22
30
|
/** A namespaced HTTP binding. */
|
|
23
|
-
export interface Http<Groups extends ReadonlyArray<Actions
|
|
31
|
+
export interface Http<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec> = []> {
|
|
24
32
|
/** The exact contracts bound to this HTTP adapter, in declaration order. */
|
|
25
33
|
readonly groups: Groups;
|
|
26
|
-
readonly api: Api<Groups[number]>;
|
|
34
|
+
readonly api: Api<Groups[number], Errors[number]>;
|
|
27
35
|
/**
|
|
28
36
|
* Serve any number of implementations in one layer. Build failures, build
|
|
29
37
|
* services and request services are unions over precisely those apps.
|
|
@@ -34,6 +42,6 @@ export interface Http<Groups extends ReadonlyArray<Actions>> {
|
|
|
34
42
|
* Bind the contract-level configuration once. Every route is `POST
|
|
35
43
|
* <apiPath>/<group>/<action>`, so action names may repeat across groups.
|
|
36
44
|
*/
|
|
37
|
-
export declare function make<const G extends ReadonlyArray<Actions
|
|
45
|
+
export declare function make<const G extends ReadonlyArray<Actions>, const E extends ReadonlyArray<Codec> = []>(options: Options<E>, ...groups: G): Http<G, E>;
|
|
38
46
|
//#endregion
|
|
39
47
|
export { ActionHttp_d_exports };
|
package/dist/ActionHttp.mjs
CHANGED
|
@@ -4,26 +4,40 @@ import { Effect, Layer } from "effect";
|
|
|
4
4
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
|
|
5
5
|
//#region src/ActionHttp.ts
|
|
6
6
|
const httpActions = (group) => group.actions.filter((action) => action.http);
|
|
7
|
-
|
|
7
|
+
/** Surface errors join an action's own, without repeating a schema it already declares. */
|
|
8
|
+
const errorsOf = (options, action) => [...action.errors, ...(options.errors ?? []).filter((error) => !action.errors.includes(error))];
|
|
9
|
+
const endpoint = (options, group, action) => HttpApiEndpoint.post(action.name, `${options.apiPath}/${group.name}/${action.name}`, {
|
|
8
10
|
payload: action.input,
|
|
9
11
|
success: action.success,
|
|
10
|
-
error:
|
|
12
|
+
error: errorsOf(options, action)
|
|
11
13
|
}).annotate(OpenApi.Description, action.description);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return first === void 0 ? [] : [HttpApiGroup.make(group.name).add(first, ...rest)];
|
|
16
|
-
});
|
|
17
|
-
const empty = HttpApi.make("actions");
|
|
18
|
-
return first === void 0 ? empty : empty.add(first, ...rest);
|
|
19
|
-
}
|
|
20
|
-
const schemaErrorMiddleware = (policy) => {
|
|
21
|
-
class SchemaErrors extends HttpApiMiddleware.Service()("effect-actions/http/SchemaErrors", { error: policy.errors }) {}
|
|
14
|
+
/** The native middleware declares the policy's errors on every endpoint it wraps. */
|
|
15
|
+
const schemaErrorMiddleware = (name, policy) => {
|
|
16
|
+
class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policy.errors }) {}
|
|
22
17
|
return {
|
|
23
18
|
SchemaErrors,
|
|
24
19
|
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy.map(failure)))
|
|
25
20
|
};
|
|
26
21
|
};
|
|
22
|
+
function bind(options, group) {
|
|
23
|
+
const [first, ...rest] = httpActions(group).map((action) => endpoint(options, group, action));
|
|
24
|
+
if (first === void 0) return void 0;
|
|
25
|
+
const native = HttpApiGroup.make(group.name).add(first, ...rest);
|
|
26
|
+
if (group.schemaError === void 0) return {
|
|
27
|
+
native,
|
|
28
|
+
policy: Layer.empty
|
|
29
|
+
};
|
|
30
|
+
const policy = schemaErrorMiddleware(group.name, group.schemaError);
|
|
31
|
+
return {
|
|
32
|
+
native: native.middleware(policy.SchemaErrors),
|
|
33
|
+
policy: policy.layer
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function apiOf(groups) {
|
|
37
|
+
const [first, ...rest] = groups;
|
|
38
|
+
const empty = HttpApi.make("actions").annotate(HttpApi.ParseOptions, { errors: "all" });
|
|
39
|
+
return first === void 0 ? empty : empty.add(first, ...rest);
|
|
40
|
+
}
|
|
27
41
|
/**
|
|
28
42
|
* `HttpApiBuilder.handleAll` registers a dynamically assembled record. The
|
|
29
43
|
* action selected by the native router determines the input before `dispatch`
|
|
@@ -34,32 +48,33 @@ const schemaErrorMiddleware = (policy) => {
|
|
|
34
48
|
const erasedHandlers = (handlers) => {
|
|
35
49
|
return handlers;
|
|
36
50
|
};
|
|
37
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const api = erasedApi([app.group], options);
|
|
42
|
-
const entries = (record) => Object.fromEntries(actions.map((action) => [action.name, (request) => dispatch(app.group, action, record)(request.payload)]));
|
|
43
|
-
const handlers = (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(record)));
|
|
44
|
-
if (app.group.schemaError === void 0) return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.layer(api).pipe(Layer.provide(handlers(erasedHandlers(record))))));
|
|
45
|
-
const schemaErrors = schemaErrorMiddleware(app.group.schemaError);
|
|
46
|
-
const policyApi = api.middleware(schemaErrors.SchemaErrors);
|
|
47
|
-
const policyHandlers = (record) => HttpApiBuilder.group(policyApi, app.group.name, (builder) => {
|
|
48
|
-
const dynamicEntries = entries(record);
|
|
49
|
-
return builder.handleAll(dynamicEntries);
|
|
50
|
-
});
|
|
51
|
-
return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.layer(policyApi).pipe(Layer.provide(policyHandlers(erasedHandlers(record))), Layer.provide(schemaErrors.layer))));
|
|
51
|
+
/** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
|
|
52
|
+
const groupHandlers = (api, app, policy) => {
|
|
53
|
+
const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record, app.before)(request.payload)]));
|
|
54
|
+
return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(policy));
|
|
52
55
|
};
|
|
53
56
|
function make(options, ...groups) {
|
|
54
57
|
assertDistinct("action group", groups.map((group) => group.name));
|
|
58
|
+
const bound = new Map(groups.flatMap((group) => {
|
|
59
|
+
const binding = bind(options, group);
|
|
60
|
+
return binding === void 0 ? [] : [[group, binding]];
|
|
61
|
+
}));
|
|
55
62
|
const layer = (...apps) => {
|
|
56
63
|
assertDistinct("implementation group", apps.map((app) => app.group.name));
|
|
57
64
|
for (const app of apps) if (!groups.includes(app.group)) throw new Error(`Implementation of group "${app.group.name}" is not served by this adapter`);
|
|
58
|
-
|
|
65
|
+
const served = apps.flatMap((app) => {
|
|
66
|
+
const binding = bound.get(app.group);
|
|
67
|
+
return binding === void 0 ? [] : [{
|
|
68
|
+
app,
|
|
69
|
+
...binding
|
|
70
|
+
}];
|
|
71
|
+
});
|
|
72
|
+
const api = apiOf(served.map(({ native }) => native));
|
|
73
|
+
return served.reduce((layer, { app, policy }) => layer.pipe(Layer.provide(groupHandlers(api, app, policy))), HttpApiBuilder.layer(api));
|
|
59
74
|
};
|
|
60
75
|
return {
|
|
61
76
|
groups,
|
|
62
|
-
api:
|
|
77
|
+
api: apiOf([...bound.values()].map(({ native }) => native)),
|
|
63
78
|
layer
|
|
64
79
|
};
|
|
65
80
|
}
|
package/dist/ActionMcp.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
|
|
2
2
|
import { Cause, Layer } from "effect";
|
|
3
|
-
import { McpServer } from "effect/unstable/ai";
|
|
3
|
+
import { McpSchema, McpServer } from "effect/unstable/ai";
|
|
4
4
|
import { HttpRouter } from "effect/unstable/http";
|
|
5
5
|
import { Stdio } from "effect/Stdio";
|
|
6
6
|
//#region src/ActionMcp.d.ts
|
|
@@ -24,18 +24,20 @@ export interface StdioOptions {
|
|
|
24
24
|
readonly protocols: Parameters<typeof McpServer.layerStdio>[0]["protocols"];
|
|
25
25
|
readonly instructions?: string;
|
|
26
26
|
}
|
|
27
|
+
/** The native server supplies its own request context to every tool call. */
|
|
28
|
+
type ToolRequestContext<App> = Exclude<RequestContext<App>, McpSchema.McpRequestContext>;
|
|
27
29
|
/**
|
|
28
30
|
* Serve MCP tools over one Streamable HTTP endpoint.
|
|
29
31
|
*
|
|
30
32
|
* Middleware provided around this layer has the normal HTTP lifetime. Native
|
|
31
33
|
* context capture applies: never provide request-identity tags at startup.
|
|
32
34
|
*/
|
|
33
|
-
export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(options: Options, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires",
|
|
35
|
+
export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(options: Options, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ToolRequestContext<Apps[number]>>>;
|
|
34
36
|
/**
|
|
35
37
|
* Serve MCP tools through newline-delimited JSON-RPC on standard I/O.
|
|
36
38
|
*
|
|
37
39
|
* The host supplies the `Stdio` service. Arguments are tool input only and
|
|
38
40
|
* never establish request identity or authority.
|
|
39
41
|
*/
|
|
40
|
-
export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(options: StdioOptions, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio |
|
|
42
|
+
export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(options: StdioOptions, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio | ToolRequestContext<Apps[number]>>;
|
|
41
43
|
//#endregion
|
package/dist/ActionToolkit.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Action_d_exports, Any } from "./Action.mjs";
|
|
2
|
-
import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
|
|
2
|
+
import { AnyImplementation, BeforeContext, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
|
|
3
3
|
import { Layer, Schema } from "effect";
|
|
4
4
|
import { Tool, Toolkit } from "effect/unstable/ai";
|
|
5
5
|
//#region src/ActionToolkit.d.ts
|
|
@@ -14,10 +14,11 @@ type NativeTool<A extends Any, R> = A extends {
|
|
|
14
14
|
readonly failure: Schema.Union<A["errors"]>;
|
|
15
15
|
readonly failureMode: "return";
|
|
16
16
|
}, R> : never;
|
|
17
|
-
type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any> ? H : never;
|
|
17
|
+
type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any, any> ? H : never;
|
|
18
|
+
/** A tool needs what its handler needs, plus what the group's `before` hook needs. */
|
|
18
19
|
type ToolForAction<App extends AnyImplementation, A extends Any> = A extends {
|
|
19
20
|
readonly mcp: object;
|
|
20
|
-
} ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App
|
|
21
|
+
} ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | BeforeContext<App>> : never;
|
|
21
22
|
type ToolsFor<App extends AnyImplementation> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number]> : never;
|
|
22
23
|
type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>> = { readonly [T in ToolsFor<Apps[number]> as T["name"]]: T; };
|
|
23
24
|
/** Native tools and the layer that binds their action implementations. */
|
|
@@ -9,31 +9,40 @@ type ErasedHandler<R> = {
|
|
|
9
9
|
}["handle"];
|
|
10
10
|
/** An adapter's erased view of a record of handlers with request requirements `R`. */
|
|
11
11
|
export type Handlers<R> = Readonly<Record<string, ErasedHandler<R>>>;
|
|
12
|
+
/**
|
|
13
|
+
* An adapter's erased view of the pre-handler hook. It sees the selected action
|
|
14
|
+
* contract, so a group-wide policy reads `access` rather than the action name.
|
|
15
|
+
*/
|
|
16
|
+
export type Before<R> = (action: Any) => Effect.Effect<void, ErasedValue, R>;
|
|
12
17
|
/** Per-request requirements of the handler selected by an action name. */
|
|
13
18
|
export type HandlerContext<H, Name extends keyof H> = H[Name] extends ((input: never) => Effect.Effect<infer _A, infer _E, infer R>) ? R : never;
|
|
14
19
|
/** The union of per-request requirements declared by a handler record. */
|
|
15
20
|
export type HandlersContext<H> = { readonly [K in keyof H]: HandlerContext<H, K>; }[keyof H];
|
|
16
21
|
/**
|
|
17
|
-
* An acquired handler record for a group.
|
|
22
|
+
* An acquired handler record for a group, with the hook that runs before each of them.
|
|
18
23
|
*
|
|
19
24
|
* The private field makes this class nominal: a structurally similar object,
|
|
20
25
|
* including one made by spreading an implementation, is not an implementation.
|
|
21
26
|
*/
|
|
22
|
-
export declare class Implementation<G extends Actions, H, EX, RX> {
|
|
27
|
+
export declare class Implementation<G extends Actions, H, EX, RX, RB> {
|
|
23
28
|
#private;
|
|
24
29
|
/** The group whose exact handler record was built. */
|
|
25
30
|
readonly group: G;
|
|
26
31
|
private constructor();
|
|
27
32
|
/** Acquire the exact handler record in the adapter layer's scope. */
|
|
28
33
|
get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
|
|
29
|
-
|
|
34
|
+
/** Runs once per invocation, before the selected handler, on every surface. */
|
|
35
|
+
get before(): Before<RB> | undefined;
|
|
36
|
+
static make<G extends Actions, H, EX, RX, RB>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>, before: Before<RB> | undefined): Implementation<G, H, EX, RX, RB>;
|
|
30
37
|
}
|
|
31
38
|
/** Any nominal implementation of `G`, with its record and channels erased. */
|
|
32
|
-
export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
|
|
39
|
+
export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any, any>;
|
|
40
|
+
/** Pre-handler requirements of one implementation, or of a union of them. */
|
|
41
|
+
export type BeforeContext<App> = App extends Implementation<any, any, any, any, infer RB> ? RB : never;
|
|
33
42
|
/** Per-request requirements of one implementation, or of a union of them. */
|
|
34
|
-
export type RequestContext<App> = App extends Implementation<any, infer H, any, any> ? HandlersContext<H> : never;
|
|
43
|
+
export type RequestContext<App> = App extends Implementation<any, infer H, any, any, infer RB> ? HandlersContext<H> | RB : never;
|
|
35
44
|
/** Handler-acquisition failures of one implementation, or of a union of them. */
|
|
36
|
-
export type BuildError<App> = App extends Implementation<any, any, infer EX, any> ? EX : never;
|
|
45
|
+
export type BuildError<App> = App extends Implementation<any, any, infer EX, any, any> ? EX : never;
|
|
37
46
|
/** Handler-acquisition requirements of one implementation, or of a union of them. */
|
|
38
|
-
export type BuildContext<App> = App extends Implementation<any, any, any, infer RX> ? RX : never;
|
|
47
|
+
export type BuildContext<App> = App extends Implementation<any, any, any, infer RX, any> ? RX : never;
|
|
39
48
|
//#endregion
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
//#region src/internal/implementation.ts
|
|
3
3
|
/**
|
|
4
|
-
* An acquired handler record for a group.
|
|
4
|
+
* An acquired handler record for a group, with the hook that runs before each of them.
|
|
5
5
|
*
|
|
6
6
|
* The private field makes this class nominal: a structurally similar object,
|
|
7
7
|
* including one made by spreading an implementation, is not an implementation.
|
|
@@ -9,28 +9,37 @@ import { Effect } from "effect";
|
|
|
9
9
|
var Implementation = class Implementation {
|
|
10
10
|
group;
|
|
11
11
|
#build;
|
|
12
|
-
|
|
12
|
+
#before;
|
|
13
|
+
constructor(group, build, before) {
|
|
13
14
|
this.group = group;
|
|
14
15
|
this.#build = build;
|
|
16
|
+
this.#before = before;
|
|
15
17
|
}
|
|
16
18
|
/** Acquire the exact handler record in the adapter layer's scope. */
|
|
17
19
|
get build() {
|
|
18
20
|
return this.#build;
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
/** Runs once per invocation, before the selected handler, on every surface. */
|
|
23
|
+
get before() {
|
|
24
|
+
return this.#before;
|
|
25
|
+
}
|
|
26
|
+
static make(group, build, before) {
|
|
27
|
+
return new Implementation(group, build, before);
|
|
22
28
|
}
|
|
23
29
|
};
|
|
24
30
|
/**
|
|
25
31
|
* Select and invoke a handler after an adapter has selected a concrete action.
|
|
32
|
+
* The pre-handler hook runs first, inside the action's span, so every surface
|
|
33
|
+
* applies it exactly once and its failures are declared errors of the action.
|
|
26
34
|
* `R` remains in the returned effect so transport layers cannot erase required
|
|
27
35
|
* per-request services while assembling routes.
|
|
28
36
|
*/
|
|
29
|
-
const dispatch = (group, action, handlers) => {
|
|
37
|
+
const dispatch = (group, action, handlers, before) => {
|
|
30
38
|
const handle = handlers[action.name];
|
|
31
39
|
if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
|
|
32
40
|
return (input) => {
|
|
33
|
-
|
|
41
|
+
const run = () => before === void 0 ? handle(input) : Effect.flatMap(before(action), () => handle(input));
|
|
42
|
+
return Effect.withSpan(Effect.suspend(run), `${group.name}.${action.name}`, { captureStackTrace: false });
|
|
34
43
|
};
|
|
35
44
|
};
|
|
36
45
|
//#endregion
|
package/dist/internal/tools.mjs
CHANGED
|
@@ -55,11 +55,12 @@ const assertMcpObjectInput = (action, tool) => {
|
|
|
55
55
|
};
|
|
56
56
|
const project = (projection, entry) => {
|
|
57
57
|
const tool = projection === "native" ? nativeTool(entry.action) : mcpTool(entry.action);
|
|
58
|
-
if (projection === "
|
|
59
|
-
|
|
58
|
+
if (projection === "native") return tool;
|
|
59
|
+
assertMcpObjectInput(entry.action, tool);
|
|
60
|
+
return tool.annotate(Tool.Strict, true);
|
|
60
61
|
};
|
|
61
62
|
const handler = (projection, app, action, handlers) => {
|
|
62
|
-
const run = (input) => dispatch(app.group, action, handlers)(input);
|
|
63
|
+
const run = (input) => dispatch(app.group, action, handlers, app.before)(input);
|
|
63
64
|
return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
|
|
64
65
|
};
|
|
65
66
|
/**
|
package/docs/Action.md
CHANGED
|
@@ -16,18 +16,22 @@ function make<Name, Input = NoInput, Output, Errors = [], Http = true, Mcp = und
|
|
|
16
16
|
options: Options<Input, Output, Errors, Http, Mcp>,
|
|
17
17
|
): Action<Name, Input, Output, Errors, Http, Mcp>;
|
|
18
18
|
|
|
19
|
+
/** What the action does to its resource. Authorization metadata, not an MCP hint. */
|
|
20
|
+
type Access = "read" | "write";
|
|
21
|
+
|
|
19
22
|
interface Options<Input, Output, Errors, Http, Mcp> {
|
|
20
23
|
readonly description: string;
|
|
21
24
|
readonly input?: Input; // omit for an action without arguments
|
|
22
25
|
readonly success: Output;
|
|
23
26
|
readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
|
|
27
|
+
readonly access?: Access; // defaults to "write"
|
|
24
28
|
readonly http?: Http; // false hides the action from HTTP routes and clients
|
|
25
29
|
readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface McpOptions {
|
|
29
33
|
readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
|
|
30
|
-
readonly readOnly?: boolean; // readOnlyHint, default
|
|
34
|
+
readonly readOnly?: boolean; // readOnlyHint, default access === "read"
|
|
31
35
|
readonly destructive?: boolean; // destructiveHint, default !readOnly
|
|
32
36
|
readonly idempotent?: boolean; // idempotentHint, default false
|
|
33
37
|
readonly openWorld?: boolean; // openWorldHint, default true
|
|
@@ -39,6 +43,7 @@ interface Action<Name, Input, Output, Errors, Http, Mcp> {
|
|
|
39
43
|
readonly input: Input;
|
|
40
44
|
readonly success: Output;
|
|
41
45
|
readonly errors: Errors;
|
|
46
|
+
readonly access: Access; // resolved
|
|
42
47
|
readonly http: Http;
|
|
43
48
|
readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
|
|
44
49
|
}
|
|
@@ -63,27 +68,42 @@ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
|
63
68
|
{ httpApiStatus: 404 },
|
|
64
69
|
) {}
|
|
65
70
|
|
|
71
|
+
// `access: "read"` states the fact once: it is what the group's `before` hook
|
|
72
|
+
// authorizes on, and it is where `mcp.readOnly` comes from.
|
|
66
73
|
export const GetUser = Action.make("getUser", {
|
|
67
74
|
description: "Look up a user in your tenant.",
|
|
68
75
|
input: Schema.Struct({ id: Schema.String }),
|
|
69
76
|
success: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
70
77
|
errors: [UserNotFound],
|
|
71
|
-
|
|
78
|
+
access: "read",
|
|
79
|
+
mcp: { name: "get_user" },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A write. `access` defaults to "write", but say it: the default is a fallback,
|
|
83
|
+
// not a statement, and an unclassified action is the one a reviewer must check.
|
|
84
|
+
export const RenameUser = Action.make("renameUser", {
|
|
85
|
+
description: "Rename a user in your tenant.",
|
|
86
|
+
input: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
87
|
+
success: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
88
|
+
errors: [UserNotFound],
|
|
89
|
+
access: "write",
|
|
90
|
+
mcp: { name: "rename_user", destructive: false },
|
|
72
91
|
});
|
|
73
92
|
|
|
74
93
|
// No arguments: omit `input`. Identity comes from request context, never from input.
|
|
75
94
|
export const WhoAmI = Action.make("whoAmI", {
|
|
76
95
|
description: "Inspect the authenticated actor.",
|
|
77
96
|
success: Schema.Struct({ id: Schema.String, tenantId: Schema.String }),
|
|
78
|
-
|
|
97
|
+
access: "read",
|
|
79
98
|
});
|
|
80
99
|
|
|
81
100
|
// MCP-only: no HTTP route, no client method.
|
|
82
101
|
export const ListChanges = Action.make("listChanges", {
|
|
83
102
|
description: "List renames in your tenant, oldest first.",
|
|
84
103
|
success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
|
|
104
|
+
access: "read",
|
|
85
105
|
http: false,
|
|
86
|
-
mcp: { name: "list_changes"
|
|
106
|
+
mcp: { name: "list_changes" },
|
|
87
107
|
});
|
|
88
108
|
|
|
89
109
|
// Input decoding is the schema's: "21" on the wire, 21 in the handler.
|
|
@@ -91,7 +111,7 @@ export const Double = Action.make("double", {
|
|
|
91
111
|
description: "Double a finite number supplied as a string.",
|
|
92
112
|
input: Schema.Struct({ value: Schema.FiniteFromString }),
|
|
93
113
|
success: Schema.Finite,
|
|
94
|
-
|
|
114
|
+
access: "read",
|
|
95
115
|
});
|
|
96
116
|
```
|
|
97
117
|
|
|
@@ -101,13 +121,15 @@ export const Double = Action.make("double", {
|
|
|
101
121
|
- Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
|
|
102
122
|
- `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
|
|
103
123
|
- A handler may fail only with the declared errors. Anything else is a defect.
|
|
124
|
+
- `access` is `"read"` or `"write"` and defaults to `"write"`, so an action nobody classified is treated as the dangerous one. It is authorization metadata for a group's `before` hook (see [ActionGroup.md](ActionGroup.md)); the library itself authorizes nothing and no adapter reads it.
|
|
125
|
+
- `access` is independent of `mcp`. A local-only action (`mcp: false`) still has one, and an action may set `access: "write"` with `mcp: { readOnly: true }` if the tool hint should say something else. Derive authorization from `access`, never from a tool hint.
|
|
104
126
|
- Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
|
|
105
127
|
- MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
|
|
106
128
|
- `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
|
|
107
|
-
- Hint defaults: `readOnly:
|
|
129
|
+
- Hint defaults: `readOnly: access === "read"`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
|
|
108
130
|
- Schemas must be service-free. Put service access in the handler.
|
|
109
131
|
- The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
|
|
110
|
-
-
|
|
132
|
+
- HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP is not strict, and cannot be made strict without a hack: Effect merges one `HttpApi.ParseOptions` per endpoint and uses it for payload decoding _and_ error encoding, so `onExcessProperty: "error"` also rejects a `TaggedError` instance's own `message` and `stack`, turning a declared 409 into an empty 500. Making the payload schema itself strict would require wrapping it in an open schema, which erases its OpenAPI shape. Declare the fields you accept and treat extra HTTP fields as ignored.
|
|
111
133
|
|
|
112
134
|
## Failure modes
|
|
113
135
|
|
|
@@ -115,3 +137,5 @@ export const Double = Action.make("double", {
|
|
|
115
137
|
- Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
|
|
116
138
|
- `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
|
|
117
139
|
- Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
|
|
140
|
+
- A read action is refused by an authorization hook that reads `access`: `access` was omitted, so it defaulted to `"write"`. Declare it.
|
|
141
|
+
- A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
|
package/docs/ActionCatalog.md
CHANGED
|
@@ -20,6 +20,7 @@ interface Entry {
|
|
|
20
20
|
readonly group: string;
|
|
21
21
|
readonly name: string;
|
|
22
22
|
readonly description: string;
|
|
23
|
+
readonly access: "read" | "write"; // resolved; "write" unless the contract says otherwise
|
|
23
24
|
readonly http: boolean;
|
|
24
25
|
readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
|
|
25
26
|
readonly input: JsonSchema.JsonSchema;
|
|
@@ -47,6 +48,7 @@ process.stdout.write(JSON.stringify(catalog, null, 2));
|
|
|
47
48
|
- Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
|
|
48
49
|
- Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
|
|
49
50
|
- `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
|
|
51
|
+
- `access` is the action's resolved authorization metadata, not a permission. A reader may use it to tell reads from writes; it grants nothing and names no scope.
|
|
50
52
|
- 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
53
|
- The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
|
|
52
54
|
|
package/docs/ActionCli.md
CHANGED
|
@@ -9,15 +9,15 @@ nothing is fetched over HTTP.
|
|
|
9
9
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
10
10
|
|
|
11
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>,
|
|
12
|
+
const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, RB, Parameters = never>(
|
|
13
|
+
app: Implementation<G, H, EX, RX, RB>,
|
|
14
14
|
name: Name,
|
|
15
15
|
options?: Options<SuccessType, Parameters>,
|
|
16
|
-
) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | HandlerContext, Scope>>;
|
|
16
|
+
) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | RB | HandlerContext, Scope>>;
|
|
17
17
|
|
|
18
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>,
|
|
19
|
+
const group: <G, H, EX, RX, RB>(
|
|
20
|
+
app: Implementation<G, H, EX, RX, RB>,
|
|
21
21
|
options?: GroupOptions,
|
|
22
22
|
) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
|
|
23
23
|
|
|
@@ -49,6 +49,7 @@ import { Console, Effect, Logger } from "effect";
|
|
|
49
49
|
import { Command, Flag } from "effect/unstable/cli";
|
|
50
50
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
51
51
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
52
|
+
import { actors, CurrentActor } from "./auth.js";
|
|
52
53
|
import { UserApp } from "./handlers.js";
|
|
53
54
|
import { Users } from "./users.js";
|
|
54
55
|
|
|
@@ -64,6 +65,9 @@ const all = ActionCli.group(UserApp);
|
|
|
64
65
|
Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
65
66
|
Effect.tapCause((cause) => Console.error(cause)),
|
|
66
67
|
Effect.provideService(Logger.LogToStderr, true),
|
|
68
|
+
// The group's `before` hook runs here too, so a local caller supplies the
|
|
69
|
+
// identity it reads exactly as HTTP middleware does for a request.
|
|
70
|
+
Effect.provideService(CurrentActor, actors.alice),
|
|
67
71
|
Effect.provide(Users.layerMemory),
|
|
68
72
|
Effect.provide(NodeServices.layer),
|
|
69
73
|
NodeRuntime.runMain({ disableErrorReporting: true }),
|
|
@@ -80,6 +84,7 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
|
80
84
|
- 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
85
|
- 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
86
|
- 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.
|
|
87
|
+
- The group's pre-handler hook runs here too, before every command's handler. Its services are the caller's to provide, so a local CLI supplies the identity the hook reads exactly as HTTP middleware does. A CLI is not a trusted bypass.
|
|
83
88
|
- Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
|
|
84
89
|
|
|
85
90
|
## Failure modes
|
|
@@ -88,4 +93,5 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
|
88
93
|
- Command rejects `--input`: the command was built with `parameters`. Use its flags.
|
|
89
94
|
- Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
|
|
90
95
|
- Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
|
|
96
|
+
- A command requires a request-identity tag no handler yields: the group's `before` hook yields it. Provide it, or bind a separate implementation without the hook for local use.
|
|
91
97
|
- `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
|
package/docs/ActionGroup.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# ActionGroup
|
|
2
2
|
|
|
3
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
|
|
4
|
+
what `implement` binds handlers to. Shared errors, the pre-handler hook, and the HTTP
|
|
5
|
+
schema-error policy live here.
|
|
5
6
|
|
|
6
7
|
## API
|
|
7
8
|
|
|
@@ -11,7 +12,7 @@ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
|
11
12
|
function make<Name, Actions, Errors = [], PolicyErrors = []>(
|
|
12
13
|
options: Options<Name, Errors, PolicyErrors>,
|
|
13
14
|
...actions: Actions
|
|
14
|
-
): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
|
|
15
|
+
): Group<Name, WithErrors<Actions, Errors>, PolicyErrors, Errors>;
|
|
15
16
|
|
|
16
17
|
interface Options<Name, Errors, PolicyErrors> {
|
|
17
18
|
readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
|
|
@@ -24,20 +25,33 @@ interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
|
|
|
24
25
|
readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
interface
|
|
28
|
+
interface ImplementOptions<Errors, RB> {
|
|
29
|
+
/** Runs before every handler, on every surface. May fail only with the group's `errors`. */
|
|
30
|
+
readonly before: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Group<Name, Actions, PolicyErrors, Errors> {
|
|
28
34
|
readonly name: Name;
|
|
29
35
|
readonly actions: Actions;
|
|
30
36
|
readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
|
|
31
37
|
/** Bind every handler at once; build-time services resolve once per adapter layer. */
|
|
32
|
-
readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
|
|
38
|
+
readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never, RB = never>(
|
|
33
39
|
build: H | Effect.Effect<H, EX, RX>,
|
|
34
|
-
|
|
40
|
+
options?: ImplementOptions<Errors, RB>,
|
|
41
|
+
) => Implementation<
|
|
42
|
+
Group<Name, Actions, PolicyErrors, Errors>,
|
|
43
|
+
H,
|
|
44
|
+
EX,
|
|
45
|
+
Exclude<RX, Scope.Scope>,
|
|
46
|
+
RB
|
|
47
|
+
>;
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
/** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
|
|
38
|
-
class Implementation<G, H, EX, RX> {
|
|
51
|
+
class Implementation<G, H, EX, RX, RB> {
|
|
39
52
|
readonly group: G;
|
|
40
53
|
get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
|
|
54
|
+
get before(): ((action: Action.Any) => Effect.Effect<void, unknown, RB>) | undefined;
|
|
41
55
|
}
|
|
42
56
|
```
|
|
43
57
|
|
|
@@ -50,7 +64,8 @@ all required.
|
|
|
50
64
|
import { Effect, Schema } from "effect";
|
|
51
65
|
import type { HttpApiError } from "effect/unstable/httpapi";
|
|
52
66
|
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
53
|
-
import
|
|
67
|
+
import type * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
68
|
+
import { Forbidden, CurrentActor } from "./auth.js";
|
|
54
69
|
import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
|
|
55
70
|
import { Users } from "./users.js";
|
|
56
71
|
|
|
@@ -82,28 +97,29 @@ export const UserActions = ActionGroup.make(
|
|
|
82
97
|
WhoAmI,
|
|
83
98
|
);
|
|
84
99
|
|
|
100
|
+
// One rule for the whole group, read from each contract's own `access`. It runs
|
|
101
|
+
// on HTTP, MCP, Toolkit and CLI alike, so no handler contains authorization code.
|
|
102
|
+
const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
103
|
+
const permission = action.access === "read" ? "users:read" : "users:write";
|
|
104
|
+
const actor = yield* CurrentActor;
|
|
105
|
+
|
|
106
|
+
if (!actor.permissions.includes(permission)) return yield* new Forbidden({ permission });
|
|
107
|
+
});
|
|
108
|
+
|
|
85
109
|
// Build-time services (Users) are yielded in the builder, once per adapter layer.
|
|
86
|
-
// Request-time services (CurrentActor) are yielded inside handlers
|
|
110
|
+
// Request-time services (CurrentActor) are yielded inside handlers and in `before`.
|
|
87
111
|
export const UserApp = UserActions.implement(
|
|
88
112
|
Effect.gen(function* () {
|
|
89
113
|
const users = yield* Users;
|
|
90
114
|
|
|
91
115
|
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
|
-
}),
|
|
116
|
+
getUser: ({ id }) => Effect.flatMap(CurrentActor, (actor) => users.get(actor.tenantId, id)),
|
|
98
117
|
renameUser: ({ id, name }) =>
|
|
99
|
-
Effect.
|
|
100
|
-
const actor = yield* authorize("users:write");
|
|
101
|
-
|
|
102
|
-
return yield* users.rename(actor, id, name);
|
|
103
|
-
}),
|
|
118
|
+
Effect.flatMap(CurrentActor, (actor) => users.rename(actor, id, name)),
|
|
104
119
|
whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
|
|
105
120
|
};
|
|
106
121
|
}),
|
|
122
|
+
{ before: authorize },
|
|
107
123
|
);
|
|
108
124
|
|
|
109
125
|
// No build-time services: pass the record directly.
|
|
@@ -120,15 +136,24 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
|
|
|
120
136
|
- `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
|
|
121
137
|
- `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
138
|
|
|
139
|
+
### Pre-handler hook
|
|
140
|
+
|
|
141
|
+
- `implement(handlers, { before })` binds one hook to the whole implementation. It runs once per invocation, immediately before the selected handler, on **every** surface: `ActionHttp`, `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, `ActionToolkit`, and `ActionCli`. There is no surface that skips it and no per-action opt-out.
|
|
142
|
+
- It receives the selected action contract (`Action.Any`), so a policy reads `action.access`, `action.name` or `action.mcp` instead of a hand-maintained list. Write authorization against `access`.
|
|
143
|
+
- It may fail only with the group's own `errors`, because those are the failures every action of the group declares. A hook that fails with anything else is a compile error; a group with no `errors` has no failure a hook could use. Its failure is encoded exactly like a handler's: the error's `httpApiStatus` on HTTP, an `isError` tool result on MCP.
|
|
144
|
+
- Services it yields are request-time requirements, like a handler's, and they join the adapter's request context. A local caller (`ActionCli`, `ActionToolkit`, stdio) supplies them itself.
|
|
145
|
+
- It runs inside the `<group>.<action>` span, before the handler. When it fails, the handler never runs.
|
|
146
|
+
- It is not authentication. Establish identity in middleware, then let the hook authorize what that identity may do.
|
|
147
|
+
|
|
123
148
|
### Schema-error policy
|
|
124
149
|
|
|
125
150
|
- Without a policy, HTTP answers decoding and encoding failures with Effect's native empty 400.
|
|
126
151
|
- 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)
|
|
152
|
+
- `kind` values: `Payload` (input decoding) and `Body` (success encoding). Actions declare no parameters, query, or headers, so no other kind occurs.
|
|
128
153
|
- MCP is unaffected. The native `McpServer` answers invalid arguments and unencodable results itself.
|
|
129
154
|
- Policy errors extend the transport contract, not the handler contract. A handler cannot return them.
|
|
130
155
|
- The policy runs only on the server. Client-side codec failures stay `SchemaError`.
|
|
131
|
-
- `cause`
|
|
156
|
+
- HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
|
|
132
157
|
- Groups served by one adapter may have different policies; each action answers with its own group's.
|
|
133
158
|
- 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
159
|
|
|
@@ -139,3 +164,6 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
|
|
|
139
164
|
- 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
165
|
- `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
166
|
- 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.
|
|
167
|
+
- Type error on `before`'s error channel: the hook fails with an error the group does not declare. Add it to the group's `errors` so every action declares it.
|
|
168
|
+
- `Type 'X' is not assignable to type 'never'` on `before` in a group without `errors`: the group declares no shared failure. Declare one.
|
|
169
|
+
- The hook does not run for an action: it cannot happen through an adapter. A handler invoked directly through `build` is not dispatched and is not hooked; use an adapter, or `ActionCli`, in tests that must exercise the policy.
|
package/docs/ActionHttp.md
CHANGED
|
@@ -8,13 +8,17 @@ of groups into a contract value shared by the server, every client, and the Open
|
|
|
8
8
|
```ts
|
|
9
9
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
10
10
|
|
|
11
|
-
function make<const G extends ReadonlyArray<Actions
|
|
11
|
+
function make<const G extends ReadonlyArray<Actions>, const E extends ReadonlyArray<Codec> = []>(
|
|
12
|
+
options: Options<E>,
|
|
13
|
+
...groups: G
|
|
14
|
+
): Http<G, E>;
|
|
12
15
|
|
|
13
|
-
interface Options {
|
|
16
|
+
interface Options<Errors = []> {
|
|
14
17
|
readonly apiPath: `/${string}`; // no default
|
|
18
|
+
readonly errors?: Errors; // failures the surface answers with, declared on every endpoint
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
interface Http<Groups> {
|
|
21
|
+
interface Http<Groups, Errors = []> {
|
|
18
22
|
readonly groups: Groups; // the exact contracts bound, in declaration order
|
|
19
23
|
readonly api: HttpApi.HttpApi<"actions", ...>; // native HttpApi, one HttpApiGroup per group
|
|
20
24
|
/** Serve implementations. Errors and requirements are unions over exactly these apps. */
|
|
@@ -42,10 +46,16 @@ import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"
|
|
|
42
46
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
43
47
|
import { PublicActions, UserActions } from "./contracts.js";
|
|
44
48
|
import { PublicApp, UserApp } from "./handlers.js";
|
|
45
|
-
import { authentication } from "./auth.js";
|
|
46
|
-
|
|
47
|
-
// Contract: shared by server and clients.
|
|
48
|
-
|
|
49
|
+
import { authentication, Unauthenticated } from "./auth.js";
|
|
50
|
+
|
|
51
|
+
// Contract: shared by server and clients. `errors` are the failures the surface
|
|
52
|
+
// itself renders — here the 401 from `authentication` — so a typed client decodes
|
|
53
|
+
// them instead of reporting a decode error on an unexpected status.
|
|
54
|
+
export const Http = ActionHttp.make(
|
|
55
|
+
{ apiPath: "/api/actions", errors: [Unauthenticated] },
|
|
56
|
+
PublicActions,
|
|
57
|
+
UserActions,
|
|
58
|
+
);
|
|
49
59
|
|
|
50
60
|
// One layer per middleware set. Middleware provided to a layer applies to that layer only.
|
|
51
61
|
const routes = Layer.mergeAll(
|
|
@@ -85,12 +95,15 @@ export const greeting = Effect.gen(function* () {
|
|
|
85
95
|
## Rules
|
|
86
96
|
|
|
87
97
|
- `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.
|
|
98
|
+
- `errors` declares the failures the surface around these endpoints answers with rather than a handler: authentication, authorization, rate limiting, upstream unavailability. They are added to every endpoint's error schemas, so `HttpApiClient`, `ActionCliClient` and the in-memory `Testing.httpClient` decode them as typed failures, and they appear in OpenAPI on every operation. A schema an action already declares is not repeated.
|
|
99
|
+
- No two schemas reachable from one endpoint may share an `httpApiStatus`: the client selects the decoder by status. Keep surface statuses (401, 403, 429, 503) apart from action statuses.
|
|
100
|
+
- `errors` changes only what is declared. Nothing produces them: the middleware that renders those responses must encode a body that matches the schema, or the client sees a decode error again. Handlers cannot fail with them.
|
|
88
101
|
- `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
102
|
- Middleware is per layer call. Groups that need different middleware go in separate `Http.layer` calls, merged with `Layer.mergeAll`.
|
|
90
103
|
- 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
104
|
- `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
105
|
- 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.
|
|
106
|
+
- Client effects fail with the declared errors, the group's policy errors, the binding's `errors`, `SchemaError` for local codec failures, and native `HttpClientError`. MCP-only actions are absent from the client.
|
|
94
107
|
- 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
108
|
- 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
109
|
- Each handler runs in a span named `<group>.<action>`, a child of the request span. Decoding and encoding are outside it.
|
|
@@ -104,3 +117,5 @@ export const greeting = Effect.gen(function* () {
|
|
|
104
117
|
- Client method missing for an action: the action is `http: false`.
|
|
105
118
|
- 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
119
|
- 415: wrong or missing content type.
|
|
120
|
+
- `HttpClientError: Decode error (401 POST ...)` from a typed client: the surface answered with a status no endpoint declares. Add that error schema to `ActionHttp.make`'s `errors`.
|
|
121
|
+
- A surface error decodes as the wrong type: two schemas share an `httpApiStatus` on the same endpoint. Give them distinct statuses.
|
package/docs/ActionMcp.md
CHANGED
|
@@ -118,9 +118,12 @@ Layer.launch(layer).pipe(
|
|
|
118
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
119
|
- Tool discovery is not filtered by actor. Every caller sees every tool of the endpoint. Authorization happens in the handler.
|
|
120
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.
|
|
121
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.
|
|
122
123
|
- Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
|
|
123
124
|
|
|
125
|
+
- The group's pre-handler hook runs before every tool call. A refusal is the tool's declared failure: an `isError` result whose text is the error's JSON encoding, exactly as for a handler failure.
|
|
126
|
+
|
|
124
127
|
## Failure modes
|
|
125
128
|
|
|
126
129
|
- `<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`.
|
package/docs/ActionToolkit.md
CHANGED
|
@@ -64,6 +64,8 @@ With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText
|
|
|
64
64
|
- Supply identity at invocation, never when building the layer. Native context capture is not a security boundary.
|
|
65
65
|
- This is not an MCP server. Use `ActionMcp` to expose the same actions to external clients.
|
|
66
66
|
|
|
67
|
+
- The group's pre-handler hook runs before every tool call, and its services join each tool's requirements. The caller supplies them, as it does a handler's.
|
|
68
|
+
|
|
67
69
|
## Failure modes
|
|
68
70
|
|
|
69
71
|
- Tool missing from the toolkit: the action has `mcp: false`. The Toolkit selects by MCP enablement.
|
package/docs/Authentication.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Two helpers for hosts that own identity: router middleware that provides a request-scoped
|
|
4
4
|
identity service, and RFC 9728 protected-resource discovery with bearer challenges. Token
|
|
5
|
-
verification, login, and consent stay in the application.
|
|
5
|
+
verification, login, and consent stay in the application. Authorization is a group's
|
|
6
|
+
pre-handler hook ([ActionGroup.md](ActionGroup.md)), not part of this module.
|
|
6
7
|
|
|
7
8
|
## API
|
|
8
9
|
|
|
@@ -103,7 +104,8 @@ export const routes = Layer.mergeAll(
|
|
|
103
104
|
- `protectedResource` publishes what it is given. The deployment must ensure `resource` and `authorizationServers` are valid OAuth URLs (HTTPS, or loopback HTTP in development).
|
|
104
105
|
- 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
106
|
- `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
|
|
107
|
+
- Tool discovery is never filtered by actor. Authorization belongs in the group's `before` hook, which runs on every surface with the action contract in hand; write the rule against `action.access` rather than repeating a check in each handler.
|
|
108
|
+
- A response this middleware renders is not part of any endpoint's contract. Declare its schema in `ActionHttp.make`'s `errors` so typed clients decode the 401 instead of reporting a decode error ([ActionHttp.md](ActionHttp.md)).
|
|
107
109
|
|
|
108
110
|
## Failure modes
|
|
109
111
|
|
|
@@ -112,3 +114,4 @@ export const routes = Layer.mergeAll(
|
|
|
112
114
|
- 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
115
|
- Discovery requires a token: `discovery.layer` was placed under the authentication middleware. Mount it separately.
|
|
114
116
|
- 401 response lacks `WWW-Authenticate`: the host's failure response did not set it. Use `discovery.challenge(...)` in its headers.
|
|
117
|
+
- A typed client reports `Decode error (401 ...)` instead of the failure schema: the response this middleware renders is not declared. Add it to `ActionHttp.make`'s `errors`.
|
package/docs/CONTEXT.md
CHANGED
|
@@ -5,12 +5,14 @@ use these words with these meanings.
|
|
|
5
5
|
|
|
6
6
|
## Terms
|
|
7
7
|
|
|
8
|
-
- **Action**: one contract. A name, a description, an input schema, a success schema, declared error schemas, and transport metadata (`http`, `mcp`). Holds no behavior. Made by `Action.make`.
|
|
8
|
+
- **Action**: one contract. A name, a description, an input schema, a success schema, declared error schemas, authorization metadata (`access`), and transport metadata (`http`, `mcp`). Holds no behavior. Made by `Action.make`.
|
|
9
|
+
- **Access**: `"read"` or `"write"` on an action, defaulting to `"write"`. States what the action does to its resource. Authorization metadata read by a pre-handler hook, independent of the MCP `readOnly` hint, which defaults from it.
|
|
9
10
|
- **Group**: a named, ordered set of actions plus group-level errors and an optional schema-error policy. Adapters serve groups, clients are organized by group, and the group name is the OpenAPI tag. Made by `ActionGroup.make`.
|
|
10
11
|
- **Contract**: an action or a group. Pure data; safe to import anywhere, including browsers.
|
|
11
12
|
- **Handler**: a function from decoded input to an Effect of decoded success, failing only with the action's declared errors, possibly requiring services.
|
|
12
13
|
- **Implementation**: a nominal binding of a complete handler record to a group, made by `group.implement`. Carries build-time error and requirement channels. Adapters accept implementations; `ActionCatalog` accepts contracts.
|
|
13
14
|
- **Builder**: the record or Effect passed to `implement`. Runs once per adapter layer that serves the implementation.
|
|
15
|
+
- **Pre-handler hook**: the `before` function passed to `implement`. Runs once per invocation, on every surface, with the selected action contract, before its handler. May fail only with the group's own errors. Its services are request-time requirements.
|
|
14
16
|
- **Build-time requirement**: a service yielded in the builder. Resolved when an adapter layer is built.
|
|
15
17
|
- **Request-time requirement**: a service yielded inside a handler. Supplied per invocation: by router middleware for HTTP-hosted adapters, by the host for Toolkit, CLI, and stdio.
|
|
16
18
|
- **Adapter** (also **projection**): a module that maps implementations or contracts onto one surface: `ActionHttp`, `ActionMcp`, `ActionToolkit`, `ActionCli`, `ActionCliClient`, `ActionCatalog`. Each adapter reads only what it serves and validates only its own namespace.
|
|
@@ -18,6 +20,7 @@ use these words with these meanings.
|
|
|
18
20
|
- **Declared error**: an error schema listed on an action or inherited from its group. Handlers may fail with it. Encoded on every transport with its `httpApiStatus`.
|
|
19
21
|
- **Schema-error policy**: a group option mapping Effect's `HttpApiSchemaError` (decode or encode failure) to a declared policy error with a status. HTTP only.
|
|
20
22
|
- **Policy error**: an error a schema-error policy may answer with. Part of the transport contract, never returnable by a handler.
|
|
23
|
+
- **Surface error**: an error declared on an `ActionHttp` binding rather than on an action. Produced by middleware around the endpoints, never by a handler; declared so typed clients decode it.
|
|
21
24
|
- **Local-only action**: `http: false` and `mcp: false`. Reachable through `ActionCli` and listed in the catalog.
|
|
22
25
|
- **Tool**: the MCP or Toolkit projection of an MCP-enabled action, named by `mcp.name` and carrying its hints.
|
|
23
26
|
- **Endpoint**: one `ActionMcp.layerHttp` mount. One route, one middleware set, one tool registry.
|
|
@@ -31,6 +34,6 @@ use these words with these meanings.
|
|
|
31
34
|
- `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.
|
|
32
35
|
- 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.
|
|
33
36
|
- 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 and defines no application error types.
|
|
34
|
-
- Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application.
|
|
37
|
+
- Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application. Authorization is the application's pre-handler hook, which the library runs but never writes.
|
|
35
38
|
- `Testing` depends only on `effect`. `TestingClient` is the single module that loads the optional MCP client peer.
|
|
36
39
|
- 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.
|
package/docs/README.md
CHANGED
|
@@ -17,9 +17,9 @@ Package facts that apply everywhere:
|
|
|
17
17
|
| Read | When you need to |
|
|
18
18
|
| ---------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
19
19
|
| [setup.md](setup.md) | install, pin versions, pick entry points, know what the package does not do |
|
|
20
|
-
| [Action.md](Action.md) | define one contract: input, success, errors, HTTP and MCP flags
|
|
21
|
-
| [ActionGroup.md](ActionGroup.md) | group contracts, declare shared errors,
|
|
22
|
-
| [ActionHttp.md](ActionHttp.md) | serve JSON POST routes, publish OpenAPI, call
|
|
20
|
+
| [Action.md](Action.md) | define one contract: input, success, errors, read/write access, HTTP and MCP flags |
|
|
21
|
+
| [ActionGroup.md](ActionGroup.md) | group contracts, declare shared errors, run one check before every handler, bind them |
|
|
22
|
+
| [ActionHttp.md](ActionHttp.md) | serve JSON POST routes, declare the surface's own errors, publish OpenAPI, call it |
|
|
23
23
|
| [ActionMcp.md](ActionMcp.md) | serve MCP tools over Streamable HTTP or stdio |
|
|
24
24
|
| [ActionToolkit.md](ActionToolkit.md) | use actions as a native Effect AI `Toolkit` without a server |
|
|
25
25
|
| [ActionCli.md](ActionCli.md) | run handlers in-process from a terminal command |
|
|
@@ -54,7 +54,7 @@ const Greet = Action.make("greet", {
|
|
|
54
54
|
description: "Greet someone by name.",
|
|
55
55
|
input: Schema.Struct({ name: Schema.String }),
|
|
56
56
|
success: Schema.String,
|
|
57
|
-
|
|
57
|
+
access: "read",
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
export const Actions = ActionGroup.make({ name: "greetings" }, Greet);
|
package/docs/guarantees.md
CHANGED
|
@@ -5,10 +5,12 @@ Rules that hold across every adapter. Module pages link here rather than repeati
|
|
|
5
5
|
## Dependency lifetimes
|
|
6
6
|
|
|
7
7
|
- Handlers receive decoded input, return decoded results, and may fail only with declared errors. Anything else is a defect.
|
|
8
|
+
- A group's pre-handler hook (`implement(handlers, { before })`) runs once per invocation on every surface, before the selected handler, inside that handler's span. It may fail only with the group's own errors, and its services are request-time requirements like a handler's. No adapter can skip it; only calling `build` directly does, and that is not dispatch.
|
|
8
9
|
- Build-time services are those yielded in the `implement` builder Effect. They resolve once per adapter layer, in that layer's scope. An implementation served by two adapters is built twice. Share state through a Layer provided to the adapters, never through the builder.
|
|
9
10
|
- Request-time services are those yielded inside a handler. For HTTP-hosted adapters (`ActionHttp`, `ActionMcp.layerHttp`) they appear as `HttpRouter.Request.From<"Requires", R>` and are supplied by router middleware, `HttpRouter.provideRequest`, or request context. For `ActionToolkit`, `ActionCli`, and `ActionMcp.layerStdio` the host supplies them at invocation.
|
|
10
11
|
- Use distinct tags for build-time capabilities and request-scoped identity. Never provide an identity or tenant tag in a startup layer or root context. The adapters use native Effect context capture and merging: a startup value under a tag can shadow a request value or satisfy a missing one. Types verify presence, not provenance.
|
|
11
|
-
- Authentication must establish identity on every protected request. Types cannot verify that a handler performed authorization.
|
|
12
|
+
- Authentication must establish identity on every protected request. Types cannot verify that a handler performed authorization; the `before` hook is the one place the library guarantees runs first, so put the check there rather than in each handler.
|
|
13
|
+
- `access` is contract metadata. Nothing in the library reads it: adapters ignore it and the catalog only reports it. An action defaults to `access: "write"` so a hook that fails closed does so without a list of exceptions.
|
|
12
14
|
- `ActionCli` acquires the implementation per invocation and releases it after the call. `ActionToolkit.layer` acquires it once for the layer's lifetime.
|
|
13
15
|
|
|
14
16
|
## Wire behavior
|
|
@@ -20,6 +22,7 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
|
|
|
20
22
|
| Input | decoded with the schema's JSON codec; failure is an empty **400** | decoded with the schema's JSON codec; failure is `InvalidParams`, presented as an `isError` result |
|
|
21
23
|
| Success | the success codec's encoding as the body: `{"id":"1","name":"Ada"}`, `42` | `structuredContent: { value: <encoded> }` |
|
|
22
24
|
| Declared error | encoded by its schema with its `httpApiStatus` (unannotated: 500): `{"_tag":"UserNotFound","id":"x"}` | `isError: true`; text content is the same JSON; no `structuredContent` |
|
|
25
|
+
| Pre-handler hook failure | the same, using the hook's error schema; the handler never runs | the same `isError` result; the handler never runs |
|
|
23
26
|
| Invalid output encoding | empty **400** (`HttpApiSchemaError`) | `isError: true`, text `Tool execution failed due to an internal server error.`; cause logged, not sent |
|
|
24
27
|
| Defect | empty **500** | same generic `isError` result; cause logged, not sent |
|
|
25
28
|
| Unknown path or method | 404 | n/a |
|
|
@@ -27,7 +30,8 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
|
|
|
27
30
|
|
|
28
31
|
- A group `schemaError` policy replaces the empty 400s on HTTP with a declared error and status. It never affects MCP. Details in [ActionGroup.md](ActionGroup.md).
|
|
29
32
|
- Routes are `POST <apiPath>/<group>/<action>`. Operation IDs are `<group>.<action>`. Effect generates OpenAPI component names and references.
|
|
30
|
-
-
|
|
33
|
+
- `ActionHttp.make`'s `errors` are declared on every endpoint so clients decode the surface's own failures (401, 403, 429, 503). They are HTTP-only, produced by middleware rather than by the library, and never returnable by a handler. MCP is unaffected.
|
|
34
|
+
- HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP is not strict, and cannot be made strict without a hack: Effect merges one `HttpApi.ParseOptions` per endpoint and uses it for payload decoding _and_ error encoding, so `onExcessProperty: "error"` also rejects a `TaggedError` instance's own `message` and `stack`, turning a declared 409 into an empty 500. Making the payload schema itself strict would require wrapping it in an open schema, which erases its OpenAPI shape. Declare the fields you accept and treat extra HTTP fields as ignored.
|
|
31
35
|
|
|
32
36
|
## Namespaces
|
|
33
37
|
|
|
@@ -51,5 +55,5 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
|
|
|
51
55
|
|
|
52
56
|
- HTTP is JSON `POST` on `HttpApi`, not Effect RPC. MCP is native `McpServer`, no SDK runtime dependency.
|
|
53
57
|
- Actions are unary: no streaming, uploads, prompts, resources, retries, or code-execution sandbox.
|
|
54
|
-
- Authentication and authorization are the application's. Tool discovery is not filtered by actor. Production needs real token validation, MCP authorization discovery, request limits, and error reporting
|
|
58
|
+
- Authentication and authorization are the application's. The library supplies the seams: `Authentication.middleware` for identity, `implement({ before })` for one authorization rule per group, `access` for what a rule reads, and `ActionHttp.make`'s `errors` for what a client can decode. It defines no error types, no scopes and no verifier. Tool discovery is not filtered by actor. Production needs real token validation, MCP authorization discovery, request limits, and error reporting.
|
|
55
59
|
- The catalog is descriptive. Presence in it grants nothing.
|
package/docs/setup.md
CHANGED
|
@@ -54,7 +54,7 @@ What the package does, and nothing else:
|
|
|
54
54
|
- HTTP means JSON `POST` endpoints built on Effect's `HttpApi`. It is not Effect RPC.
|
|
55
55
|
- MCP means one native `McpServer` `Tool` per action. There is no MCP SDK runtime dependency.
|
|
56
56
|
- Actions are unary: one decoded input, one decoded success or one declared error. No streaming, uploads, prompts, resources, retries, or code-execution sandbox.
|
|
57
|
-
- Authentication and authorization are the application's. The library provides
|
|
57
|
+
- Authentication and authorization are the application's. The library provides the seams — `Authentication.middleware` for identity, `implement({ before })` for one authorization rule per group, `access` for what that rule reads, `ActionHttp.make`'s `errors` for what a client can decode — and no token verifier, error types or scopes of its own. Tool discovery is never filtered by actor.
|
|
58
58
|
|
|
59
59
|
## Failure modes
|
|
60
60
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/effect-actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Define Effect action contracts once and project them onto HTTP, MCP, native Toolkits, catalogs, and CLIs.",
|
|
5
5
|
"homepage": "https://github.com/gjermundgaraba/effect-actions#readme",
|
|
6
6
|
"bugs": {
|