@gjermundgaraba/effect-actions 0.5.0 → 0.7.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.
Files changed (43) hide show
  1. package/README.md +7 -6
  2. package/dist/Action.d.mts +16 -8
  3. package/dist/Action.mjs +0 -1
  4. package/dist/ActionCatalog.d.mts +2 -3
  5. package/dist/ActionCatalog.mjs +4 -5
  6. package/dist/ActionCli.mjs +5 -1
  7. package/dist/ActionCliClient.d.mts +8 -9
  8. package/dist/ActionCliClient.mjs +4 -11
  9. package/dist/ActionGroup.d.mts +15 -11
  10. package/dist/ActionGroup.mjs +8 -2
  11. package/dist/ActionHttp.d.mts +9 -5
  12. package/dist/ActionHttp.mjs +14 -9
  13. package/dist/ActionHttpClient.d.mts +33 -0
  14. package/dist/ActionHttpClient.mjs +16 -0
  15. package/dist/ActionMcp.d.mts +16 -26
  16. package/dist/ActionMcp.mjs +12 -8
  17. package/dist/ActionToolkit.d.mts +10 -11
  18. package/dist/Testing.d.mts +35 -4
  19. package/dist/Testing.mjs +53 -6
  20. package/dist/TestingClient.d.mts +6 -5
  21. package/dist/TestingClient.mjs +6 -3
  22. package/dist/internal/actions.d.mts +18 -7
  23. package/dist/internal/actions.mjs +11 -1
  24. package/dist/internal/fetchClient.d.mts +6 -0
  25. package/dist/internal/fetchClient.mjs +8 -0
  26. package/dist/internal/implementation.d.mts +14 -15
  27. package/dist/internal/implementation.mjs +6 -5
  28. package/dist/internal/tools.mjs +1 -1
  29. package/docs/Action.md +11 -10
  30. package/docs/ActionCatalog.md +9 -9
  31. package/docs/ActionCli.md +7 -7
  32. package/docs/ActionCliClient.md +4 -4
  33. package/docs/ActionGroup.md +24 -16
  34. package/docs/ActionHttp.md +14 -12
  35. package/docs/ActionHttpClient.md +80 -0
  36. package/docs/ActionMcp.md +19 -22
  37. package/docs/ActionToolkit.md +1 -1
  38. package/docs/CONTEXT.md +4 -4
  39. package/docs/README.md +18 -18
  40. package/docs/Testing.md +27 -16
  41. package/docs/guarantees.md +5 -5
  42. package/docs/setup.md +7 -6
  43. package/package.json +5 -4
@@ -1,16 +1,14 @@
1
1
  import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
- import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
2
+ import { AnyImplementation, BuildContext, BuildError, HandlerContext, HiddenFromMcp, Implementation } from "./internal/implementation.mjs";
3
3
  import { SurfaceOptions } from "./internal/tools.mjs";
4
4
  import { Layer, Schema } from "effect";
5
5
  import { Tool, Toolkit } from "effect/unstable/ai";
6
6
  //#region src/ActionToolkit.d.ts
7
7
  /** What the in-process caller binds around the implementations it projects. */
8
8
  export type Options<Errors extends ReadonlyArray<Codec> = [], R = never> = SurfaceOptions<Errors, R>;
9
- /** A native tool corresponding to an MCP-enabled action. */
10
- type NativeTool<A extends Any, E extends Codec, R> = A extends {
11
- readonly mcp: {
12
- readonly name: infer Name extends string;
13
- };
9
+ /** A native tool corresponding to an action MCP may serve, named by its tool metadata. */
10
+ type NativeTool<A extends Any, E extends Codec, R> = Extract<A["mcp"], object> extends {
11
+ readonly name: infer Name extends string;
14
12
  } ? Tool.Tool<Name, {
15
13
  readonly parameters: A["input"];
16
14
  readonly success: A["success"];
@@ -18,10 +16,11 @@ type NativeTool<A extends Any, E extends Codec, R> = A extends {
18
16
  readonly failureMode: "return";
19
17
  }, R> : never;
20
18
  type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any> ? H : never;
21
- /** A tool needs what its handler needs, plus what the binding's `before` hook needs. */
22
- type ToolForAction<App extends AnyImplementation, A extends Any, E extends Codec, RB> = A extends {
23
- readonly mcp: object;
24
- } ? NativeTool<A, E, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | RB> : never;
19
+ /**
20
+ * A tool needs what its handler needs, plus what the binding's `before` hook needs. The
21
+ * rule is `ActionMcp`'s: only an action certainly hidden from MCP has no tool.
22
+ */
23
+ type ToolForAction<App extends AnyImplementation, A extends Any, E extends Codec, RB> = A extends HiddenFromMcp ? never : NativeTool<A, E, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | RB>;
25
24
  type ToolsFor<App extends AnyImplementation, E extends Codec, RB> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number], E, RB> : never;
26
25
  type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>, E extends Codec, RB> = { readonly [T in ToolsFor<Apps[number], E, RB> as T["name"]]: T; };
27
26
  /** Native tools and the layer that binds their action implementations. */
@@ -37,5 +36,5 @@ export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R =
37
36
  * Build services are needed to construct `layer`; request services are needed
38
37
  * when the resulting toolkit handles a call.
39
38
  */
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">>;
39
+ 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], HiddenFromMcp>, BuildContext<Apps[number], HiddenFromMcp>>;
41
40
  //#endregion
@@ -1,5 +1,6 @@
1
- import { Effect } from "effect";
2
- import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi";
1
+ import { ClientOptions } from "./internal/fetchClient.mjs";
2
+ import { Schema } from "effect";
3
+ import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
3
4
  //#region src/Testing.d.ts
4
5
  /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
5
6
  export type Handler = (request: Request) => Promise<Response>;
@@ -7,9 +8,9 @@ export type Handler = (request: Request) => Promise<Response>;
7
8
  * The native grouped HTTP client, calling `handler` in memory instead of the network.
8
9
  * `baseUrl` defaults to `http://localhost`.
9
10
  */
10
- export declare const httpClient: <Id extends string, Groups extends HttpApiGroup.Constraint>(api: HttpApi.HttpApi<Id, Groups>, handler: Handler, options?: NonNullable<Parameters<typeof HttpApiClient.make>[1]>) => Effect.Effect<import("effect/Types").Simplify<{ readonly [Group in Extract<Groups, {
11
+ export declare const httpClient: <Id extends string, Groups extends HttpApiGroup.Constraint>(api: HttpApi.HttpApi<Id, Groups>, handler: Handler, options?: ClientOptions) => import("effect/Effect").Effect<import("effect/Types").Simplify<{ readonly [Group in Extract<Groups, {
11
12
  readonly topLevel: false;
12
- }> as HttpApiGroup.Identifier<Group>]: HttpApiClient.Client.GroupByEndpoint<Group, never, never>; } & HttpApiClient.Client.TopLevelMethods<Groups, never, never>>, never, Exclude<import("effect/unstable/httpapi/HttpApiMiddleware").MiddlewareClient<import("effect/unstable/httpapi/HttpApiEndpoint").Middleware<HttpApiGroup.Endpoints<Groups>>>, import("effect/unstable/http/HttpClient").HttpClient>>;
13
+ }> as HttpApiGroup.Identifier<Group>]: import("effect/unstable/httpapi/HttpApiClient").Client.GroupByEndpoint<Group, never, never>; } & import("effect/unstable/httpapi/HttpApiClient").Client.TopLevelMethods<Groups, never, never>>, never, Exclude<Exclude<import("effect/unstable/httpapi/HttpApiMiddleware").MiddlewareClient<import("effect/unstable/httpapi/HttpApiEndpoint").Middleware<HttpApiGroup.Endpoints<Groups>>>, import("effect/unstable/http/HttpClient").HttpClient>, never>>;
13
14
  /** A stateless 2026-07-28 request. */
14
15
  export interface McpRequestOptions {
15
16
  readonly url: string | URL;
@@ -33,4 +34,34 @@ export interface McpRequestParams {
33
34
  }
34
35
  /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
35
36
  export declare const mcpRequest: ({ url, method, params, headers: init }: McpRequestOptions) => Request;
37
+ /** One `tools/call` for `mcpCall`: the endpoint, the tool and its arguments. */
38
+ export interface McpCallOptions {
39
+ readonly url: string | URL;
40
+ /** The tool name, `mcp.name` of its action. */
41
+ readonly name: string;
42
+ /** Defaults to `{}`. Like `params`, it may be malformed on purpose. */
43
+ readonly arguments?: {
44
+ readonly [key: string]: McpRequestValue;
45
+ };
46
+ readonly headers?: ConstructorParameters<typeof Headers>[0];
47
+ }
48
+ /**
49
+ * A tool call's outcome with the library's wire envelope removed: the success from
50
+ * `structuredContent.value`, or the error text of an `isError` result, parsed as JSON
51
+ * when it is JSON (a declared error, whatever its shape) and kept as the text otherwise
52
+ * (the native server's own message, such as for invalid arguments).
53
+ */
54
+ export type McpCallResult = {
55
+ readonly isError: false;
56
+ readonly value: Schema.Json;
57
+ } | {
58
+ readonly isError: true;
59
+ readonly error: Schema.Json;
60
+ };
61
+ /**
62
+ * Call one tool through `handler` with a stateless 2026-07-28 request and return its
63
+ * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
64
+ * authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
65
+ */
66
+ export declare const mcpCall: (handler: Handler, { url, name, headers, arguments: args }: McpCallOptions) => Promise<McpCallResult>;
36
67
  //#endregion
package/dist/Testing.mjs CHANGED
@@ -1,15 +1,14 @@
1
- import { Effect, Layer, Predicate } from "effect";
2
- import { FetchHttpClient } from "effect/unstable/http";
3
- import { HttpApiClient } from "effect/unstable/httpapi";
1
+ import { fetchApiClient } from "./internal/fetchClient.mjs";
2
+ import { Option, Predicate, Schema } from "effect";
4
3
  //#region src/Testing.ts
5
4
  /**
6
5
  * The native grouped HTTP client, calling `handler` in memory instead of the network.
7
6
  * `baseUrl` defaults to `http://localhost`.
8
7
  */
9
- const httpClient = (api, handler, options) => HttpApiClient.make(api, {
8
+ const httpClient = (api, handler, options) => fetchApiClient(api, (input, init) => handler(new Request(input, init)), {
10
9
  baseUrl: "http://localhost",
11
10
  ...options
12
- }).pipe(Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, (input, init) => handler(new Request(input, init)))))));
11
+ });
13
12
  /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
14
13
  const mcpRequest = ({ url, method, params = {}, headers: init }) => {
15
14
  const headers = new Headers(init);
@@ -38,5 +37,53 @@ const mcpRequest = ({ url, method, params = {}, headers: init }) => {
38
37
  })
39
38
  });
40
39
  };
40
+ /** The JSON-RPC response to a `tools/call`: a tool result or a protocol error. */
41
+ const ToolReply = Schema.Union([Schema.Struct({ result: Schema.Struct({
42
+ isError: Schema.optionalKey(Schema.Boolean),
43
+ structuredContent: Schema.optionalKey(Schema.Struct({ value: Schema.Json })),
44
+ content: Schema.Array(Schema.Struct({
45
+ type: Schema.String,
46
+ text: Schema.optionalKey(Schema.String)
47
+ }))
48
+ }) }), Schema.Struct({ error: Schema.Struct({
49
+ code: Schema.Finite,
50
+ message: Schema.String
51
+ }) })]);
52
+ const decodeReply = Schema.decodeUnknownOption(Schema.fromJsonString(ToolReply));
53
+ const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
54
+ /**
55
+ * Call one tool through `handler` with a stateless 2026-07-28 request and return its
56
+ * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
57
+ * authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
58
+ */
59
+ const mcpCall = async (handler, { url, name, headers, arguments: args = {} }) => {
60
+ const response = await handler(mcpRequest({
61
+ url,
62
+ method: "tools/call",
63
+ params: {
64
+ name,
65
+ arguments: args
66
+ },
67
+ ...headers === void 0 ? {} : { headers }
68
+ }));
69
+ const body = await response.text();
70
+ if (response.status !== 200) throw new Error(`MCP tools/call "${name}" answered ${response.status}: ${body}`);
71
+ const reply = body.split("\n").map((line) => line.replace(/^data:/, "").trim()).flatMap((line) => Option.toArray(decodeReply(line))).at(-1);
72
+ if (reply === void 0) throw new Error(`MCP tools/call "${name}" had no reply: ${body}`);
73
+ if ("error" in reply) throw new Error(`MCP tools/call "${name}" failed with ${reply.error.code}: ${reply.error.message}`);
74
+ const { result } = reply;
75
+ if (result.isError === true) {
76
+ const text = result.content.find((content) => content.type === "text")?.text ?? "";
77
+ return {
78
+ isError: true,
79
+ error: Option.getOrElse(parseJson(text), () => text)
80
+ };
81
+ }
82
+ if (result.structuredContent === void 0) throw new Error(`MCP tools/call "${name}" returned no structured content: ${body}`);
83
+ return {
84
+ isError: false,
85
+ value: result.structuredContent.value
86
+ };
87
+ };
41
88
  //#endregion
42
- export { httpClient, mcpRequest };
89
+ export { httpClient, mcpCall, mcpRequest };
@@ -1,16 +1,17 @@
1
- import { Client, ClientOptions } from "@modelcontextprotocol/client";
1
+ import { Client } from "@modelcontextprotocol/client";
2
2
  //#region src/TestingClient.d.ts
3
3
  /** How `withMcpClient` connects the official client. */
4
4
  export interface McpClientOptions {
5
5
  /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
6
6
  readonly fetch: (request: Request) => Promise<Response>;
7
7
  readonly path: string;
8
- /** Passed directly to the official client; omitted options use its native default. */
9
- readonly versionNegotiation?: ClientOptions["versionNegotiation"];
10
8
  /** Defaults to `http://localhost`. */
11
9
  readonly baseUrl?: string | URL;
12
10
  readonly headers?: ConstructorParameters<typeof Headers>[0];
13
11
  }
14
- /** Connect an official client and always close its transport after the callback. */
15
- export declare const withMcpClient: <A>({ fetch, path, baseUrl, headers, versionNegotiation }: McpClientOptions, run: (client: Client) => Promise<A>) => Promise<A>;
12
+ /**
13
+ * Connect an official client pinned to MCP 2026-07-28, the revision `ActionMcp`
14
+ * serves, and always close its transport after the callback.
15
+ */
16
+ export declare const withMcpClient: <A>({ fetch, path, baseUrl, headers }: McpClientOptions, run: (client: Client) => Promise<A>) => Promise<A>;
16
17
  //#endregion
@@ -1,11 +1,14 @@
1
1
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
2
  //#region src/TestingClient.ts
3
- /** Connect an official client and always close its transport after the callback. */
4
- const withMcpClient = async ({ fetch, path, baseUrl = "http://localhost", headers, versionNegotiation }, run) => {
3
+ /**
4
+ * Connect an official client pinned to MCP 2026-07-28, the revision `ActionMcp`
5
+ * serves, and always close its transport after the callback.
6
+ */
7
+ const withMcpClient = async ({ fetch, path, baseUrl = "http://localhost", headers }, run) => {
5
8
  const client = new Client({
6
9
  name: "test",
7
10
  version: "0"
8
- }, versionNegotiation === void 0 ? {} : { versionNegotiation });
11
+ }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
9
12
  try {
10
13
  const transportOptions = { fetch: (input, init) => fetch(new Request(input, init)) };
11
14
  if (headers !== void 0) transportOptions.requestInit = { headers };
@@ -1,18 +1,29 @@
1
1
  import { Action_d_exports, Any, Codec } from "../Action.mjs";
2
2
  import { HttpApiError } from "effect/unstable/httpapi";
3
3
  //#region src/internal/actions.d.ts
4
- /** Pure HTTP policy for native schema decoding and encoding failures. */
5
- export interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
6
- readonly errors: Errors;
7
- readonly map: (failure: HttpApiError.HttpApiSchemaError) => NoInfer<Errors[number]["Type"]>;
4
+ /** One side of a schema-error policy: the error it answers with, and how to make it. */
5
+ export interface SchemaErrorAnswer<E extends Codec> {
6
+ /** A declared error schema; its `httpApiStatus` is the HTTP status of the answer. */
7
+ readonly schema: E;
8
+ readonly make: (failure: HttpApiError.HttpApiSchemaError) => NoInfer<E["Type"]>;
9
+ }
10
+ /**
11
+ * Pure HTTP policy for native schema failures, split by whose fault they are. The
12
+ * library owns the split, so every group draws it the same way.
13
+ */
14
+ export interface SchemaErrorPolicy<Invalid extends Codec = Codec, Internal extends Codec = Codec> {
15
+ /** The request did not decode: its payload, or its params, headers or query. */
16
+ readonly invalid: SchemaErrorAnswer<Invalid>;
17
+ /** The handler's result did not encode: its body or its response headers. */
18
+ readonly internal: SchemaErrorAnswer<Internal>;
8
19
  }
9
20
  /** The contract half of a group: what adapters and clients need, without `implement`. */
10
- export interface Actions<Name extends string = string, A extends ReadonlyArray<Any> = ReadonlyArray<Any>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> {
21
+ export interface Actions<Name extends string = string, A extends ReadonlyArray<Any> = ReadonlyArray<Any>, PolicyError extends Codec = Codec> {
11
22
  readonly name: Name;
12
23
  readonly actions: A;
13
24
  /** How HTTP answers failed decoding or encoding; native behavior without one. MCP is unaffected. */
14
- readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
25
+ readonly schemaError: SchemaErrorPolicy<PolicyError, PolicyError> | undefined;
15
26
  }
16
27
  /** The errors a group's policy may answer with, by lookup rather than a conditional type. */
17
- export type PolicyError<G extends Actions> = NonNullable<G["schemaError"]>["errors"][number];
28
+ export type PolicyError<G extends Actions> = NonNullable<G["schemaError"]>[keyof SchemaErrorPolicy]["schema"];
18
29
  //#endregion
@@ -1,4 +1,14 @@
1
1
  //#region src/internal/actions.ts
2
+ /** Each distinct error a policy may answer with, invalid first. */
3
+ const policyErrors = (policy) => [.../* @__PURE__ */ new Set([policy.invalid.schema, policy.internal.schema])];
4
+ /**
5
+ * `HttpApiBuilder` reports these kinds while encoding the handler's answer, after the
6
+ * handler ran; every other kind (`Payload`, `Params`, `Headers`, `Query`) comes from
7
+ * decoding the request before it.
8
+ */
9
+ const responseKinds = /* @__PURE__ */ new Set(["Body", "ResponseHeaders"]);
10
+ /** The policy's answer to one native failure: `internal` for the response side, else `invalid`. */
11
+ const answerSchemaError = (policy, failure) => responseKinds.has(failure.kind) ? policy.internal.make(failure) : policy.invalid.make(failure);
2
12
  /**
3
13
  * An action's own failures plus the ones its surface answers with, which is what
4
14
  * a projection of that action declares. A schema the action already declares is
@@ -28,4 +38,4 @@ const assertDistinct = (what, names) => {
28
38
  }
29
39
  };
30
40
  //#endregion
31
- export { assertDistinct, assertName, projectedErrors, selectNamed };
41
+ export { answerSchemaError, assertDistinct, assertName, policyErrors, projectedErrors, selectNamed };
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi";
3
+ //#region src/internal/fetchClient.d.ts
4
+ /** The native `HttpApiClient.make` options: `baseUrl`, `transformClient`, `transformResponse`. */
5
+ export type ClientOptions = NonNullable<Parameters<typeof HttpApiClient.make>[1]>;
6
+ //#endregion
@@ -0,0 +1,8 @@
1
+ import { Effect } from "effect";
2
+ import { FetchHttpClient } from "effect/unstable/http";
3
+ import { HttpApiClient } from "effect/unstable/httpapi";
4
+ //#region src/internal/fetchClient.ts
5
+ /** The native grouped `HttpApiClient` of `api`, sending every request through `fetch`. */
6
+ const fetchApiClient = (api, fetch, options) => HttpApiClient.make(api, options).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, fetch));
7
+ //#endregion
8
+ export { fetchApiClient };
@@ -30,21 +30,20 @@ export declare class Implementation<G extends Actions, H, EX, RX> {
30
30
  }
31
31
  /** Any nominal implementation of `G`, with its record and channels erased. */
32
32
  export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
33
- /** The transports that serve a subset of a group's actions, by the contract flag that hides an action. */
34
- export type Transport = "http" | "mcp";
33
+ /** What hides an action from MCP and Toolkit types: an `mcp` type of exactly `false`. The one rule both use. */
34
+ export type HiddenFromMcp = {
35
+ readonly mcp: false;
36
+ };
35
37
  /**
36
- * Names of the actions of `G` that `T` may serve. Only an action hidden by a
37
- * literal `false` is excluded; a flag decided at runtime keeps its requirements.
38
+ * Names of the actions of `G` a surface serves: all of them, less those matching
39
+ * `Hidden`. An action whose `mcp` may be `false` at runtime does not match, so it keeps
40
+ * its requirements.
38
41
  */
39
- export type ServedNames<G extends Actions, T extends Transport> = Exclude<G["actions"][number], T extends "http" ? {
40
- readonly http: false;
41
- } : {
42
- readonly mcp: false;
43
- }>["name"];
44
- /** Per-request requirements of the handlers `T` can invoke, or of a union of implementations. */
45
- export type RequestContext<App, T extends Transport> = App extends Implementation<infer G, infer H, any, any> ? { readonly [K in Extract<ServedNames<G, T>, keyof H>]: HandlerContext<H, K>; }[Extract<ServedNames<G, T>, keyof H>] : never;
46
- /** Handler-acquisition failures of the implementations `T` acquires, or of a union of them. */
47
- export type BuildError<App, T extends Transport> = App extends Implementation<infer G, any, infer EX, any> ? [ServedNames<G, T>] extends [never] ? never : EX : never;
48
- /** Handler-acquisition requirements of the implementations `T` acquires, or of a union of them. */
49
- export type BuildContext<App, T extends Transport> = App extends Implementation<infer G, any, any, infer RX> ? [ServedNames<G, T>] extends [never] ? never : RX : never;
42
+ type ServedNames<G extends Actions, Hidden> = Exclude<G["actions"][number], Hidden>["name"];
43
+ /** Per-request requirements of the handlers a surface can invoke, or of a union of implementations. */
44
+ export type RequestContext<App, Hidden = never> = App extends Implementation<infer G, infer H, any, any> ? { readonly [K in Extract<ServedNames<G, Hidden>, keyof H>]: HandlerContext<H, K>; }[Extract<ServedNames<G, Hidden>, keyof H>] : never;
45
+ /** Handler-acquisition failures of the implementations a surface acquires, or of a union of them. */
46
+ export type BuildError<App, Hidden = never> = App extends Implementation<infer G, any, infer EX, any> ? [ServedNames<G, Hidden>] extends [never] ? never : EX : never;
47
+ /** Handler-acquisition requirements of the implementations a surface acquires, or of a union of them. */
48
+ export type BuildContext<App, Hidden = never> = App extends Implementation<infer G, any, any, infer RX> ? [ServedNames<G, Hidden>] extends [never] ? never : RX : never;
50
49
  //#endregion
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Predicate } from "effect";
2
2
  //#region src/internal/implementation.ts
3
3
  /**
4
4
  * An acquired handler record for a group.
@@ -22,15 +22,16 @@ var Implementation = class Implementation {
22
22
  }
23
23
  };
24
24
  /**
25
- * Select and invoke a handler after an adapter has selected a concrete action.
26
- * The surface's pre-handler hook runs first, outside the action's span, so a
25
+ * Select the handler of a concrete action, once, when an adapter binds its acquired
26
+ * record; `implement` has already refused a record without it. The returned function
27
+ * invokes it: the surface's pre-handler hook runs first, outside the action's span, so a
27
28
  * refusal is attributed to the surface rather than to a handler that never ran.
28
29
  * `R` remains in the returned effect so transport layers cannot erase required
29
30
  * per-request services while assembling routes.
30
31
  */
31
32
  const dispatch = (group, action, handlers, before) => {
32
- const handle = handlers[action.name];
33
- if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
33
+ const handle = Object.hasOwn(handlers, action.name) ? handlers[action.name] : void 0;
34
+ if (!Predicate.isFunction(handle)) throw new Error(`No handler for ${group.name}.${action.name}`);
34
35
  const attributes = {
35
36
  "action.group": group.name,
36
37
  "action.name": action.name,
@@ -47,7 +47,7 @@ const project = (projection, entry, options) => {
47
47
  return tool.annotate(Tool.Strict, true);
48
48
  };
49
49
  const handler = (projection, app, action, handlers, before) => {
50
- const run = (input) => dispatch(app.group, action, handlers, before)(input);
50
+ const run = dispatch(app.group, action, handlers, before);
51
51
  return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
52
52
  };
53
53
  /**
package/docs/Action.md CHANGED
@@ -7,18 +7,17 @@ metadata. An action holds no behavior; handlers are bound by `ActionGroup.implem
7
7
 
8
8
  Import `@gjermundgaraba/effect-actions/Action`.
9
9
 
10
- | Export | Purpose |
11
- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
12
- | `make(name, options)` | Define a pure contract; literal names, access and transport exclusions stay typed. |
13
- | `Action`, `Any`, `Options` | Concrete contracts, erased contracts and construction options. |
14
- | `Codec`, `Handler`, `Access`, `McpOptions` | Service-free codecs, typed handlers, read/write classification and tool metadata. |
10
+ | Export | Purpose |
11
+ | ------------------------------------------ | --------------------------------------------------------------------------------- |
12
+ | `make(name, options)` | Define a pure contract; literal names, access and MCP exclusion stay typed. |
13
+ | `Action`, `Any`, `Options` | Concrete contracts, erased contracts and construction options. |
14
+ | `Codec`, `Handler`, `Access`, `McpOptions` | Service-free codecs, typed handlers, read/write classification and tool metadata. |
15
15
 
16
16
  | Option | Meaning |
17
17
  | ---------------------------------- | ---------------------------------------------------------------------------- |
18
18
  | `description`, `success`, `access` | Required description, success codec and `"read"` / `"write"` classification. |
19
19
  | `input` | Optional codec; omission means an empty object. |
20
20
  | `errors` | Declared error codecs; defaults to none. |
21
- | `http` | Defaults to enabled; `false` removes routes and client methods. |
22
21
  | `mcp` | Defaults to enabled; `false` removes tools, otherwise accepts `McpOptions`. |
23
22
 
24
23
  MCP options: `name` defaults to the action name; `readOnly` to `access === "read"`;
@@ -65,12 +64,11 @@ export const WhoAmI = Action.make("whoAmI", {
65
64
  access: "read",
66
65
  });
67
66
 
68
- // MCP-only: no HTTP route, no client method.
67
+ // A tool only: its group is left out of the HTTP binding (see ActionHttp.md).
69
68
  export const ListChanges = Action.make("listChanges", {
70
69
  description: "List renames in your tenant, oldest first.",
71
70
  success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
72
71
  access: "read",
73
- http: false,
74
72
  mcp: { name: "list_changes" },
75
73
  });
76
74
 
@@ -90,8 +88,10 @@ export const Double = Action.make("double", {
90
88
  - `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)).
91
89
  - A handler may fail only with the declared errors. Anything else is a defect.
92
90
  - `access` is `"read"` or `"write"` and is required. `make` also checks it at runtime, so a caller the compiler never sees cannot define an action no rule classifies. It stays a literal on the action, so a rule may switch on it at the type level. It is authorization metadata for a surface's `before` hook (see [guarantees.md](guarantees.md)); the library itself authorizes nothing. Its only built-in uses are default MCP hints and span/log annotations; adapters never enforce an authorization rule from it.
93
- - `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.
94
- - 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.
91
+ - `access` is independent of `mcp`. An action hidden from MCP (`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.
92
+ - An action has no HTTP switch. HTTP serves every action of every group it binds; to keep an action off HTTP, put it in a group the HTTP binding leaves out. `ActionCli` runs any action locally.
93
+ - `mcp: false` removes the tool. The action's type is hidden from MCP only when the options' type has a required `mcp: false`, such as a literal `mcp: false` in the object passed to `make`. Options that may serve it type it as served, so MCP layers keep its handler's requirements: a conditional spread of `{ mcp: false }`, `false | undefined`, a flag-driven value such as `enabled ? {} : false`, or an `Options` value whose `mcp` is optional.
94
+ - `make` accepts only the keys `Options` declares. An unknown key, such as a stale `http`, is a compile error.
95
95
  - MCP input must have an object-root JSON Schema, an identified or recursive root included. Scalar or array input is fine for HTTP and for a native Toolkit, but the native MCP server refuses it when an `ActionMcp` layer is built. Success and error schemas may be any shape.
96
96
  - `mcp.name` is the tool name, matches `[A-Za-z0-9_-]+` except `then`, is at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
97
97
  - 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.
@@ -105,4 +105,5 @@ export const Double = Action.make("double", {
105
105
  - Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
106
106
  - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
107
107
  - `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
108
+ - `Type 'false' is not assignable to type 'never'` on `http` at `make`: the option is gone. Delete it; to keep the action off HTTP, move it to a group the HTTP binding leaves out.
108
109
  - A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
@@ -7,15 +7,15 @@ Built from contracts alone: no implementation, service or server is involved.
7
7
 
8
8
  Import `@gjermundgaraba/effect-actions/ActionCatalog`.
9
9
 
10
- `make(...groups)` returns `Catalog`: `version: "3"` and an ordered `actions` array of `Entry`.
10
+ `make(...groups)` returns `Catalog`: `version: "4"` and an ordered `actions` array of `Entry`.
11
11
 
12
- | Entry field | Meaning |
13
- | ------------------------------------ | ---------------------------------------------------------------------------- |
14
- | `id`, `group`, `name`, `description` | Stable `<group>.<action>` identity and contract metadata. |
15
- | `http`, `mcp` | HTTP enablement and resolved MCP enablement/name/hints. |
16
- | `input`, `success` | Standalone draft 2020-12 JSON Schema objects, not wrappers. |
17
- | `errors` | JSON Schema objects for own and inherited group errors. |
18
- | `httpSchemaErrors` | JSON Schema objects for HTTP policy errors; empty for HTTP-disabled actions. |
12
+ | Entry field | Meaning |
13
+ | ------------------------------------ | ------------------------------------------------------------------- |
14
+ | `id`, `group`, `name`, `description` | Stable `<group>.<action>` identity and contract metadata. |
15
+ | `mcp` | Resolved MCP enablement, name and hints. |
16
+ | `input`, `success` | Standalone draft 2020-12 JSON Schema objects, not wrappers. |
17
+ | `errors` | JSON Schema objects for own and inherited group errors. |
18
+ | `httpSchemaErrors` | JSON Schema objects for HTTP policy errors; empty without a policy. |
19
19
 
20
20
  Exported types are `Catalog` and `Entry`.
21
21
 
@@ -43,7 +43,7 @@ Console.log(
43
43
  - Each schema root includes its own `$schema` and, where needed, `$defs`. Equal identifiers across entries or fields cannot replace each other; recursive references resolve locally.
44
44
  - The catalog contains JSON Schema only. Use Effect's `SchemaRepresentation` directly when native schema persistence or revival is needed.
45
45
  - `errors` includes group-level errors. HTTP schema-policy errors are separate in `httpSchemaErrors`. Surface errors belong to bindings and are not listed.
46
- - `access` is omitted; read it from the contract when needed. Local-only actions are included. Presence grants no authorization or proof that an endpoint is mounted; the host decides what to publish.
46
+ - `access` is omitted; read it from the contract when needed. Actions hidden from MCP are included. Presence grants no authorization or proof that an endpoint is mounted; the host decides what to publish.
47
47
  - No search, catalog HTTP endpoint, or TypeScript code generation is provided.
48
48
 
49
49
  ## Failure modes
package/docs/ActionCli.md CHANGED
@@ -1,17 +1,17 @@
1
1
  # ActionCli
2
2
 
3
3
  Native Effect CLI commands that run handlers in-process. The host provides every service;
4
- nothing is fetched over HTTP.
4
+ nothing is fetched over HTTP. Any action runs locally, whether or not HTTP or MCP serves it.
5
5
 
6
6
  ## API
7
7
 
8
8
  Import `@gjermundgaraba/effect-actions/ActionCli`.
9
9
 
10
- | API | Purpose |
11
- | ------------------------------ | ----------------------------------------------------------------- |
12
- | `command(app, name, options?)` | One selected action as a native Effect `Command`. |
13
- | `group(app, options?)` | Every action under a group command, including local-only actions. |
14
- | `Options`, `GroupOptions` | Configuration for single and aggregate commands. |
10
+ | API | Purpose |
11
+ | ------------------------------ | ------------------------------------------------- |
12
+ | `command(app, name, options?)` | One selected action as a native Effect `Command`. |
13
+ | `group(app, options?)` | Every action under a group command. |
14
+ | `Options`, `GroupOptions` | Configuration for single and aggregate commands. |
15
15
 
16
16
  | Option | Meaning |
17
17
  | --------------------- | ------------------------------------------------------------------------------------------------------------ |
@@ -56,7 +56,7 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
56
56
 
57
57
  ## Rules
58
58
 
59
- - `command(app, name)` selects one action by name from the bound group; the name is checked at the type level. `group(app)` puts every action under the group command, including actions with `http: false` and `mcp: false`.
59
+ - `command(app, name)` selects one action by name from the bound group; the name is checked at the type level. `group(app)` puts every action under the group command, including actions with `mcp: false` and groups no HTTP binding serves.
60
60
  - Default syntax is `--input '<json>'` or `--input-file <path>` carrying the entire encoded input: nested objects, arrays, scalars. The file is read and JSON-parsed by the native `Flag.FileSchema`. Both are decoded by the native parser; if both are given, the file is used. Omitting both decodes `{}` separately on every invocation; result identity follows the codec's behavior. An action that requires input then fails with a `SchemaError`, exactly as a `parameters` command's mapped input does.
61
61
  - `parameters` and `input` are supplied together or not at all. With them, the command has explicit native flags and arguments and neither `--input` nor `--input-file`, so its syntax does not change when the schema changes.
62
62
  - `input(parsed)` returns encoded JSON. The action schema decodes it before dispatch; a mismatch is a `SchemaError` at runtime. Action fields are never turned into flags automatically.
@@ -9,8 +9,8 @@ Import `@gjermundgaraba/effect-actions/ActionCliClient`.
9
9
 
10
10
  | API | Purpose |
11
11
  | ------------------------------------------------ | -------------------------------------------------------------------------- |
12
- | `command(http, groupName, actionName, options?)` | Call one HTTP-enabled action through a native Effect `Command`. |
13
- | `group(http, groupName, options?)` | Every HTTP-enabled action of a bound group under one command. |
12
+ | `command(http, groupName, actionName, options?)` | Call one action of the binding through a native Effect `Command`. |
13
+ | `group(http, groupName, options?)` | Every action of a bound group under one command. |
14
14
  | `Options`, `GroupOptions`, `Connection` | Single/aggregate command configuration and native HTTP connection options. |
15
15
 
16
16
  Single-command options: `name`, `render`, paired `parameters`/`input`, and `connection`.
@@ -49,7 +49,7 @@ Authentication: `connection: { transformClient: HttpClient.mapRequest(HttpClient
49
49
 
50
50
  ## Rules
51
51
 
52
- - Selectors resolve from `http.groups` only. `groupName` must be a bound group; `actionName` must be an action of that group with `http: true`. Runtime guards reject dynamically supplied strings that the types would not accept.
52
+ - Selectors resolve from `http.groups` only. `groupName` must be a bound group; `actionName` must be an action of that group. Runtime guards reject dynamically supplied strings that the types would not accept.
53
53
  - The host provides `HttpClient` and its configuration. Endpoint selection, credentials, and storage are the host's; nothing is inferred from action arguments.
54
54
  - Input is decoded by the action schema before dispatch, then passed to the native client at its normal codec boundary. Do not pre-encode values.
55
55
  - Input syntax (`--input`, `--input-file`, or `parameters`) and output (`render` and its `--json` flag) are exactly as in `ActionCli`.
@@ -57,7 +57,7 @@ Authentication: `connection: { transformClient: HttpClient.mapRequest(HttpClient
57
57
 
58
58
  ## Failure modes
59
59
 
60
- - Type error on `actionName`: the action is `http: false`, or the group is not bound in this `Http`.
60
+ - Type error on `actionName`: the action is not in that group, or the group is not bound in this `Http`.
61
61
  - `HttpClient` missing at runtime: provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`) to the runtime.
62
62
  - Connection refused: `connection.baseUrl` is absent or wrong. It has no default.
63
63
  - 401 from the server: add `transformClient` to `connection`. The command adds no headers on its own.