@gjermundgaraba/effect-actions 0.3.0 → 0.4.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 CHANGED
@@ -30,7 +30,7 @@ const app = Actions.implement({
30
30
  });
31
31
 
32
32
  export const routes = Layer.mergeAll(
33
- Http.layer(app),
33
+ Http.layer({}, app),
34
34
  ActionMcp.layerHttp(
35
35
  { protocols: [McpProtocol.v2026_07_28], name: "greetings", version: "1.0.0", path: "/mcp" },
36
36
  app,
@@ -42,7 +42,7 @@ Serve `routes` with Effect's `HttpRouter` and you have `POST /api/actions/greeti
42
42
  and an MCP tool named `greet` at `/mcp`. The same `app` is also:
43
43
 
44
44
  ```ts
45
- const { toolkit, layer } = ActionToolkit.make(app); // native Effect AI Toolkit and its handler layer
45
+ const { toolkit, layer } = ActionToolkit.make({}, app); // native Effect AI Toolkit and its handler layer
46
46
  const cli = ActionCli.group(app); // greetings greet --input '{"name":"Ada"}'
47
47
  const remote = ActionCliClient.command(Http, "greetings", "greet"); // same command, over HTTP
48
48
  const catalog = ActionCatalog.make(Actions); // offline JSON contract, no handlers acquired
@@ -61,9 +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, 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.
64
+ - A handler may fail only with the errors its action declares. Each surface binds one `before` hook that runs before every handler it serves — on HTTP before the payload is even decoded — so a policy such as scope enforcement is written once per surface and no action of that surface can skip it.
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
+ - Every action states its `access` (`"read"` or `"write"`, required), so authorization reads the contract instead of a hand-maintained list of mutation names.
67
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.
68
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.
69
69
 
package/dist/Action.d.mts CHANGED
@@ -30,7 +30,7 @@ type ResolvedMcp<Name extends string, Mcp extends false | McpOptions | undefined
30
30
  readonly openWorld: boolean;
31
31
  };
32
32
  /** What `make` needs to define an action. */
33
- export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
33
+ export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Acc extends Access = Access, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
34
34
  readonly description: string;
35
35
  /** Omit for an action without arguments. */
36
36
  readonly input?: Input;
@@ -38,29 +38,29 @@ export interface Options<Input extends Codec, Output extends Codec, Errors exten
38
38
  /** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
39
39
  readonly errors?: Errors;
40
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.
41
+ * What the action does to its resource. Required: an action nobody classified
42
+ * is the one a reviewer must check. Read by a surface's `before` hook; the
43
+ * library itself authorizes nothing.
44
44
  */
45
- readonly access?: Access;
45
+ readonly access: Acc;
46
46
  /** `false` hides the action from HTTP routes and clients. */
47
47
  readonly http?: Http;
48
48
  /** `false` hides the action from MCP; otherwise tool metadata. */
49
49
  readonly mcp?: Mcp;
50
50
  }
51
51
  /** A pure contract: schemas and transport metadata. Implementations are bound by `ActionGroup.implement`. */
52
- export interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
52
+ export interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Acc extends Access = Access, Http extends boolean = boolean, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
53
53
  readonly name: Name;
54
54
  readonly description: string;
55
55
  readonly input: Input;
56
56
  readonly success: Output;
57
57
  readonly errors: Errors;
58
- readonly access: Access;
58
+ readonly access: Acc;
59
59
  readonly http: Http;
60
60
  readonly mcp: ResolvedMcp<Name, Mcp>;
61
61
  }
62
62
  /** Any action, with its schemas erased. */
63
- export type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, false> | Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, McpOptions> | Action<string, Codec, Codec, ReadonlyArray<Codec>, boolean, undefined>;
63
+ export type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>, Access, boolean, false> | Action<string, Codec, Codec, ReadonlyArray<Codec>, Access, boolean, McpOptions> | Action<string, Codec, Codec, ReadonlyArray<Codec>, Access, boolean, undefined>;
64
64
  /** Receives decoded input; may fail only with the declared errors. */
65
65
  export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
66
66
  /** An empty object schema that also produces the object root MCP requires. */
@@ -69,6 +69,6 @@ declare const NoInput: Schema.$Record<Schema.String, Schema.Never>;
69
69
  * Define an action contract. Names are `[A-Za-z0-9_-]+`, other than `then`.
70
70
  * Actions can be local-only by setting both transports to `false`.
71
71
  */
72
- export declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Http extends boolean = true, const Mcp extends false | McpOptions | undefined = undefined>(name: Name, options: Options<Input, Output, Errors, Http, Mcp>): Action<Name, Input, Output, Errors, Http, Mcp>;
72
+ export declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Acc extends Access = Access, const Http extends boolean = true, const Mcp extends false | McpOptions | undefined = undefined>(name: Name, options: Options<Input, Output, Errors, Acc, Http, Mcp>): Action<Name, Input, Output, Errors, Acc, Http, Mcp>;
73
73
  //#endregion
74
74
  export { Action_d_exports };
package/dist/Action.mjs CHANGED
@@ -5,7 +5,7 @@ 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 access = options.access ?? "write";
8
+ const { access } = options;
9
9
  const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? access === "read";
10
10
  const mcp = options.mcp === false ? false : {
11
11
  name: options.mcp?.name ?? name,
@@ -1,4 +1,4 @@
1
- import { Access, Action_d_exports, Any } from "./Action.mjs";
1
+ import { 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,8 +8,6 @@ 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;
13
11
  readonly http: boolean;
14
12
  readonly mcp: Any["mcp"];
15
13
  readonly input: JsonSchema.JsonSchema;
@@ -23,7 +23,6 @@ const make = (...groups) => {
23
23
  group: group.name,
24
24
  name: action.name,
25
25
  description: action.description,
26
- access: action.access,
27
26
  http: action.http,
28
27
  mcp: action.mcp,
29
28
  input: jsonSchema(action.input),
@@ -1,12 +1,22 @@
1
1
  import { Action_d_exports, Any, Handler } from "./Action.mjs";
2
2
  import { Actions } from "./internal/actions.mjs";
3
3
  import { HandlerContext, Handlers, HandlersContext, Implementation } from "./internal/implementation.mjs";
4
- import { Options } from "./internal/cli.mjs";
5
- import { Scope } from "effect";
4
+ import { Options as Options$1 } from "./internal/cli.mjs";
5
+ import { Effect, Scope } from "effect";
6
6
  import { Command } from "effect/unstable/cli";
7
7
  //#region src/ActionCli.d.ts
8
+ /**
9
+ * The hook a local command runs before the selected handler. A CLI encodes
10
+ * nothing, so its refusal is a typed failure of the command effect rather than a
11
+ * declared surface error; the native parser has already decoded the input.
12
+ */
13
+ interface BeforeOptions<E, R> {
14
+ readonly before?: (action: Any) => Effect.Effect<void, E, R>;
15
+ }
16
+ /** Parsing, rendering and the pre-handler hook of one local command. */
17
+ export type Options<Output, E = never, R = never, Parameters extends Command.Command.Config = never> = Options$1<Output, Parameters> & BeforeOptions<E, R>;
8
18
  /** Configuration for an aggregate group command. */
9
- export interface GroupOptions {
19
+ export interface GroupOptions<E = never, R = never> extends BeforeOptions<E, R> {
10
20
  /** Override the group command name. */
11
21
  readonly name?: string;
12
22
  }
@@ -18,10 +28,7 @@ type BoundHandler<Name extends string, A extends Any, R> = Readonly<Record<Name,
18
28
  * Project one explicitly selected local action into a native Effect CLI command.
19
29
  * The action name resolves against the bound group; no HTTP fallback exists.
20
30
  */
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
- readonly name: Name;
23
- }>["errors"][number]["Type"], Exclude<RB, Scope.Scope> | Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
31
+ 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, EB = never, RB = never, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], EB, RB, Parameters>) => Command.Command<string, never, {}, unknown, Exclude<RB, Scope.Scope> | Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
24
32
  /** 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, 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
- //#endregion
27
- export type { Options };
33
+ export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX, EB = never, RB = never>(app: Implementation<G, H, EX, RX>, options?: GroupOptions<EB, RB>) => Command.Command<string, {}, {}, EB | 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>>>;
34
+ //#endregion
@@ -8,18 +8,19 @@ 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, app.before)(input)));
11
+ const local = (app, action, before, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, 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.
15
15
  */
16
16
  const command = (app, name, options) => {
17
17
  const action = select(app.group, name);
18
- return command$1(action, (input) => local(app, action, input), options);
18
+ return command$1(action, (input) => local(app, action, options?.before, input), 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, app.before)(input)))));
22
+ const before = options?.before;
23
+ const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, before)(input)))));
23
24
  return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
24
25
  };
25
26
  //#endregion
@@ -3,9 +3,9 @@ import { Actions } from "./internal/actions.mjs";
3
3
  import { Options as Options$1 } from "./internal/cli.mjs";
4
4
  import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
5
5
  import { Effect } from "effect";
6
+ import { HttpClient } from "effect/unstable/http";
6
7
  import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
7
8
  import { Command } from "effect/unstable/cli";
8
- import { HttpClient } from "effect/unstable/http";
9
9
  //#region src/ActionCliClient.d.ts
10
10
  /** Parsing, rendering and native HTTP-endpoint configuration for one remote action. */
11
11
  export type Options<Output, ParsedParameters extends Command.Command.Config = never> = Options$1<Output, ParsedParameters> & {
@@ -1,8 +1,8 @@
1
1
  import { command as command$1 } from "./internal/cli.mjs";
2
2
  import { Effect } from "effect";
3
+ import { HttpClient } from "effect/unstable/http";
3
4
  import { HttpApiClient } from "effect/unstable/httpapi";
4
5
  import { Command } from "effect/unstable/cli";
5
- import { HttpClient } from "effect/unstable/http";
6
6
  //#region src/ActionCliClient.ts
7
7
  /**
8
8
  * The native endpoint builder needs string selectors for a runtime-selected route.
@@ -3,11 +3,11 @@ 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, ImplementOptions, Implementation, Options, SchemaErrorPolicy, make };
6
+ export { Any, Contracts, Group, Implementation, Options, SchemaErrorPolicy, contracts, 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. */
10
- type WithErrors<Actions extends ReadonlyArray<Any$1>, Errors extends ReadonlyArray<Codec>> = { readonly [K in keyof Actions]: Actions[K] extends Action<infer Name, infer Input, infer Output, infer Own, infer Http, infer Mcp> ? Action<Name, Input, Output, readonly [...Own, ...Errors], Http, Mcp> : never; };
10
+ type WithErrors<Actions extends ReadonlyArray<Any$1>, Errors extends ReadonlyArray<Codec>> = { readonly [K in keyof Actions]: Actions[K] extends Action<infer Name, infer Input, infer Output, infer Own, infer Access, infer Http, infer Mcp> ? Action<Name, Input, Output, readonly [...Own, ...Errors], Access, Http, Mcp> : never; };
11
11
  /** What `make` needs to define a group. */
12
12
  export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>, PolicyErrors extends ReadonlyArray<Codec>> {
13
13
  /** The `HttpApiGroup` identifier, and so the OpenAPI tag and operation-ID prefix. */
@@ -17,24 +17,26 @@ 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
- }
30
20
  /** A named set of action contracts: what adapters serve and what `implement` binds. */
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> {
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> {
32
22
  /** Bind every handler at once, resolving build-time services once per adapter layer. */
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>>;
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>>>;
34
24
  }
35
25
  /** Any group, with its actions and error schemas erased. */
36
- export type Any = Group<string, ReadonlyArray<Any$1>, ReadonlyArray<Codec>, any>;
26
+ export type Any = Group<string, ReadonlyArray<Any$1>, ReadonlyArray<Codec>>;
37
27
  /** Define a group. Invalid or duplicate action and MCP names fail here, at definition time. */
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>;
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>;
29
+ /** `G extends unknown` distributes, so each group maps only its own actions. */
30
+ type PerGroup<G extends Actions> = G extends unknown ? { readonly [A in G["actions"][number] as `${G["name"]}.${A["name"]}`]: A; } : never;
31
+ /** Merges those per-group maps into one, rather than leaving a union of them. */
32
+ type Merged<Maps> = (Maps extends unknown ? (map: Maps) => void : never) extends ((map: infer Merged) => void) ? Merged : never;
33
+ /** Every action of `Groups`, keyed `<group>.<action>`. */
34
+ export type Contracts<Groups extends ReadonlyArray<Actions>> = Merged<PerGroup<Groups[number]>>;
35
+ /**
36
+ * Project groups into one map of their action contracts, keyed `<group>.<action>`:
37
+ * the identity routes, tools and commands share. Deriving it from the groups
38
+ * themselves means an added action cannot be missed.
39
+ */
40
+ export declare function contracts<const Groups extends ReadonlyArray<Actions>>(...groups: Groups): Contracts<Groups>;
39
41
  //#endregion
40
42
  export { ActionGroup_d_exports, type Implementation, type SchemaErrorPolicy };
@@ -15,13 +15,16 @@ function make(options, ...declared) {
15
15
  name,
16
16
  actions,
17
17
  schemaError: options.schemaError,
18
- implement: (build, options) => {
18
+ implement: (build) => {
19
19
  const built = Effect.isEffect(build) ? build : Effect.succeed(build);
20
- const before = options?.before;
21
- return Implementation.make(group, built, before);
20
+ return Implementation.make(group, built);
22
21
  }
23
22
  };
24
23
  return group;
25
24
  }
25
+ function contracts(...groups) {
26
+ assertDistinct("contract group", groups.map((group) => group.name));
27
+ return Object.fromEntries(groups.flatMap((group) => group.actions.map((action) => [`${group.name}.${action.name}`, action])));
28
+ }
26
29
  //#endregion
27
- export { make };
30
+ export { contracts, make };
@@ -1,13 +1,13 @@
1
1
  import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
2
  import { Actions, PolicyError } from "./internal/actions.mjs";
3
3
  import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
4
- import { Layer, Schema } from "effect";
5
- import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
4
+ import { Effect, Layer, Schema } from "effect";
6
5
  import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
6
+ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
7
7
  import { FileSystem } from "effect/FileSystem";
8
8
  import { Path } from "effect/Path";
9
9
  declare namespace ActionHttp_d_exports {
10
- export { Api, Http, Options, make };
10
+ export { Api, Http, LayerOptions, Options, make };
11
11
  }
12
12
  /** Contract-level configuration: servers and clients must agree on it. */
13
13
  export interface Options<Errors extends ReadonlyArray<Codec> = []> {
@@ -17,10 +17,21 @@ export interface Options<Errors extends ReadonlyArray<Codec> = []> {
17
17
  * handler: authentication, authorization, rate limits. Declared on every
18
18
  * endpoint, so `HttpApiClient` and `ActionCliClient` decode them as typed
19
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.
20
+ * `httpApiStatus`, and their `_tag`s must be distinct.
21
21
  */
22
22
  readonly errors?: Errors;
23
23
  }
24
+ /** What `Http.layer` binds around the implementations it serves. */
25
+ export interface LayerOptions<Errors extends ReadonlyArray<Codec>, R> {
26
+ /**
27
+ * Runs once per request, before the request payload is decoded, with the action
28
+ * contract it is about to run. It fails with the binding's own `errors`, so an
29
+ * unauthorized caller learns nothing about the action's input, and the refusal
30
+ * is encoded exactly like a declared error. Its services are request-time
31
+ * requirements, like a handler's.
32
+ */
33
+ readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
34
+ }
24
35
  type Endpoint<A extends Any, E extends Codec> = A extends {
25
36
  readonly http: false;
26
37
  } ? 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;
@@ -33,10 +44,11 @@ export interface Http<Groups extends ReadonlyArray<Actions>, Errors extends Read
33
44
  readonly groups: Groups;
34
45
  readonly api: Api<Groups[number], Errors[number]>;
35
46
  /**
36
- * Serve any number of implementations in one layer. Build failures, build
37
- * services and request services are unions over precisely those apps.
47
+ * Serve any number of implementations in one layer, with one pre-handler hook
48
+ * around every request they answer. Build failures, build services and request
49
+ * services are unions over precisely those apps and that hook.
38
50
  */
39
- readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>>(...apps: Apps) => Layer.Layer<never, BuildError<Apps[number]>, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]>> | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path>;
51
+ readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(options: LayerOptions<Errors, RB>, ...apps: Apps) => Layer.Layer<never, BuildError<Apps[number]>, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]> | RB> | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path>;
40
52
  }
41
53
  /**
42
54
  * Bind the contract-level configuration once. Every route is `POST
@@ -1,15 +1,14 @@
1
- import { assertDistinct } from "./internal/actions.mjs";
1
+ import { assertDistinct, projectedErrors } from "./internal/actions.mjs";
2
2
  import { dispatch } from "./internal/implementation.mjs";
3
3
  import { Effect, Layer } from "effect";
4
+ import { HttpEffect, HttpRouter, HttpServerResponse } from "effect/unstable/http";
4
5
  import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
5
6
  //#region src/ActionHttp.ts
6
7
  const httpActions = (group) => group.actions.filter((action) => action.http);
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
8
  const endpoint = (options, group, action) => HttpApiEndpoint.post(action.name, `${options.apiPath}/${group.name}/${action.name}`, {
10
9
  payload: action.input,
11
10
  success: action.success,
12
- error: errorsOf(options, action)
11
+ error: projectedErrors(action, options.errors)
13
12
  }).annotate(OpenApi.Description, action.description);
14
13
  /** The native middleware declares the policy's errors on every endpoint it wraps. */
15
14
  const schemaErrorMiddleware = (name, policy) => {
@@ -19,18 +18,36 @@ const schemaErrorMiddleware = (name, policy) => {
19
18
  layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy.map(failure)))
20
19
  };
21
20
  };
21
+ /** The hook of one group, applied to every one of its endpoints. */
22
+ const hookMiddleware = (name) => {
23
+ class Admission extends HttpApiMiddleware.Service()(`effect-actions/http/Admission/${name}`) {}
24
+ const implementation = (group, before) => {
25
+ if (before === void 0) return (httpEffect) => httpEffect;
26
+ const actions = new Map(httpActions(group).map((action) => [action.name, action]));
27
+ return (httpEffect, { endpoint }) => {
28
+ const action = actions.get(endpoint.identifier);
29
+ if (action === void 0) throw new Error(`Missing action: ${endpoint.identifier}`);
30
+ return Effect.flatMap(before(action), () => httpEffect);
31
+ };
32
+ };
33
+ return {
34
+ Admission,
35
+ layer: (group, before) => Layer.succeed(Admission, implementation(group, before))
36
+ };
37
+ };
22
38
  function bind(options, group) {
23
39
  const [first, ...rest] = httpActions(group).map((action) => endpoint(options, group, action));
24
40
  if (first === void 0) return void 0;
25
- const native = HttpApiGroup.make(group.name).add(first, ...rest);
41
+ const bound = HttpApiGroup.make(group.name).add(first, ...rest);
42
+ const hook = hookMiddleware(group.name);
26
43
  if (group.schemaError === void 0) return {
27
- native,
28
- policy: Layer.empty
44
+ native: bound.middleware(hook.Admission),
45
+ middleware: (before) => hook.layer(group, before)
29
46
  };
30
47
  const policy = schemaErrorMiddleware(group.name, group.schemaError);
31
48
  return {
32
- native: native.middleware(policy.SchemaErrors),
33
- policy: policy.layer
49
+ native: bound.middleware(policy.SchemaErrors).middleware(hook.Admission),
50
+ middleware: (before) => Layer.merge(policy.layer, hook.layer(group, before))
34
51
  };
35
52
  }
36
53
  function apiOf(groups) {
@@ -49,17 +66,22 @@ const erasedHandlers = (handlers) => {
49
66
  return handlers;
50
67
  };
51
68
  /** 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));
69
+ const groupHandlers = (api, app, middleware) => {
70
+ const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record, void 0)(request.payload)]));
71
+ return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(middleware));
55
72
  };
73
+ /**
74
+ * A refused or failed request is never stored by a shared cache. Successful
75
+ * responses keep whatever the host sets, since only the host knows what is public.
76
+ */
77
+ const noStore = HttpRouter.middleware((httpEffect) => HttpEffect.withPreResponseHandler(httpEffect, (_request, response) => Effect.succeed(response.status < 400 ? response : HttpServerResponse.setHeader(response, "cache-control", "no-store"))));
56
78
  function make(options, ...groups) {
57
79
  assertDistinct("action group", groups.map((group) => group.name));
58
80
  const bound = new Map(groups.flatMap((group) => {
59
81
  const binding = bind(options, group);
60
82
  return binding === void 0 ? [] : [[group, binding]];
61
83
  }));
62
- const layer = (...apps) => {
84
+ const layer = (layerOptions, ...apps) => {
63
85
  assertDistinct("implementation group", apps.map((app) => app.group.name));
64
86
  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`);
65
87
  const served = apps.flatMap((app) => {
@@ -70,7 +92,8 @@ function make(options, ...groups) {
70
92
  }];
71
93
  });
72
94
  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));
95
+ const before = layerOptions.before;
96
+ return served.reduce((layer, { app, middleware }) => layer.pipe(Layer.provide(groupHandlers(api, app, middleware(before)))), HttpApiBuilder.layer(api)).pipe(Layer.provide(noStore.layer));
74
97
  };
75
98
  return {
76
99
  groups,
@@ -1,11 +1,28 @@
1
+ import { Action_d_exports, Any, Codec } from "./Action.mjs";
1
2
  import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
2
- import { Cause, Layer } from "effect";
3
- import { McpSchema, McpServer } from "effect/unstable/ai";
3
+ import { Cause, Effect, Layer } from "effect";
4
4
  import { HttpRouter } from "effect/unstable/http";
5
+ import { McpSchema, McpServer } from "effect/unstable/ai";
5
6
  import { Stdio } from "effect/Stdio";
6
7
  //#region src/ActionMcp.d.ts
8
+ /** What every MCP transport binds around the tools it serves. */
9
+ interface SurfaceOptions<Errors extends ReadonlyArray<Codec>, R> {
10
+ /**
11
+ * Failures the surface answers with instead of a handler: authorization, rate
12
+ * limits. Declared on every tool of this transport, so a refusal is returned
13
+ * as an `isError` result exactly like an action's own error.
14
+ */
15
+ readonly errors?: Errors;
16
+ /**
17
+ * Runs once per tool call, after the native server has decoded the arguments
18
+ * and before the selected handler, with the action contract it is about to run.
19
+ * It fails with this transport's `errors`. Its services are request-time
20
+ * requirements, like a handler's.
21
+ */
22
+ readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
23
+ }
7
24
  /** One Streamable HTTP MCP endpoint; its fields become the native server info. */
8
- export interface Options {
25
+ export interface Options<Errors extends ReadonlyArray<Codec> = [], R = never> extends SurfaceOptions<Errors, R> {
9
26
  readonly name: string;
10
27
  readonly version: string;
11
28
  /** Native protocol adapters to serve; negotiation and sessions are owned by Effect. */
@@ -17,7 +34,7 @@ export interface Options {
17
34
  readonly instructions?: string;
18
35
  }
19
36
  /** Server information for an MCP subprocess connected through standard I/O. */
20
- export interface StdioOptions {
37
+ export interface StdioOptions<Errors extends ReadonlyArray<Codec> = [], R = never> extends SurfaceOptions<Errors, R> {
21
38
  readonly name: string;
22
39
  readonly version: string;
23
40
  /** Native protocol adapters to serve; negotiation is owned by Effect. */
@@ -25,19 +42,19 @@ export interface StdioOptions {
25
42
  readonly instructions?: string;
26
43
  }
27
44
  /** The native server supplies its own request context to every tool call. */
28
- type ToolRequestContext<App> = Exclude<RequestContext<App>, McpSchema.McpRequestContext>;
45
+ type ToolRequestContext<App, RB> = Exclude<RequestContext<App> | RB, McpSchema.McpRequestContext>;
29
46
  /**
30
47
  * Serve MCP tools over one Streamable HTTP endpoint.
31
48
  *
32
49
  * Middleware provided around this layer has the normal HTTP lifetime. Native
33
50
  * context capture applies: never provide request-identity tags at startup.
34
51
  */
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]>>>;
52
+ export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(options: Options<Errors, RB>, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ToolRequestContext<Apps[number], RB>>>;
36
53
  /**
37
54
  * Serve MCP tools through newline-delimited JSON-RPC on standard I/O.
38
55
  *
39
56
  * The host supplies the `Stdio` service. Arguments are tool input only and
40
57
  * never establish request identity or authority.
41
58
  */
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]>>;
59
+ export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(options: StdioOptions<Errors, RB>, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio | ToolRequestContext<Apps[number], RB>>;
43
60
  //#endregion
@@ -3,13 +3,21 @@ import { bindTools } from "./internal/tools.mjs";
3
3
  import { Layer } from "effect";
4
4
  import { McpServer } from "effect/unstable/ai";
5
5
  //#region src/ActionMcp.ts
6
- const registration = (apps) => {
7
- const binding = bindTools(apps, "mcp");
6
+ const registration = (apps, options) => {
7
+ const binding = bindTools(apps, "mcp", options);
8
8
  return Layer.effectDiscard(McpServer.registerToolkit(binding.toolkit)).pipe(Layer.provide(binding.layer));
9
9
  };
10
- const server = (apps, transport) => registration(apps).pipe(Layer.provide(transport), Layer.fresh);
10
+ const server = (apps, options, transport) => registration(apps, options).pipe(Layer.provide(transport), Layer.fresh);
11
+ /** The hook and surface errors an erased transport binds. */
12
+ const toolOptions = (options) => {
13
+ const before = options.before;
14
+ return {
15
+ errors: options.errors,
16
+ before
17
+ };
18
+ };
11
19
  function layerHttp(options, ...apps) {
12
- return server(apps, McpServer.layerHttp({
20
+ return server(apps, toolOptions(options), McpServer.layerHttp({
13
21
  name: options.name,
14
22
  version: options.version,
15
23
  instructions: options.instructions,
@@ -19,7 +27,7 @@ function layerHttp(options, ...apps) {
19
27
  }));
20
28
  }
21
29
  function layerStdio(options, ...apps) {
22
- return server(apps, McpServer.layerStdio(options));
30
+ return server(apps, toolOptions(options), McpServer.layerStdio(options));
23
31
  }
24
32
  //#endregion
25
33
  export { layerHttp, layerStdio };
@@ -1,26 +1,41 @@
1
- import { Action_d_exports, Any } from "./Action.mjs";
2
- import { AnyImplementation, BeforeContext, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
3
- import { Layer, Schema } from "effect";
1
+ import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
+ import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
3
+ import { Effect, Layer, Schema } from "effect";
4
4
  import { Tool, Toolkit } from "effect/unstable/ai";
5
5
  //#region src/ActionToolkit.d.ts
6
+ /** What `make` binds around the implementations it projects. */
7
+ export interface Options<Errors extends ReadonlyArray<Codec> = [], R = never> {
8
+ /**
9
+ * Failures the caller answers with instead of a handler: authorization, rate
10
+ * limits. Declared on every tool, so a refusal is returned as a tool failure
11
+ * exactly like an action's own error.
12
+ */
13
+ readonly errors?: Errors;
14
+ /**
15
+ * Runs once per tool call, before the selected handler, with the action contract
16
+ * it is about to run. It fails with this binding's `errors`. Its services join
17
+ * each tool's request requirements, like a handler's.
18
+ */
19
+ readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
20
+ }
6
21
  /** A native tool corresponding to an MCP-enabled action. */
7
- type NativeTool<A extends Any, R> = A extends {
22
+ type NativeTool<A extends Any, E extends Codec, R> = A extends {
8
23
  readonly mcp: {
9
24
  readonly name: infer Name extends string;
10
25
  };
11
26
  } ? Tool.Tool<Name, {
12
27
  readonly parameters: A["input"];
13
28
  readonly success: A["success"];
14
- readonly failure: Schema.Union<A["errors"]>;
29
+ readonly failure: Schema.Union<ReadonlyArray<A["errors"][number] | E>>;
15
30
  readonly failureMode: "return";
16
31
  }, R> : 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. */
19
- type ToolForAction<App extends AnyImplementation, A extends Any> = A extends {
32
+ type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any> ? H : never;
33
+ /** A tool needs what its handler needs, plus what the binding's `before` hook needs. */
34
+ type ToolForAction<App extends AnyImplementation, A extends Any, E extends Codec, RB> = A extends {
20
35
  readonly mcp: object;
21
- } ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | BeforeContext<App>> : never;
22
- type ToolsFor<App extends AnyImplementation> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number]> : never;
23
- type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>> = { readonly [T in ToolsFor<Apps[number]> as T["name"]]: T; };
36
+ } ? NativeTool<A, E, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | RB> : never;
37
+ type ToolsFor<App extends AnyImplementation, E extends Codec, RB> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number], E, RB> : never;
38
+ type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>, E extends Codec, RB> = { readonly [T in ToolsFor<Apps[number], E, RB> as T["name"]]: T; };
24
39
  /** Native tools and the layer that binds their action implementations. */
25
40
  export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R = never> {
26
41
  readonly toolkit: Toolkit.Toolkit<Tools>;
@@ -34,5 +49,5 @@ export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R =
34
49
  * Build services are needed to construct `layer`; request services are needed
35
50
  * when the resulting toolkit handles a call.
36
51
  */
37
- export declare function make<const Apps extends ReadonlyArray<AnyImplementation>>(...apps: Apps): Binding<ToolkitTools<Apps>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
52
+ export declare function make<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(options: Options<Errors, RB>, ...apps: Apps): Binding<ToolkitTools<Apps, Errors[number], RB>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
38
53
  //#endregion