@gjermundgaraba/effect-actions 0.6.0 → 0.8.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 (48) hide show
  1. package/README.md +3 -3
  2. package/dist/Action.d.mts +29 -8
  3. package/dist/Action.mjs +2 -2
  4. package/dist/ActionCatalog.d.mts +2 -3
  5. package/dist/ActionCatalog.mjs +8 -6
  6. package/dist/ActionCli.d.mts +1 -1
  7. package/dist/ActionCli.mjs +1 -1
  8. package/dist/ActionCliClient.d.mts +11 -12
  9. package/dist/ActionCliClient.mjs +7 -14
  10. package/dist/ActionGroup.d.mts +1 -1
  11. package/dist/ActionGroup.mjs +3 -9
  12. package/dist/ActionHttp.d.mts +5 -7
  13. package/dist/ActionHttp.mjs +8 -6
  14. package/dist/ActionHttpClient.d.mts +8 -20
  15. package/dist/ActionHttpClient.mjs +8 -16
  16. package/dist/ActionMcp.d.mts +8 -7
  17. package/dist/ActionMcp.mjs +73 -4
  18. package/dist/ActionToolkit.d.mts +11 -12
  19. package/dist/ActionToolkit.mjs +1 -1
  20. package/dist/Authentication.d.mts +1 -1
  21. package/dist/Authentication.mjs +1 -1
  22. package/dist/Testing.d.mts +11 -9
  23. package/dist/Testing.mjs +10 -9
  24. package/dist/internal/actions.d.mts +1 -1
  25. package/dist/internal/cli.d.mts +1 -1
  26. package/dist/internal/cli.mjs +1 -1
  27. package/dist/internal/fetchClient.d.mts +6 -0
  28. package/dist/internal/fetchClient.mjs +8 -0
  29. package/dist/internal/implementation.d.mts +14 -15
  30. package/dist/internal/implementation.mjs +3 -3
  31. package/dist/internal/tools.d.mts +2 -2
  32. package/dist/internal/tools.mjs +16 -14
  33. package/docs/Action.md +27 -14
  34. package/docs/ActionCatalog.md +10 -10
  35. package/docs/ActionCli.md +8 -8
  36. package/docs/ActionCliClient.md +9 -9
  37. package/docs/ActionGroup.md +10 -8
  38. package/docs/ActionHttp.md +13 -13
  39. package/docs/ActionHttpClient.md +26 -19
  40. package/docs/ActionMcp.md +9 -6
  41. package/docs/ActionToolkit.md +5 -5
  42. package/docs/Authentication.md +2 -3
  43. package/docs/CONTEXT.md +6 -6
  44. package/docs/README.md +4 -4
  45. package/docs/Testing.md +8 -5
  46. package/docs/guarantees.md +5 -4
  47. package/docs/setup.md +6 -5
  48. package/package.json +11 -6
@@ -1,16 +1,85 @@
1
1
  import "./internal/implementation.mjs";
2
- import { bindTools } from "./internal/tools.mjs";
3
- import { Layer } from "effect";
4
- import { McpProtocol, McpServer } from "effect/unstable/ai";
2
+ import { TextField, bindTools } from "./internal/tools.mjs";
3
+ import { Context, Effect, Layer, Option, Predicate, Schema } from "effect";
4
+ import { McpProtocol, McpSchema, McpServer } from "effect/ai";
5
5
  //#region src/ActionMcp.ts
6
6
  /**
7
7
  * The one protocol revision served. 2026-07-28 is stateless over HTTP: no
8
8
  * initialize handshake and no session, so every request stands alone.
9
9
  */
10
10
  const protocols = [McpProtocol.v2026_07_28];
11
+ /** The parts of a listed output schema a text field is removed from. */
12
+ const decodeOutput = Schema.decodeUnknownOption(Schema.Struct({
13
+ properties: Schema.Record(Schema.String, Schema.Json),
14
+ required: Schema.optionalKey(Schema.Array(Schema.String))
15
+ }));
16
+ /** A JSON value that is an object: neither null, a scalar nor an array. */
17
+ const isJsonObject = (value) => Predicate.isObject(value);
18
+ /** Split a JSON object into one field and the rest. */
19
+ const detach = (value, field) => {
20
+ const { [field]: detached, ...rest } = value;
21
+ return {
22
+ detached,
23
+ rest
24
+ };
25
+ };
26
+ /**
27
+ * The listed output schema without the text field. Only a top-level property can be
28
+ * detached; anything else fails the layer build rather than publish a schema the
29
+ * structured content would not satisfy.
30
+ */
31
+ const outputWithout = (tool, field) => {
32
+ const { properties: _, required: __, ...output } = tool.outputSchema ?? {};
33
+ const listed = Option.getOrUndefined(decodeOutput(tool.outputSchema));
34
+ if (listed === void 0 || !Object.hasOwn(listed.properties, field)) return Effect.die(`MCP tool '${tool.name}' cannot send '${field}' as text: it is not a top-level property of its success`);
35
+ const required = (listed.required ?? []).filter((key) => key !== field);
36
+ return Effect.succeed({
37
+ ...output,
38
+ properties: detach(listed.properties, field).rest,
39
+ ...required.length === 0 ? {} : { required }
40
+ });
41
+ };
42
+ /**
43
+ * A successful result with a string text field sent once, raw, before the structured
44
+ * rest and its JSON copy. Any other result, such as a failure or a success without
45
+ * the field, is the native one.
46
+ */
47
+ const textResult = (result, field) => {
48
+ const structured = result.structuredContent;
49
+ if (result.isError === true || !isJsonObject(structured)) return result;
50
+ const { detached, rest } = detach(structured, field);
51
+ if (!Predicate.isString(detached)) return result;
52
+ return new McpSchema.CallToolResult({
53
+ ...result,
54
+ structuredContent: rest,
55
+ content: [{
56
+ type: "text",
57
+ text: detached
58
+ }, {
59
+ type: "text",
60
+ text: JSON.stringify(rest)
61
+ }]
62
+ });
63
+ };
64
+ /** A native registration whose success sends `field` as text. */
65
+ const withText = (registration, field) => Effect.map(outputWithout(registration.tool, field), (outputSchema) => ({
66
+ ...registration,
67
+ tool: new McpSchema.Tool({
68
+ ...registration.tool,
69
+ outputSchema
70
+ }),
71
+ handle: (payload) => Effect.map(registration.handle(payload), (result) => Predicate.isTagged(result, "InputRequired") ? result : textResult(result, field))
72
+ }));
11
73
  const registration = (apps, options) => {
12
74
  const binding = bindTools(apps, "mcp", options);
13
- return Layer.effectDiscard(McpServer.registerToolkit(binding.toolkit)).pipe(Layer.provide(binding.layer));
75
+ const registered = McpServer.registerToolkit(binding.toolkit).pipe(Effect.updateService(McpServer.McpServer, (server) => ({
76
+ ...server,
77
+ addTool: (tool) => {
78
+ const field = Context.get(tool.annotations, TextField);
79
+ return field === void 0 ? server.addTool(tool) : Effect.flatMap(withText(tool, field), server.addTool);
80
+ }
81
+ })));
82
+ return Layer.effectDiscard(registered).pipe(Layer.provide(binding.layer));
14
83
  };
15
84
  const server = (apps, options, transport) => registration(apps, options).pipe(Layer.provide(transport), Layer.fresh);
16
85
  function layerHttp(apps, options) {
@@ -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
- import { Tool, Toolkit } from "effect/unstable/ai";
5
+ import { Tool, Toolkit } from "effect/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,7 +1,7 @@
1
1
  import "./internal/implementation.mjs";
2
2
  import { bindTools } from "./internal/tools.mjs";
3
3
  import "effect";
4
- import "effect/unstable/ai";
4
+ import "effect/ai";
5
5
  //#region src/ActionToolkit.ts
6
6
  function make(apps, options = {}) {
7
7
  return bindTools(apps, "native", options);
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect } from "effect";
2
- import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
3
  import { NonEmptyReadonlyArray } from "effect/Array";
4
4
  //#region src/Authentication.d.ts
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect";
2
- import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2
+ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
3
3
  //#region src/Authentication.ts
4
4
  /**
5
5
  * Authenticate each request and provide its identity to the downstream handler.
@@ -1,5 +1,6 @@
1
- import { Effect, Schema } 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/http-api";
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/http-api/HttpApiClient").Client.GroupByEndpoint<Group, never, never>; } & import("effect/http-api/HttpApiClient").Client.TopLevelMethods<Groups, never, never>>, never, Exclude<Exclude<import("effect/http-api/HttpApiMiddleware").MiddlewareClient<import("effect/http-api/HttpApiEndpoint").Middleware<HttpApiGroup.Endpoints<Groups>>>, import("effect/http/HttpClient").HttpClient>, never>>;
13
14
  /** A stateless 2026-07-28 request. */
14
15
  export interface McpRequestOptions {
15
16
  readonly url: string | URL;
@@ -45,14 +46,15 @@ export interface McpCallOptions {
45
46
  readonly headers?: ConstructorParameters<typeof Headers>[0];
46
47
  }
47
48
  /**
48
- * A tool call's outcome with the library's wire envelope removed: the success from
49
- * `structuredContent.value`, or the error text of an `isError` result, parsed as JSON
50
- * when it is JSON (a declared error) and kept as the text otherwise (the native
51
- * server's own message, such as for invalid arguments).
49
+ * A tool call's outcome: the success from `structuredContent`, with the text an action's
50
+ * `mcp.text` field sent before it; or the error text of an `isError` result, parsed as
51
+ * JSON when it is JSON (a declared error, whatever its shape) and kept as the text
52
+ * otherwise (the native server's own message, such as for invalid arguments).
52
53
  */
53
54
  export type McpCallResult = {
54
55
  readonly isError: false;
55
56
  readonly value: Schema.Json;
57
+ readonly text?: string;
56
58
  } | {
57
59
  readonly isError: true;
58
60
  readonly error: Schema.Json;
@@ -62,5 +64,5 @@ export type McpCallResult = {
62
64
  * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
63
65
  * authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
64
66
  */
65
- export declare const mcpCall: (handler: Handler, { url, name, headers, ...options }: McpCallOptions) => Promise<McpCallResult>;
67
+ export declare const mcpCall: (handler: Handler, { url, name, headers, arguments: args }: McpCallOptions) => Promise<McpCallResult>;
66
68
  //#endregion
package/dist/Testing.mjs CHANGED
@@ -1,15 +1,14 @@
1
- import { Effect, Layer, Option, Predicate, Schema } 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);
@@ -41,7 +40,7 @@ const mcpRequest = ({ url, method, params = {}, headers: init }) => {
41
40
  /** The JSON-RPC response to a `tools/call`: a tool result or a protocol error. */
42
41
  const ToolReply = Schema.Union([Schema.Struct({ result: Schema.Struct({
43
42
  isError: Schema.optionalKey(Schema.Boolean),
44
- structuredContent: Schema.optionalKey(Schema.Struct({ value: Schema.Json })),
43
+ structuredContent: Schema.optionalKey(Schema.Json),
45
44
  content: Schema.Array(Schema.Struct({
46
45
  type: Schema.String,
47
46
  text: Schema.optionalKey(Schema.String)
@@ -57,13 +56,13 @@ const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))
57
56
  * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
58
57
  * authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
59
58
  */
60
- const mcpCall = async (handler, { url, name, headers, ...options }) => {
59
+ const mcpCall = async (handler, { url, name, headers, arguments: args = {} }) => {
61
60
  const response = await handler(mcpRequest({
62
61
  url,
63
62
  method: "tools/call",
64
63
  params: {
65
64
  name,
66
- arguments: options.arguments ?? {}
65
+ arguments: args
67
66
  },
68
67
  ...headers === void 0 ? {} : { headers }
69
68
  }));
@@ -81,9 +80,11 @@ const mcpCall = async (handler, { url, name, headers, ...options }) => {
81
80
  };
82
81
  }
83
82
  if (result.structuredContent === void 0) throw new Error(`MCP tools/call "${name}" returned no structured content: ${body}`);
83
+ const [text] = result.content.length > 1 ? result.content : [];
84
84
  return {
85
85
  isError: false,
86
- value: result.structuredContent.value
86
+ value: result.structuredContent,
87
+ ...text?.text === void 0 ? {} : { text: text.text }
87
88
  };
88
89
  };
89
90
  //#endregion
@@ -1,5 +1,5 @@
1
1
  import { Action_d_exports, Any, Codec } from "../Action.mjs";
2
- import { HttpApiError } from "effect/unstable/httpapi";
2
+ import { HttpApiError } from "effect/http-api";
3
3
  //#region src/internal/actions.d.ts
4
4
  /** One side of a schema-error policy: the error it answers with, and how to make it. */
5
5
  export interface SchemaErrorAnswer<E extends Codec> {
@@ -1,6 +1,6 @@
1
1
  import "../Action.mjs";
2
2
  import { Effect, Schema } from "effect";
3
- import { Command } from "effect/unstable/cli";
3
+ import { Command } from "effect/cli";
4
4
  //#region src/internal/cli.d.ts
5
5
  /** Options shared by the local and HTTP command projections. */
6
6
  interface CommonOptions<Output> {
@@ -1,5 +1,5 @@
1
1
  import { Console, Effect, Option, Schema } from "effect";
2
- import { Command, Flag } from "effect/unstable/cli";
2
+ import { Command, Flag } from "effect/cli";
3
3
  //#region src/internal/cli.ts
4
4
  const isParametersOptions = (options) => options?.parameters !== void 0;
5
5
  /**
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/http-api";
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/http";
3
+ import { HttpApiClient } from "effect/http-api";
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.
@@ -30,8 +30,8 @@ var Implementation = class Implementation {
30
30
  * per-request services while assembling routes.
31
31
  */
32
32
  const dispatch = (group, action, handlers, before) => {
33
- const handle = handlers[action.name];
34
- 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}`);
35
35
  const attributes = {
36
36
  "action.group": group.name,
37
37
  "action.name": action.name,
@@ -1,7 +1,7 @@
1
1
  import { Action_d_exports, Any, Codec } from "../Action.mjs";
2
2
  import "./implementation.mjs";
3
- import { Effect, Layer } from "effect";
4
- import { Tool, Toolkit } from "effect/unstable/ai";
3
+ import { Context, Effect, Layer } from "effect";
4
+ import { Tool, Toolkit } from "effect/ai";
5
5
  //#region src/internal/tools.d.ts
6
6
  /** What a tool surface binds around the implementations it projects. */
7
7
  export interface SurfaceOptions<Errors extends ReadonlyArray<Codec>, R> {
@@ -1,8 +1,14 @@
1
1
  import { assertDistinct, projectedErrors } from "./actions.mjs";
2
2
  import { dispatch } from "./implementation.mjs";
3
- import { Effect, Schema } from "effect";
4
- import { Tool, Toolkit } from "effect/unstable/ai";
3
+ import { Context, Effect, Schema } from "effect";
4
+ import { Tool, Toolkit } from "effect/ai";
5
5
  //#region src/internal/tools.ts
6
+ /**
7
+ * The success field an MCP tool sends as a raw text block, from `mcp.text`. The native
8
+ * server reads a tool's annotations when it registers the tool, so `ActionMcp` finds
9
+ * it there.
10
+ */
11
+ const TextField = Context.Reference("@gjermundgaraba/effect-actions/TextField", { defaultValue: () => void 0 });
6
12
  const isToolAction = (action) => action.mcp !== false;
7
13
  /** Only tool-enabled groups are acquired; each implementation appears once. */
8
14
  const toolApps = (apps) => apps.flatMap((app) => {
@@ -30,30 +36,26 @@ const nativeTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
30
36
  failureMode: "return"
31
37
  }), action.mcp);
32
38
  /**
33
- * MCP has a JSON-only wire contract. Success uses its documented `{ value }`
34
- * structured-content envelope; declared failures are returned as JSON text.
39
+ * MCP has a JSON-only wire contract. Success is the encoded success as structured
40
+ * content; declared failures are returned as JSON text.
35
41
  */
36
42
  const mcpTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
37
43
  description: action.description,
38
44
  parameters: Schema.toCodecJson(action.input),
39
- success: Schema.toCodecJson(Schema.Struct({ value: action.success })),
45
+ success: Schema.toCodecJson(action.success),
40
46
  failure: Schema.toCodecJson(Schema.Union(errors)),
41
47
  failureMode: "return"
42
- }), action.mcp);
48
+ }), action.mcp).annotate(TextField, action.mcp.text);
43
49
  const project = (projection, entry, options) => {
44
50
  const errors = projectedErrors(entry.action, options.errors);
45
51
  const tool = projection === "native" ? nativeTool(entry.action, errors) : mcpTool(entry.action, errors);
46
52
  if (projection === "native") return tool;
47
53
  return tool.annotate(Tool.Strict, true);
48
54
  };
49
- const handler = (projection, app, action, handlers, before) => {
50
- const run = dispatch(app.group, action, handlers, before);
51
- return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
52
- };
53
55
  /**
54
56
  * Project MCP-enabled actions and acquire their handlers exactly once per adapter layer.
55
- * Native and MCP differ only in tool codecs and the MCP success envelope; selection,
56
- * scoped acquisition, dispatch and native Toolkit binding stay identical.
57
+ * Native and MCP differ only in tool codecs; selection, scoped acquisition, dispatch
58
+ * and native Toolkit binding stay identical.
57
59
  */
58
60
  const bindTools = (apps, projection, options) => {
59
61
  const served = toolApps(apps);
@@ -64,9 +66,9 @@ const bindTools = (apps, projection, options) => {
64
66
  toolkit,
65
67
  layer: toolkit.toLayer(Effect.map(Effect.forEach(served, ({ app, actions }) => Effect.map(app.build, (handlers) => {
66
68
  const record = handlers;
67
- return actions.map((action) => [action.mcp.name, handler(projection, app, action, record, options.before)]);
69
+ return actions.map((action) => [action.mcp.name, dispatch(app.group, action, record, options.before)]);
68
70
  })), (built) => Object.fromEntries(built.flat())))
69
71
  };
70
72
  };
71
73
  //#endregion
72
- export { bindTools };
74
+ export { TextField, bindTools };
package/docs/Action.md CHANGED
@@ -7,22 +7,22 @@ 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"`;
25
- `destructive` to `!readOnly`; `idempotent` to `false`; `openWorld` to `true`.
24
+ `destructive` to `!readOnly`; `idempotent` to `false`; `openWorld` to `true`. `text` has no
25
+ default; it names the success's text field.
26
26
  Handlers receive decoded input and return decoded success, failing only with declared errors.
27
27
 
28
28
  ## Canonical
@@ -65,15 +65,24 @@ export const WhoAmI = Action.make("whoAmI", {
65
65
  access: "read",
66
66
  });
67
67
 
68
- // MCP-only: no HTTP route, no client method.
68
+ // A tool only: its group is left out of the HTTP binding (see ActionHttp.md).
69
69
  export const ListChanges = Action.make("listChanges", {
70
70
  description: "List renames in your tenant, oldest first.",
71
71
  success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
72
72
  access: "read",
73
- http: false,
74
73
  mcp: { name: "list_changes" },
75
74
  });
76
75
 
76
+ // MCP sends `markdown` once, raw, then the rest as structured content.
77
+ // Every other surface serves the whole page.
78
+ export const ReadPage = Action.make("readPage", {
79
+ description: "Read one page of a document as Markdown.",
80
+ input: Schema.Struct({ url: Schema.String }),
81
+ success: Schema.Struct({ markdown: Schema.String, next: Schema.optionalKey(Schema.String) }),
82
+ access: "read",
83
+ mcp: { name: "read_page", text: "markdown" },
84
+ });
85
+
77
86
  // Input decoding is the schema's: "21" on the wire, 21 in the handler.
78
87
  export const Double = Action.make("double", {
79
88
  description: "Double a finite number supplied as a string.",
@@ -90,14 +99,17 @@ export const Double = Action.make("double", {
90
99
  - `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
100
  - A handler may fail only with the declared errors. Anything else is a defect.
92
101
  - `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.
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
- - `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.
102
+ - `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.
103
+ - 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.
104
+ - `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.
105
+ - `make` accepts only the keys `Options` declares. An unknown key, such as a stale `http`, is a compile error.
106
+ - 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. `Schema.Struct({})` is not an object root either: omit `input` for no arguments. Success and error schemas may be any shape.
107
+ - `mcp.name` is the tool name, matches `[A-Za-z0-9_-]+` except `then`, is at most 128 characters, unique within each Toolkit or MCP projection that serves it. Default is the action name.
108
+ - `mcp.text` names a string field of the encoded success, the text field; it may be optional. `make` refuses a field that is not a string. Building the MCP layer refuses a field that is not a top-level property of the success's JSON Schema, such as one of a union of structs. It changes only the MCP result (see [ActionMcp.md](ActionMcp.md)).
97
109
  - 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.
98
110
  - Schemas must be service-free. Put service access in the handler.
99
111
  - The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
100
- - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP is not strict, and cannot be made strict without a hack: Effect merges one `HttpApi.ParseOptions` per endpoint and uses it for payload decoding _and_ error encoding, so `onExcessProperty: "error"` also rejects a `TaggedError` instance's own `message` and `stack`, turning a declared 409 into an empty 500. Making the payload schema itself strict would require wrapping it in an open schema, which erases its OpenAPI shape. Declare the fields you accept and treat extra HTTP fields as ignored.
112
+ - Input is closed: HTTP, MCP and typed clients refuse fields `input` does not declare. Details in [guarantees.md](guarantees.md).
101
113
 
102
114
  ## Failure modes
103
115
 
@@ -105,4 +117,5 @@ export const Double = Action.make("double", {
105
117
  - 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
118
  - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
107
119
  - `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
120
+ - `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
121
  - 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, hints and text field. |
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
 
@@ -38,12 +38,12 @@ Console.log(
38
38
  - Takes groups, not implementations. Nothing is acquired or started.
39
39
  - Group names must be unique. Entries preserve group declaration order, then action declaration order.
40
40
  - `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
41
- - Schemas describe encoded action values through native `Schema.toJsonSchemaDocument`, which lowers to the JSON codec first. A `Date` is a string, an `Option` a tagged union, a `bigint` a string of digits. These are action values, not MCP's `{ value }` envelope or deployment URLs.
41
+ - Schemas describe encoded action values through native `Schema.toJsonSchemaDocument`, which lowers to the JSON codec first. Objects are closed (`additionalProperties: false`), as in the OpenAPI document. A `Date` is a string, an `Option` a tagged union, a `bigint` a string of digits. These are whole action values, as HTTP sends them, not MCP's split of a text field or deployment URLs.
42
42
  - JSON Schema does not preserve arbitrary Effect transformations or filters. Checks on the decoded side of a transformation, such as `FiniteFromString.check(isGreaterThan(0))`, are not described. The server's decode remains authoritative.
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