@gjermundgaraba/effect-actions 0.4.1 → 0.5.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,11 +30,13 @@ const app = Actions.implement({
30
30
  });
31
31
 
32
32
  export const routes = Layer.mergeAll(
33
- Http.layer({}, app),
34
- ActionMcp.layerHttp(
35
- { protocols: [McpProtocol.v2026_07_28], name: "greetings", version: "1.0.0", path: "/mcp" },
36
- app,
37
- ),
33
+ Http.layer([app]),
34
+ ActionMcp.layerHttp([app], {
35
+ protocols: [McpProtocol.v2026_07_28],
36
+ name: "greetings",
37
+ version: "1.0.0",
38
+ path: "/mcp",
39
+ }),
38
40
  );
39
41
  ```
40
42
 
@@ -42,7 +44,7 @@ Serve `routes` with Effect's `HttpRouter` and you have `POST /api/actions/greeti
42
44
  and an MCP tool named `greet` at `/mcp`. The same `app` is also:
43
45
 
44
46
  ```ts
45
- const { toolkit, layer } = ActionToolkit.make({}, app); // native Effect AI Toolkit and its handler layer
47
+ const { toolkit, layer } = ActionToolkit.make([app]); // native Effect AI Toolkit and its handler layer
46
48
  const cli = ActionCli.group(app); // greetings greet --input '{"name":"Ada"}'
47
49
  const remote = ActionCliClient.command(Http, "greetings", "greet"); // same command, over HTTP
48
50
  const catalog = ActionCatalog.make(Actions); // offline JSON contract, no handlers acquired
@@ -61,7 +63,7 @@ const greeting = Effect.gen(function* () {
61
63
  ## Why
62
64
 
63
65
  - 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. 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.
66
+ - A handler may fail only with the errors its action declares. Each surface binds one `before` hook that runs before every handler it serves — after successful input decoding — so a policy such as scope enforcement is written once per surface and no action of that surface can skip it.
65
67
  - 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
68
  - Every action states its `access` (`"read"` or `"write"`, required), so authorization reads the contract instead of a hand-maintained list of mutation names.
67
69
  - 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.
@@ -104,7 +106,7 @@ Authentication and authorization belong to the application. See [docs/setup.md](
104
106
 
105
107
  ## Acknowledgements
106
108
 
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.
109
+ A few ideas (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
110
 
109
111
  ## Contributing
110
112
 
@@ -2,7 +2,7 @@ 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
5
- /** One contract, with independently rooted JSON schemas for its encoded values. */
5
+ /** One contract, with independently rooted descriptions of its encoded values. */
6
6
  export interface Entry {
7
7
  readonly id: string;
8
8
  readonly group: string;
@@ -19,7 +19,7 @@ export interface Entry {
19
19
  }
20
20
  /** An offline contract document, not an authorization decision or a mounted endpoint. */
21
21
  export interface Catalog {
22
- readonly version: "1";
22
+ readonly version: "3";
23
23
  readonly actions: ReadonlyArray<Entry>;
24
24
  }
25
25
  /**
@@ -1,12 +1,13 @@
1
1
  import { assertDistinct } from "./internal/actions.mjs";
2
2
  import { JsonSchema, Schema } from "effect";
3
3
  //#region src/ActionCatalog.ts
4
- const jsonSchema = (codec) => {
5
- const document = Schema.toJsonSchemaDocument(codec);
4
+ /** Describe the JSON wire form; decoding on the server remains authoritative. */
5
+ const describe = (codec) => {
6
+ const { schema, definitions } = Schema.toJsonSchemaDocument(codec);
6
7
  return {
7
8
  $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
8
- ...document.schema,
9
- ...Object.keys(document.definitions).length === 0 ? {} : { $defs: document.definitions }
9
+ ...schema,
10
+ ...Object.keys(definitions).length === 0 ? {} : { $defs: definitions }
10
11
  };
11
12
  };
12
13
  /**
@@ -17,7 +18,7 @@ const jsonSchema = (codec) => {
17
18
  const make = (...groups) => {
18
19
  assertDistinct("catalog group", groups.map((group) => group.name));
19
20
  return {
20
- version: "1",
21
+ version: "3",
21
22
  actions: groups.flatMap((group) => group.actions.map((action) => ({
22
23
  id: `${group.name}.${action.name}`,
23
24
  group: group.name,
@@ -25,10 +26,10 @@ const make = (...groups) => {
25
26
  description: action.description,
26
27
  http: action.http,
27
28
  mcp: action.mcp,
28
- input: jsonSchema(action.input),
29
- success: jsonSchema(action.success),
30
- errors: action.errors.map(jsonSchema),
31
- httpSchemaErrors: action.http ? group.schemaError?.errors.map(jsonSchema) ?? [] : []
29
+ input: describe(action.input),
30
+ success: describe(action.success),
31
+ errors: action.errors.map(describe),
32
+ httpSchemaErrors: action.http ? group.schemaError?.errors.map(describe) ?? [] : []
32
33
  })))
33
34
  };
34
35
  };
@@ -6,8 +6,8 @@ import { Effect, Scope } from "effect";
6
6
  import { Command } from "effect/unstable/cli";
7
7
  //#region src/ActionCli.d.ts
8
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
9
+ * The hook a local command runs before the selected handler. A CLI does not serialize
10
+ * failures, so its refusal is a typed failure of the command effect rather than a
11
11
  * declared surface error; the native parser has already decoded the input.
12
12
  */
13
13
  interface BeforeOptions<E, R> {
@@ -1,26 +1,21 @@
1
+ import { selectNamed } from "./internal/actions.mjs";
1
2
  import { dispatch } from "./internal/implementation.mjs";
2
3
  import { command as command$1 } from "./internal/cli.mjs";
3
4
  import { Effect } from "effect";
4
5
  import { Command } from "effect/unstable/cli";
5
6
  //#region src/ActionCli.ts
6
- const select = (group, name) => {
7
- const action = group.actions.find((candidate) => candidate.name === name);
8
- if (action === void 0) throw new Error(`Unknown action "${group.name}.${name}"`);
9
- return action;
10
- };
11
7
  const local = (app, action, before, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, before)(input)));
12
8
  /**
13
9
  * Project one explicitly selected local action into a native Effect CLI command.
14
10
  * The action name resolves against the bound group; no HTTP fallback exists.
15
11
  */
16
12
  const command = (app, name, options) => {
17
- const action = select(app.group, name);
13
+ const action = selectNamed(app.group.actions, name, `action "${app.group.name}.${name}"`);
18
14
  return command$1(action, (input) => local(app, action, options?.before, input), options);
19
15
  };
20
16
  /** Project every local action below its group namespace with default CLI options. */
21
17
  const group = (app, options) => {
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)))));
18
+ const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, options?.before)(input)))));
24
19
  return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
25
20
  };
26
21
  //#endregion
@@ -1,5 +1,6 @@
1
1
  import { Action_d_exports, Codec } from "./Action.mjs";
2
2
  import { Actions } from "./internal/actions.mjs";
3
+ import { ServedNames } from "./internal/implementation.mjs";
3
4
  import { Options as Options$1 } from "./internal/cli.mjs";
4
5
  import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
5
6
  import { Effect } from "effect";
@@ -7,17 +8,21 @@ import { HttpClient } from "effect/unstable/http";
7
8
  import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
8
9
  import { Command } from "effect/unstable/cli";
9
10
  //#region src/ActionCliClient.d.ts
11
+ /**
12
+ * What the host configures on Effect's native client: `baseUrl`, `transformClient`,
13
+ * `transformResponse`. The selectors and the client are the command's own.
14
+ */
15
+ export type Connection = NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
10
16
  /** Parsing, rendering and native HTTP-endpoint configuration for one remote action. */
11
17
  export type Options<Output, ParsedParameters extends Command.Command.Config = never> = Options$1<Output, ParsedParameters> & {
12
- /** Passed directly to Effect's native `HttpApiClient.endpoint`. */
13
- readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
18
+ readonly connection?: Connection;
14
19
  };
15
20
  /** Configuration for a remote aggregate group command. */
16
21
  export interface GroupOptions {
17
22
  /** Override the group command name. */
18
23
  readonly name?: string;
19
24
  /** Shared native HTTP-endpoint configuration for every remote action. */
20
- readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
25
+ readonly connection?: Connection;
21
26
  }
22
27
  type Group<Groups extends ReadonlyArray<Actions>, Name extends Groups[number]["name"]> = Extract<Groups[number], {
23
28
  readonly name: Name;
@@ -25,9 +30,7 @@ type Group<Groups extends ReadonlyArray<Actions>, Name extends Groups[number]["n
25
30
  type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Extract<G["actions"][number], {
26
31
  readonly name: Name;
27
32
  }>;
28
- type HttpNames<G extends Actions> = Extract<G["actions"][number], {
29
- readonly http: true;
30
- }>["name"];
33
+ type HttpNames<G extends Actions> = ServedNames<G, "http">;
31
34
  type ApiGroups<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>> = Api<Groups[number], Errors[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
32
35
  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>>>>, {
33
36
  readonly identifier: ActionName;
@@ -1,3 +1,4 @@
1
+ import { selectNamed } from "./internal/actions.mjs";
1
2
  import { command as command$1 } from "./internal/cli.mjs";
2
3
  import { Effect } from "effect";
3
4
  import { HttpClient } from "effect/unstable/http";
@@ -12,14 +13,11 @@ import { Command } from "effect/unstable/cli";
12
13
  const erasedApi = (api) => {
13
14
  return api;
14
15
  };
15
- const selectGroup = (groups, name) => {
16
- const group = groups.find((candidate) => candidate.name === name);
17
- if (group === void 0) throw new Error(`Unknown HTTP group "${name}"`);
18
- return group;
19
- };
16
+ /** An action hidden from HTTP is unknown to this binding, exactly like a missing one. */
20
17
  const selectAction = (group, name) => {
21
- const action = group.actions.find((candidate) => candidate.name === name);
22
- if (action === void 0 || !action.http) throw new Error(`Unknown HTTP action "${group.name}.${name}"`);
18
+ const what = `HTTP action "${group.name}.${name}"`;
19
+ const action = selectNamed(group.actions, name, what);
20
+ if (!action.http) throw new Error(`Unknown ${what}`);
23
21
  return action;
24
22
  };
25
23
  /**
@@ -28,14 +26,14 @@ const selectAction = (group, name) => {
28
26
  * keep dynamically supplied selector strings from reaching the native client.
29
27
  */
30
28
  const command = (http, groupName, actionName, options) => {
31
- const group = selectGroup(http.groups, groupName);
29
+ const group = selectNamed(http.groups, groupName, `HTTP group "${groupName}"`);
32
30
  const action = selectAction(group, actionName);
33
31
  return command$1(action, (input) => Effect.flatMap(HttpClient.HttpClient, (httpClient) => {
34
32
  const endpoint = HttpApiClient.endpoint(erasedApi(http.api), {
33
+ ...options?.connection,
35
34
  group: groupName,
36
35
  endpoint: actionName,
37
- httpClient,
38
- ...options?.connection
36
+ httpClient
39
37
  });
40
38
  return Effect.flatMap(endpoint, (method) => {
41
39
  return method({ payload: input });
@@ -44,7 +42,7 @@ const command = (http, groupName, actionName, options) => {
44
42
  };
45
43
  /** Project all HTTP-enabled actions retained by one group below its group namespace. */
46
44
  const group = (http, groupName, options) => {
47
- const actions = selectGroup(http.groups, groupName);
45
+ const actions = selectNamed(http.groups, groupName, `HTTP group "${groupName}"`);
48
46
  const commands = actions.actions.flatMap((action) => action.http ? [options?.connection === void 0 ? command(http, groupName, action.name) : command(http, groupName, action.name, { connection: options.connection })] : []);
49
47
  return Command.make(options?.name ?? actions.name).pipe(Command.withSubcommands(commands));
50
48
  };
@@ -1,7 +1,7 @@
1
1
  import { Action, Action_d_exports, Any as Any$1, Codec, Handler } from "./Action.mjs";
2
2
  import { Actions, SchemaErrorPolicy } from "./internal/actions.mjs";
3
3
  import { Implementation } from "./internal/implementation.mjs";
4
- import { Effect, Scope } from "effect";
4
+ import { Effect, Scope, Types } from "effect";
5
5
  declare namespace ActionGroup_d_exports {
6
6
  export { Any, Contracts, Group, Implementation, Options, SchemaErrorPolicy, contracts, make };
7
7
  }
@@ -28,10 +28,8 @@ export type Any = Group<string, ReadonlyArray<Any$1>, ReadonlyArray<Codec>>;
28
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
29
  /** `G extends unknown` distributes, so each group maps only its own actions. */
30
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]>>;
31
+ /** Every action of `Groups`, keyed `<group>.<action>`: the per-group maps merged into one. */
32
+ export type Contracts<Groups extends ReadonlyArray<Actions>> = Types.UnionToIntersection<PerGroup<Groups[number]>>;
35
33
  /**
36
34
  * Project groups into one map of their action contracts, keyed `<group>.<action>`:
37
35
  * the identity routes, tools and commands share. Deriving it from the groups
@@ -24,10 +24,9 @@ export interface Options<Errors extends ReadonlyArray<Codec> = []> {
24
24
  /** What `Http.layer` binds around the implementations it serves. */
25
25
  export interface LayerOptions<Errors extends ReadonlyArray<Codec>, R> {
26
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
27
+ * Runs once after successful payload decoding, before the selected handler,
28
+ * with its action contract. It fails with the binding's own `errors`, encoded
29
+ * exactly like a declared error. Its services are request-time
31
30
  * requirements, like a handler's.
32
31
  */
33
32
  readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
@@ -48,7 +47,7 @@ export interface Http<Groups extends ReadonlyArray<Actions>, Errors extends Read
48
47
  * around every request they answer. Build failures, build services and request
49
48
  * services are unions over precisely those apps and that hook.
50
49
  */
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>;
50
+ readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(apps: readonly [...Apps], options?: LayerOptions<Errors, RB>) => Layer.Layer<never, BuildError<Apps[number], "http">, BuildContext<Apps[number], "http"> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<Apps[number], "http"> | RB> | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path>;
52
51
  }
53
52
  /**
54
53
  * Bind the contract-level configuration once. Every route is `POST
@@ -1,7 +1,7 @@
1
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
+ import "effect/unstable/http";
5
5
  import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
6
6
  //#region src/ActionHttp.ts
7
7
  const httpActions = (group) => group.actions.filter((action) => action.http);
@@ -18,36 +18,18 @@ const schemaErrorMiddleware = (name, policy) => {
18
18
  layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy.map(failure)))
19
19
  };
20
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
- };
38
21
  function bind(options, group) {
39
22
  const [first, ...rest] = httpActions(group).map((action) => endpoint(options, group, action));
40
23
  if (first === void 0) return void 0;
41
24
  const bound = HttpApiGroup.make(group.name).add(first, ...rest);
42
- const hook = hookMiddleware(group.name);
43
25
  if (group.schemaError === void 0) return {
44
- native: bound.middleware(hook.Admission),
45
- middleware: (before) => hook.layer(group, before)
26
+ native: bound,
27
+ middleware: Layer.empty
46
28
  };
47
29
  const policy = schemaErrorMiddleware(group.name, group.schemaError);
48
30
  return {
49
- native: bound.middleware(policy.SchemaErrors).middleware(hook.Admission),
50
- middleware: (before) => Layer.merge(policy.layer, hook.layer(group, before))
31
+ native: bound.middleware(policy.SchemaErrors),
32
+ middleware: policy.layer
51
33
  };
52
34
  }
53
35
  function apiOf(groups) {
@@ -66,22 +48,17 @@ const erasedHandlers = (handlers) => {
66
48
  return handlers;
67
49
  };
68
50
  /** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
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)]));
51
+ const groupHandlers = (api, app, middleware, before) => {
52
+ const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record, before)(request.payload)]));
71
53
  return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(middleware));
72
54
  };
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"))));
78
55
  function make(options, ...groups) {
79
56
  assertDistinct("action group", groups.map((group) => group.name));
80
57
  const bound = new Map(groups.flatMap((group) => {
81
58
  const binding = bind(options, group);
82
59
  return binding === void 0 ? [] : [[group, binding]];
83
60
  }));
84
- const layer = (layerOptions, ...apps) => {
61
+ const layer = (apps, layerOptions = {}) => {
85
62
  assertDistinct("implementation group", apps.map((app) => app.group.name));
86
63
  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`);
87
64
  const served = apps.flatMap((app) => {
@@ -92,8 +69,8 @@ function make(options, ...groups) {
92
69
  }];
93
70
  });
94
71
  const api = apiOf(served.map(({ native }) => native));
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));
72
+ const [first, ...rest] = served.map(({ app, middleware }) => groupHandlers(api, app, middleware, layerOptions.before));
73
+ return first === void 0 ? HttpApiBuilder.layer(api) : HttpApiBuilder.layer(api).pipe(Layer.provide([first, ...rest]));
97
74
  };
98
75
  return {
99
76
  groups,
@@ -1,26 +1,11 @@
1
- import { Action_d_exports, Any, Codec } from "./Action.mjs";
1
+ import { Action_d_exports, Codec } from "./Action.mjs";
2
2
  import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
3
- import { Cause, Effect, Layer } from "effect";
3
+ import { SurfaceOptions } from "./internal/tools.mjs";
4
+ import { Cause, Layer } from "effect";
4
5
  import { HttpRouter } from "effect/unstable/http";
5
6
  import { McpSchema, McpServer } from "effect/unstable/ai";
6
7
  import { Stdio } from "effect/Stdio";
7
8
  //#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
- }
24
9
  /** One Streamable HTTP MCP endpoint; its fields become the native server info. */
25
10
  export interface Options<Errors extends ReadonlyArray<Codec> = [], R = never> extends SurfaceOptions<Errors, R> {
26
11
  readonly name: string;
@@ -42,19 +27,19 @@ export interface StdioOptions<Errors extends ReadonlyArray<Codec> = [], R = neve
42
27
  readonly instructions?: string;
43
28
  }
44
29
  /** The native server supplies its own request context to every tool call. */
45
- type ToolRequestContext<App, RB> = Exclude<RequestContext<App> | RB, McpSchema.McpRequestContext>;
30
+ type ToolRequestContext<App, RB> = Exclude<RequestContext<App, "mcp"> | RB, McpSchema.McpRequestContext>;
46
31
  /**
47
32
  * Serve MCP tools over one Streamable HTTP endpoint.
48
33
  *
49
34
  * Middleware provided around this layer has the normal HTTP lifetime. Native
50
35
  * context capture applies: never provide request-identity tags at startup.
51
36
  */
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>>>;
37
+ export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(apps: readonly [...Apps], options: Options<Errors, RB>): Layer.Layer<never, BuildError<Apps[number], "mcp"> | Cause.IllegalArgumentError, BuildContext<Apps[number], "mcp"> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ToolRequestContext<Apps[number], RB>>>;
53
38
  /**
54
39
  * Serve MCP tools through newline-delimited JSON-RPC on standard I/O.
55
40
  *
56
41
  * The host supplies the `Stdio` service. Arguments are tool input only and
57
42
  * never establish request identity or authority.
58
43
  */
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>>;
44
+ export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(apps: readonly [...Apps], options: StdioOptions<Errors, RB>): Layer.Layer<never, BuildError<Apps[number], "mcp"> | Cause.IllegalArgumentError, BuildContext<Apps[number], "mcp"> | Stdio | ToolRequestContext<Apps[number], RB>>;
60
45
  //#endregion
@@ -8,16 +8,8 @@ const registration = (apps, options) => {
8
8
  return Layer.effectDiscard(McpServer.registerToolkit(binding.toolkit)).pipe(Layer.provide(binding.layer));
9
9
  };
10
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
- };
19
- function layerHttp(options, ...apps) {
20
- return server(apps, toolOptions(options), McpServer.layerHttp({
11
+ function layerHttp(apps, options) {
12
+ return server(apps, options, McpServer.layerHttp({
21
13
  name: options.name,
22
14
  version: options.version,
23
15
  instructions: options.instructions,
@@ -26,8 +18,8 @@ function layerHttp(options, ...apps) {
26
18
  allowedOrigins: options.allowedOrigins
27
19
  }));
28
20
  }
29
- function layerStdio(options, ...apps) {
30
- return server(apps, toolOptions(options), McpServer.layerStdio(options));
21
+ function layerStdio(apps, options) {
22
+ return server(apps, options, McpServer.layerStdio(options));
31
23
  }
32
24
  //#endregion
33
25
  export { layerHttp, layerStdio };
@@ -1,23 +1,11 @@
1
1
  import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
2
  import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
3
- import { Effect, Layer, Schema } from "effect";
3
+ import { SurfaceOptions } from "./internal/tools.mjs";
4
+ import { Layer, Schema } from "effect";
4
5
  import { Tool, Toolkit } from "effect/unstable/ai";
5
6
  //#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
- }
7
+ /** What the in-process caller binds around the implementations it projects. */
8
+ export type Options<Errors extends ReadonlyArray<Codec> = [], R = never> = SurfaceOptions<Errors, R>;
21
9
  /** A native tool corresponding to an MCP-enabled action. */
22
10
  type NativeTool<A extends Any, E extends Codec, R> = A extends {
23
11
  readonly mcp: {
@@ -49,5 +37,5 @@ export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R =
49
37
  * Build services are needed to construct `layer`; request services are needed
50
38
  * when the resulting toolkit handles a call.
51
39
  */
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]>>;
40
+ export declare function make<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(apps: readonly [...Apps], options?: Options<Errors, RB>): Binding<ToolkitTools<Apps, Errors[number], RB>, BuildError<Apps[number], "mcp">, BuildContext<Apps[number], "mcp">>;
53
41
  //#endregion
@@ -3,13 +3,8 @@ import { bindTools } from "./internal/tools.mjs";
3
3
  import "effect";
4
4
  import "effect/unstable/ai";
5
5
  //#region src/ActionToolkit.ts
6
- function make(options, ...apps) {
7
- const before = options.before;
8
- const toolOptions = {
9
- errors: options.errors,
10
- before
11
- };
12
- return bindTools(apps, "native", toolOptions);
6
+ function make(apps, options = {}) {
7
+ return bindTools(apps, "native", options);
13
8
  }
14
9
  //#endregion
15
10
  export { make };
@@ -4,12 +4,21 @@
4
4
  * a projection of that action declares. A schema the action already declares is
5
5
  * not repeated.
6
6
  */
7
- const projectedErrors = (action, surface) => [...action.errors, ...(surface ?? []).filter((error) => !action.errors.includes(error))];
7
+ const projectedErrors = (action, surface) => [.../* @__PURE__ */ new Set([...action.errors, ...surface ?? []])];
8
8
  const validName = /^[A-Za-z0-9_-]+$/;
9
9
  /** Names become path segments, OpenAPI identifiers and client method keys. */
10
10
  const assertName = (what, name) => {
11
11
  if (!validName.test(name) || name === "then") throw new Error(`Invalid ${what}: ${name}`);
12
12
  };
13
+ /**
14
+ * The one item of `items` named `name`, narrowed to that contract. A caller passes
15
+ * a literal name, so a miss is a programming error reported as `Unknown <what>`.
16
+ */
17
+ const selectNamed = (items, name, what) => {
18
+ const item = items.find((candidate) => candidate.name === name);
19
+ if (item === void 0) throw new Error(`Unknown ${what}`);
20
+ return item;
21
+ };
13
22
  /** Each namespace is checked by whoever owns it: a group, the routes, or the MCP tools. */
14
23
  const assertDistinct = (what, names) => {
15
24
  const seen = /* @__PURE__ */ new Set();
@@ -19,4 +28,4 @@ const assertDistinct = (what, names) => {
19
28
  }
20
29
  };
21
30
  //#endregion
22
- export { assertDistinct, assertName, projectedErrors };
31
+ export { assertDistinct, assertName, projectedErrors, selectNamed };
@@ -6,7 +6,7 @@ import { Command } from "effect/unstable/cli";
6
6
  interface CommonOptions<Output> {
7
7
  /** Override the command name. The action name is used by default. */
8
8
  readonly name?: string;
9
- /** Human output. JSON remains available with `--json`. */
9
+ /** Human output. Adds a `--json` flag to the command that selects JSON instead. */
10
10
  readonly render?: (output: Output) => string;
11
11
  }
12
12
  /** The default projection accepts a whole canonical JSON input with `--input`. */
@@ -19,7 +19,7 @@ export interface ParametersOptions<Output, Parameters extends Command.Command.Co
19
19
  /** Native Effect CLI flags and arguments. */
20
20
  readonly parameters: Parameters;
21
21
  /** Maps native parsed parameters to the action's canonical JSON input. */
22
- readonly input: (parsed: Command.Command.Config.Infer<Parameters>) => Schema.Json;
22
+ readonly input: (parsed: Command.Command.Config.InferValue<Parameters>) => Schema.Json;
23
23
  }
24
24
  /**
25
25
  * A command either accepts whole JSON input or has explicit native CLI parameters.