@gjermundgaraba/effect-actions 0.7.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 (42) hide show
  1. package/README.md +3 -3
  2. package/dist/Action.d.mts +14 -1
  3. package/dist/Action.mjs +2 -1
  4. package/dist/ActionCatalog.mjs +5 -2
  5. package/dist/ActionCli.d.mts +1 -1
  6. package/dist/ActionCli.mjs +1 -1
  7. package/dist/ActionCliClient.d.mts +3 -3
  8. package/dist/ActionCliClient.mjs +3 -3
  9. package/dist/ActionGroup.mjs +0 -1
  10. package/dist/ActionHttp.d.mts +2 -2
  11. package/dist/ActionHttp.mjs +6 -3
  12. package/dist/ActionMcp.d.mts +2 -2
  13. package/dist/ActionMcp.mjs +73 -4
  14. package/dist/ActionToolkit.d.mts +1 -1
  15. package/dist/ActionToolkit.mjs +1 -1
  16. package/dist/Authentication.d.mts +1 -1
  17. package/dist/Authentication.mjs +1 -1
  18. package/dist/Testing.d.mts +7 -6
  19. package/dist/Testing.mjs +4 -2
  20. package/dist/internal/actions.d.mts +1 -1
  21. package/dist/internal/cli.d.mts +1 -1
  22. package/dist/internal/cli.mjs +1 -1
  23. package/dist/internal/fetchClient.d.mts +1 -1
  24. package/dist/internal/fetchClient.mjs +2 -2
  25. package/dist/internal/tools.d.mts +2 -2
  26. package/dist/internal/tools.mjs +16 -14
  27. package/docs/Action.md +16 -4
  28. package/docs/ActionCatalog.md +2 -2
  29. package/docs/ActionCli.md +1 -1
  30. package/docs/ActionCliClient.md +5 -5
  31. package/docs/ActionGroup.md +8 -6
  32. package/docs/ActionHttp.md +7 -7
  33. package/docs/ActionHttpClient.md +3 -3
  34. package/docs/ActionMcp.md +7 -4
  35. package/docs/ActionToolkit.md +4 -4
  36. package/docs/Authentication.md +2 -3
  37. package/docs/CONTEXT.md +4 -3
  38. package/docs/README.md +4 -4
  39. package/docs/Testing.md +8 -5
  40. package/docs/guarantees.md +4 -3
  41. package/docs/setup.md +6 -5
  42. package/package.json +11 -6
package/README.md CHANGED
@@ -73,10 +73,10 @@ Code that does not run Effects, such as a browser page, gets the same calls as P
73
73
  ## Install
74
74
 
75
75
  ```sh
76
- pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.117
76
+ pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.118
77
77
  ```
78
78
 
79
- Add `@effect/platform-node@4.0.0-rc.117` to serve from Node. Every module is a subpath import
79
+ Add `@effect/platform-node@4.0.0-rc.118` to serve from Node. Every module is a subpath import
80
80
  (`.../Action`, `.../ActionHttp`, ...); there is no package root, so a contracts-only bundle
81
81
  never loads a server.
82
82
 
@@ -101,7 +101,7 @@ in `node_modules/@gjermundgaraba/effect-actions/docs`) gives the same content.
101
101
 
102
102
  ## Status
103
103
 
104
- The `effect` peer accepts any Effect 4.0 release candidate from `4.0.0-rc.116` on; the package
104
+ The `effect` peer accepts any Effect 4.0 release candidate from `4.0.0-rc.118` on; the package
105
105
  is built and tested against the release candidate in its `devDependencies` (see [docs/setup.md](docs/setup.md)). Actions are unary JSON over HTTP and MCP: no streaming, uploads, prompts, or resources.
106
106
  Authentication and authorization belong to the application. See [docs/setup.md](docs/setup.md).
107
107
 
package/dist/Action.d.mts CHANGED
@@ -21,13 +21,22 @@ export interface McpOptions {
21
21
  readonly idempotent?: boolean;
22
22
  /** `openWorldHint`; defaults to `true`. */
23
23
  readonly openWorld?: boolean;
24
+ /**
25
+ * A string field of the encoded success that MCP sends as a raw text block rather
26
+ * than as structured content, such as a document body. Other surfaces serve the
27
+ * whole success.
28
+ */
29
+ readonly text?: string;
24
30
  }
31
+ /** The fields `mcp.text` may name: those an encoded success has as a string. */
32
+ type TextFields<Encoded> = Encoded extends object ? { readonly [K in keyof Encoded]-?: Required<Encoded>[K] extends string ? K : never; }[keyof Encoded] & string : never;
25
33
  type ResolvedMcp<Name extends string, Mcp extends false | McpOptions | undefined> = Mcp extends false ? false : {
26
34
  readonly name: Mcp extends McpOptions ? "name" extends keyof Mcp ? Extract<Mcp["name"], string> extends never ? Name : Extract<Mcp["name"], string> | (undefined extends Mcp["name"] ? Name : never) : Name : Name;
27
35
  readonly readOnly: boolean;
28
36
  readonly destructive: boolean;
29
37
  readonly idempotent: boolean;
30
38
  readonly openWorld: boolean;
39
+ readonly text?: string;
31
40
  };
32
41
  /** What `make` needs to define an action. */
33
42
  export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Acc extends Access = Access, Mcp extends false | McpOptions | undefined = McpOptions | undefined> {
@@ -77,6 +86,10 @@ type McpOf<O extends AnyOptions> = [O] extends [{
77
86
  * Define an action contract. Names are `[A-Za-z0-9_-]+`, other than `then`.
78
87
  * HTTP serves every action of a group it binds; `mcp: false` hides one from MCP.
79
88
  */
80
- export declare function make<const Name extends string, const O extends AnyOptions>(name: Name, options: O & { readonly [K in Exclude<keyof O, keyof AnyOptions>]: never; }): Action<Name, "input" extends keyof O ? Exclude<O["input"], undefined> : typeof NoInput, O["success"], "errors" extends keyof O ? Exclude<O["errors"], undefined> : [], O["access"], McpOf<O>>;
89
+ export declare function make<const Name extends string, const O extends AnyOptions>(name: Name, options: O & { readonly [K in Exclude<keyof O, keyof AnyOptions>]: never; } & {
90
+ readonly mcp?: false | (McpOptions & {
91
+ readonly text?: TextFields<O["success"]["Encoded"]>;
92
+ }) | undefined;
93
+ }): Action<Name, "input" extends keyof O ? Exclude<O["input"], undefined> : typeof NoInput, O["success"], "errors" extends keyof O ? Exclude<O["errors"], undefined> : [], O["access"], McpOf<O>>;
81
94
  //#endregion
82
95
  export { Action_d_exports };
package/dist/Action.mjs CHANGED
@@ -13,7 +13,8 @@ function make(name, options) {
13
13
  readOnly,
14
14
  destructive: options.mcp?.destructive ?? !readOnly,
15
15
  idempotent: options.mcp?.idempotent ?? false,
16
- openWorld: options.mcp?.openWorld ?? true
16
+ openWorld: options.mcp?.openWorld ?? true,
17
+ ...options.mcp?.text === void 0 ? {} : { text: options.mcp.text }
17
18
  };
18
19
  if (mcp !== false && mcp.name.length > 128) throw new Error(`Invalid MCP name: ${mcp.name}`);
19
20
  if (mcp !== false) assertName("MCP name", mcp.name);
@@ -1,9 +1,12 @@
1
1
  import { assertDistinct, policyErrors } from "./internal/actions.mjs";
2
2
  import { JsonSchema, Schema } from "effect";
3
3
  //#region src/ActionCatalog.ts
4
- /** Describe the JSON wire form; decoding on the server remains authoritative. */
4
+ /**
5
+ * Describe the JSON wire form closed, as the OpenAPI document does; decoding on the
6
+ * server remains authoritative.
7
+ */
5
8
  const describe = (codec) => {
6
- const { schema, definitions } = Schema.toJsonSchemaDocument(codec);
9
+ const { schema, definitions } = Schema.toJsonSchemaDocument(codec, { onExcessProperty: "error" });
7
10
  return {
8
11
  $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
9
12
  ...schema,
@@ -3,7 +3,7 @@ import { Actions } from "./internal/actions.mjs";
3
3
  import { HandlerContext, Handlers, HandlersContext, Implementation } from "./internal/implementation.mjs";
4
4
  import { Options as Options$1 } from "./internal/cli.mjs";
5
5
  import { Effect, Scope } from "effect";
6
- import { Command } from "effect/unstable/cli";
6
+ import { Command } from "effect/cli";
7
7
  //#region src/ActionCli.d.ts
8
8
  /**
9
9
  * The hook a local command runs before the selected handler. A CLI does not serialize
@@ -2,7 +2,7 @@ import { selectNamed } from "./internal/actions.mjs";
2
2
  import { dispatch } from "./internal/implementation.mjs";
3
3
  import { command as command$1 } from "./internal/cli.mjs";
4
4
  import { Effect } from "effect";
5
- import { Command } from "effect/unstable/cli";
5
+ import { Command } from "effect/cli";
6
6
  //#region src/ActionCli.ts
7
7
  /**
8
8
  * Acquire the implementation in a scope of its own, run one action through the hook
@@ -3,9 +3,9 @@ import { Actions } from "./internal/actions.mjs";
3
3
  import { Options as Options$1 } from "./internal/cli.mjs";
4
4
  import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
5
5
  import { Effect } from "effect";
6
- import { HttpClient } from "effect/unstable/http";
7
- import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
8
- import { Command } from "effect/unstable/cli";
6
+ import { HttpClient } from "effect/http";
7
+ import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/http-api";
8
+ import { Command } from "effect/cli";
9
9
  //#region src/ActionCliClient.d.ts
10
10
  /**
11
11
  * What the host configures on Effect's native client: `baseUrl`, `transformClient`,
@@ -1,9 +1,9 @@
1
1
  import { selectNamed } from "./internal/actions.mjs";
2
2
  import { command as command$1 } from "./internal/cli.mjs";
3
3
  import { Effect } from "effect";
4
- import { HttpClient } from "effect/unstable/http";
5
- import { HttpApiClient } from "effect/unstable/httpapi";
6
- import { Command } from "effect/unstable/cli";
4
+ import { HttpClient } from "effect/http";
5
+ import { HttpApiClient } from "effect/http-api";
6
+ import { Command } from "effect/cli";
7
7
  //#region src/ActionCliClient.ts
8
8
  /**
9
9
  * The native endpoint builder needs string selectors for a runtime-selected route.
@@ -16,7 +16,6 @@ function make(options, ...declared) {
16
16
  errors: [...action.errors, ...options.errors ?? []]
17
17
  }));
18
18
  assertDistinct("action", actions.map((action) => action.name));
19
- assertDistinct("MCP tool", actions.flatMap((action) => action.mcp === false ? [] : [action.mcp.name]));
20
19
  const group = {
21
20
  name,
22
21
  actions,
@@ -2,8 +2,8 @@ import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
2
  import { Actions, PolicyError } from "./internal/actions.mjs";
3
3
  import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
4
4
  import { Effect, Layer, Schema } from "effect";
5
- import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
6
- import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
5
+ import { Etag, HttpPlatform, HttpRouter } from "effect/http";
6
+ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/http-api";
7
7
  import { FileSystem } from "effect/FileSystem";
8
8
  import { Path } from "effect/Path";
9
9
  declare namespace ActionHttp_d_exports {
@@ -1,8 +1,8 @@
1
1
  import { answerSchemaError, assertDistinct, policyErrors, projectedErrors } from "./internal/actions.mjs";
2
2
  import { dispatch } from "./internal/implementation.mjs";
3
3
  import { Effect, Layer } from "effect";
4
- import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
5
- import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
4
+ import { HttpRouter, HttpServerResponse } from "effect/http";
5
+ import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/http-api";
6
6
  //#region src/ActionHttp.ts
7
7
  const endpoint = (options, group, action) => HttpApiEndpoint.post(action.name, `${options.apiPath}/${group.name}/${action.name}`, {
8
8
  payload: action.input,
@@ -33,7 +33,10 @@ function bind(options, group) {
33
33
  }
34
34
  function apiOf(groups) {
35
35
  const [first, ...rest] = groups;
36
- const empty = HttpApi.make("actions").annotate(HttpApi.ParseOptions, { errors: "all" });
36
+ const empty = HttpApi.make("actions").annotate(HttpApi.PayloadParseOptions, {
37
+ errors: "all",
38
+ onExcessProperty: "error"
39
+ });
37
40
  return first === void 0 ? empty : empty.add(first, ...rest);
38
41
  }
39
42
  /**
@@ -2,8 +2,8 @@ import { Action_d_exports, Codec } from "./Action.mjs";
2
2
  import { AnyImplementation, BuildContext, BuildError, HiddenFromMcp, RequestContext } from "./internal/implementation.mjs";
3
3
  import { SurfaceOptions } from "./internal/tools.mjs";
4
4
  import { Cause, Layer } from "effect";
5
- import { HttpRouter } from "effect/unstable/http";
6
- import { McpSchema, McpServer } from "effect/unstable/ai";
5
+ import { HttpRouter } from "effect/http";
6
+ import { McpSchema, McpServer } from "effect/ai";
7
7
  import { Stdio } from "effect/Stdio";
8
8
  //#region src/ActionMcp.d.ts
9
9
  /**
@@ -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) {
@@ -2,7 +2,7 @@ import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
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>;
@@ -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,6 +1,6 @@
1
1
  import { ClientOptions } from "./internal/fetchClient.mjs";
2
2
  import { Schema } from "effect";
3
- import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
3
+ import { HttpApi, HttpApiGroup } from "effect/http-api";
4
4
  //#region src/Testing.d.ts
5
5
  /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
6
6
  export type Handler = (request: Request) => Promise<Response>;
@@ -10,7 +10,7 @@ export type Handler = (request: Request) => Promise<Response>;
10
10
  */
11
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, {
12
12
  readonly topLevel: false;
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
+ }> 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>>;
14
14
  /** A stateless 2026-07-28 request. */
15
15
  export interface McpRequestOptions {
16
16
  readonly url: string | URL;
@@ -46,14 +46,15 @@ export interface McpCallOptions {
46
46
  readonly headers?: ConstructorParameters<typeof Headers>[0];
47
47
  }
48
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).
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).
53
53
  */
54
54
  export type McpCallResult = {
55
55
  readonly isError: false;
56
56
  readonly value: Schema.Json;
57
+ readonly text?: string;
57
58
  } | {
58
59
  readonly isError: true;
59
60
  readonly error: Schema.Json;
package/dist/Testing.mjs CHANGED
@@ -40,7 +40,7 @@ const mcpRequest = ({ url, method, params = {}, headers: init }) => {
40
40
  /** The JSON-RPC response to a `tools/call`: a tool result or a protocol error. */
41
41
  const ToolReply = Schema.Union([Schema.Struct({ result: Schema.Struct({
42
42
  isError: Schema.optionalKey(Schema.Boolean),
43
- structuredContent: Schema.optionalKey(Schema.Struct({ value: Schema.Json })),
43
+ structuredContent: Schema.optionalKey(Schema.Json),
44
44
  content: Schema.Array(Schema.Struct({
45
45
  type: Schema.String,
46
46
  text: Schema.optionalKey(Schema.String)
@@ -80,9 +80,11 @@ const mcpCall = async (handler, { url, name, headers, arguments: args = {} }) =>
80
80
  };
81
81
  }
82
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 : [];
83
84
  return {
84
85
  isError: false,
85
- value: result.structuredContent.value
86
+ value: result.structuredContent,
87
+ ...text?.text === void 0 ? {} : { text: text.text }
86
88
  };
87
89
  };
88
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
  /**
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect";
2
- import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi";
2
+ import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/http-api";
3
3
  //#region src/internal/fetchClient.d.ts
4
4
  /** The native `HttpApiClient.make` options: `baseUrl`, `transformClient`, `transformResponse`. */
5
5
  export type ClientOptions = NonNullable<Parameters<typeof HttpApiClient.make>[1]>;
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect";
2
- import { FetchHttpClient } from "effect/unstable/http";
3
- import { HttpApiClient } from "effect/unstable/httpapi";
2
+ import { FetchHttpClient } from "effect/http";
3
+ import { HttpApiClient } from "effect/http-api";
4
4
  //#region src/internal/fetchClient.ts
5
5
  /** The native grouped `HttpApiClient` of `api`, sending every request through `fetch`. */
6
6
  const fetchApiClient = (api, fetch, options) => HttpApiClient.make(api, options).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, fetch));
@@ -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
@@ -21,7 +21,8 @@ Import `@gjermundgaraba/effect-actions/Action`.
21
21
  | `mcp` | Defaults to enabled; `false` removes tools, otherwise accepts `McpOptions`. |
22
22
 
23
23
  MCP options: `name` defaults to the action name; `readOnly` to `access === "read"`;
24
- `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.
25
26
  Handlers receive decoded input and return decoded success, failing only with declared errors.
26
27
 
27
28
  ## Canonical
@@ -72,6 +73,16 @@ export const ListChanges = Action.make("listChanges", {
72
73
  mcp: { name: "list_changes" },
73
74
  });
74
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
+
75
86
  // Input decoding is the schema's: "21" on the wire, 21 in the handler.
76
87
  export const Double = Action.make("double", {
77
88
  description: "Double a finite number supplied as a string.",
@@ -92,12 +103,13 @@ export const Double = Action.make("double", {
92
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.
93
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.
94
105
  - `make` accepts only the keys `Options` declares. An unknown key, such as a stale `http`, is a compile error.
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.
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
 
@@ -12,7 +12,7 @@ Import `@gjermundgaraba/effect-actions/ActionCatalog`.
12
12
  | Entry field | Meaning |
13
13
  | ------------------------------------ | ------------------------------------------------------------------- |
14
14
  | `id`, `group`, `name`, `description` | Stable `<group>.<action>` identity and contract metadata. |
15
- | `mcp` | Resolved MCP enablement, name and hints. |
15
+ | `mcp` | Resolved MCP enablement, name, hints and text field. |
16
16
  | `input`, `success` | Standalone draft 2020-12 JSON Schema objects, not wrappers. |
17
17
  | `errors` | JSON Schema objects for own and inherited group errors. |
18
18
  | `httpSchemaErrors` | JSON Schema objects for HTTP policy errors; empty without a policy. |
@@ -38,7 +38,7 @@ 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.
package/docs/ActionCli.md CHANGED
@@ -28,7 +28,7 @@ scope. Success output is encoded; failures remain failures of the command Effect
28
28
 
29
29
  ```ts
30
30
  import { Console, Effect, Logger } from "effect";
31
- import { Command, Flag } from "effect/unstable/cli";
31
+ import { Command, Flag } from "effect/cli";
32
32
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
33
33
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
34
34
  import { actors, authorize, CurrentActor } from "./auth.js";
@@ -25,7 +25,7 @@ retain the selected endpoint's client failures and middleware requirements, and
25
25
  ## Canonical
26
26
 
27
27
  ```ts
28
- import { Command } from "effect/unstable/cli";
28
+ import { Command } from "effect/cli";
29
29
  import { Console, Effect, Logger } from "effect";
30
30
  import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
31
31
  import * as ActionCliClient from "@gjermundgaraba/effect-actions/ActionCliClient";
@@ -51,13 +51,13 @@ Authentication: `connection: { transformClient: HttpClient.mapRequest(HttpClient
51
51
 
52
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
- - 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.
54
+ - Input (`--input`, `--input-file`, or `input(parsed)`) is encoded JSON. The command decodes it with the action schema, and the native client re-encodes it with `Http.api`'s closed payload codec.
55
55
  - Input syntax (`--input`, `--input-file`, or `parameters`) and output (`render` and its `--json` flag) are exactly as in `ActionCli`.
56
- - Errors are the client's: declared errors, policy errors, `SchemaError`, and `HttpClientError`.
56
+ - Errors are the client's: declared errors (the binding's `errors` included), policy errors, `SchemaError`, and `HttpClientError`.
57
57
 
58
58
  ## Failure modes
59
59
 
60
60
  - Type error on `actionName`: the action is not in that group, or the group is not bound in this `Http`.
61
- - `HttpClient` missing at runtime: provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`) to the runtime.
62
- - Connection refused: `connection.baseUrl` is absent or wrong. It has no default.
61
+ - `HttpClient` unsatisfied (a type error at `runMain`): provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`) to the runtime.
62
+ - `HttpClientError` with `reason._tag` `InvalidUrlError`: `connection.baseUrl` is absent; it has no default. Connection refused: `baseUrl` points at the wrong host or port.
63
63
  - 401 from the server: add `transformClient` to `connection`. The command adds no headers on its own.
@@ -31,7 +31,7 @@ builder's requirements.
31
31
  import { Effect, Schema } from "effect";
32
32
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
33
33
  import { CurrentActor } from "./auth.js";
34
- import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
34
+ import { Double, GetUser, RenameUser, WhoAmI } from "./contracts.js";
35
35
  import { Users } from "./users.js";
36
36
 
37
37
  class InvalidRequest extends Schema.TaggedError<InvalidRequest>()(
@@ -58,6 +58,8 @@ const schemaError = {
58
58
  },
59
59
  };
60
60
 
61
+ export const MathActions = ActionGroup.make({ name: "math" }, Double);
62
+
61
63
  export const UserActions = ActionGroup.make(
62
64
  { name: "users", schemaError },
63
65
  GetUser,
@@ -91,10 +93,10 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
91
93
 
92
94
  ## Rules
93
95
 
94
- - `name` matches `[A-Za-z0-9_-]+`, cannot be `then`, and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
96
+ - `name` matches `[A-Za-z0-9_-]+` and cannot be `then`. Action names are unique within the group. Group and MCP tool names are checked where they are served; see [guarantees.md](guarantees.md#namespaces).
95
97
  - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them. A failure that only the surface produces belongs on the adapter instead (`ActionHttp.make`'s `errors`, `ActionMcp`'s and `ActionToolkit`'s `errors`), not here.
96
98
  - `implement` takes a complete plain record or an Effect producing one. Every action must have a handler; missing keys are compile errors. A handler is an own-property function of the record: an inherited one, such as a class instance's method, does not count. A record that still lacks one at runtime (plain JavaScript, a cast: a missing key, `undefined`, any other non-function, an inherited method) throws at `implement`; a builder's record fails the adapter layer's build. Nothing is served with a handler missing. Handlers are called without a receiver. Additional keys are not dispatched. It takes nothing else.
97
- - The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
99
+ - The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope, and once per invocation under `ActionCli`. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
98
100
  - Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
99
101
  - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
100
102
  - `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and the hook, and is not a substitute for adapter tests. There is no public handler service or Layer.
@@ -109,18 +111,18 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
109
111
  - MCP is unaffected. The native `McpServer` answers invalid arguments and unencodable results itself.
110
112
  - Policy errors extend the transport contract, not the handler contract. A handler cannot return them.
111
113
  - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
112
- - HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
114
+ - HTTP decodes input with `errors: "all"`, so an `invalid` `cause` carries every issue. Issues never retain the rejected values.
113
115
  - Groups served by one adapter may have different policies; each action answers with its own group's.
114
116
  - Input schema-error policies run before the `before` hook. Invalid input is answered by the policy without invoking the hook or handler.
115
117
  - Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
116
118
 
117
119
  ## Failure modes
118
120
 
119
- - Throws at `make`: duplicate action name, duplicate MCP tool name, invalid group name.
121
+ - Throws at `make`: `Duplicate action: <name>` or `Invalid action group name: <name>`.
120
122
  - `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
121
123
  - Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
122
124
  - `Expected 1 arguments, but got 2` at `implement`: the second options object is gone. Move `before` to the adapter that serves the group.
123
- - `Parameter 'failure' implicitly has an 'any' type` in a standalone policy constant: a `make` that reads the failure is typed only inline. Annotate the parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`), or annotate the constant as `ActionGroup.SchemaErrorPolicy<typeof Invalid, typeof Internal>`.
125
+ - `Parameter 'failure' implicitly has an 'any' type` in a standalone policy constant: a `make` that reads the failure is typed only inline. Annotate the parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/http-api`), or annotate the constant as `ActionGroup.SchemaErrorPolicy<typeof Invalid, typeof Internal>`.
124
126
  - `Missing handlers for group "<group>": <actions>` thrown at `implement`, or failing the layer build: the handler record has no own-property function for those actions. Add them to the record itself, not to a prototype.
125
127
  - A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
126
128
  - `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.
@@ -32,7 +32,7 @@ Route shape: `POST <apiPath>/<group>/<action>`. Operation ID: `<group>.<action>`
32
32
 
33
33
  ```ts
34
34
  import { Layer } from "effect";
35
- import { HttpApiScalar, HttpApiSwagger } from "effect/unstable/httpapi";
35
+ import { HttpApiScalar, HttpApiSwagger } from "effect/http-api";
36
36
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
37
37
  import { PublicActions, UserActions } from "./contracts.js";
38
38
  import { PublicApp, UserApp } from "./handlers.js";
@@ -73,9 +73,9 @@ The client is Effect's native grouped `HttpApiClient` over `Http.api`. Code that
73
73
  run Effects uses the Promise client in [ActionHttpClient.md](ActionHttpClient.md).
74
74
 
75
75
  ```ts
76
- import { HttpApiClient } from "effect/unstable/httpapi";
76
+ import { HttpApiClient } from "effect/http-api";
77
77
  import { Effect } from "effect";
78
- import { FetchHttpClient } from "effect/unstable/http";
78
+ import { FetchHttpClient } from "effect/http";
79
79
  import { Http } from "./quickstart.js";
80
80
 
81
81
  export const greeting = Effect.gen(function* () {
@@ -98,10 +98,10 @@ export const greeting = Effect.gen(function* () {
98
98
  - `ActionHttp` sets no response headers of its own. The host owns cache policy; `Authentication.middleware` marks its responses `cache-control: no-store`.
99
99
  - A hook refusal and a handler error are plain declared errors: a JSON body and a status, no other headers. Challenge headers such as `WWW-Authenticate` belong to the admission middleware that runs before the router reaches these routes (see [Authentication.md](Authentication.md)), which sets them on its own response.
100
100
  - Request-time handler services are `HttpRouter.Request.From<"Requires", R>`. Supply them with router middleware (`Authentication.middleware`, `HttpRouter.middleware`), `HttpRouter.provideRequest`, or the request context. Build-time services are ordinary layer requirements.
101
- - `Http.api` is a plain `HttpApi`. Anything Effect can do with an `HttpApi` works: `OpenApi.fromApi`, `HttpApiSwagger.layer`, `HttpApiScalar.layer`, `HttpApi.addHttpApi` to combine with other APIs, `HttpApiClient.make`.
101
+ - `Http.api` is a plain `HttpApi`. Anything Effect can do with an `HttpApi` works: `OpenApi.fromApi`, `HttpApiSwagger.layer`, `HttpApiScalar.layer`, `Http.api.addHttpApi(other)` to combine with other APIs, `HttpApiClient.make`.
102
102
  - `Http.openApi(path?)` is `OpenApi.fromApi(Http.api)` as one `GET` route, `<apiPath>/openapi.json` unless a path is given. It documents every bound group, not only the served ones. It is a plain route: middleware provided to its layer covers it, and nothing covers it otherwise.
103
103
  - Client calls always take `{ payload }`. No-input actions take `{ payload: {} }`. Pass `null` or `undefined` only when the codec accepts it. There is no flat client.
104
- - Client effects fail with the declared errors, the group's policy errors, the binding's `errors`, `SchemaError` for local codec failures, and native `HttpClientError`.
104
+ - Client effects fail with the declared errors, the group's policy errors, the binding's `errors`, `SchemaError` for local codec failures (including an undeclared input field, before sending), and native `HttpClientError`.
105
105
  - Add authentication headers with the native `transformClient` option. Use `HttpApiClient.makeWith` for custom error or service channels. Native per-call response modes are available.
106
106
  - Wire format without a policy: input failure is an empty 400, success is the encoded body, a declared error is its JSON encoding with its `httpApiStatus`, an encoding failure is an empty 400, a defect is an empty 500. Full table in [guarantees.md](guarantees.md).
107
107
  - Each handler runs in a span named `<group>.<action>`, a child of the request span, attributed with `action.group`, `action.name` and `action.access`; its log lines carry the same annotations. The hook, decoding and encoding are outside it, in the request span.
@@ -114,8 +114,8 @@ export const greeting = Effect.gen(function* () {
114
114
  - `Implementation of group "x" is not served by this adapter`: `Http.layer` received an implementation whose group was not passed to `ActionHttp.make`. Implement the exact shared group object bound by `make`, or add a genuinely new group to the binding. Matching names and schemas do not establish identity.
115
115
  - `Duplicate implementation group: <name>`: a single `Http.layer` call received more than one implementation of the same group. Pass one implementation per group.
116
116
  - Client method missing for an action: its group is not bound by `make`.
117
- - Empty 400 on a valid-looking request: input did not decode. Set a `schemaError` policy on the group to get a typed body, and check `Content-Type: application/json`.
118
- - 415: wrong or missing content type.
117
+ - Empty 400 on a valid-looking request: input did not decode, or it carries a field the action does not declare. Set a `schemaError` policy on the group to get a typed body.
118
+ - 415 `Unsupported content-type: <type>`: a content type other than `application/json`. A missing `Content-Type` is read as JSON.
119
119
  - `HttpClientError: Decode error (401 POST ...)` from a typed client: the surface answered with a status no endpoint declares. Add that error schema to `ActionHttp.make`'s `errors`.
120
120
  - A surface error decodes as the wrong type: two schemas that share a status also share a `_tag`. Give them distinct tags.
121
121
  - The hook's services appear as unsatisfied `HttpRouter.Request.From<"Requires", ...>`: the hook yields an identity tag and this `Http.layer` call has no middleware providing it. Wrap that call with the middleware's `.layer`.
@@ -29,7 +29,7 @@ whose input may be empty (no `input`, or only optional fields) may be called wit
29
29
  ## Canonical
30
30
 
31
31
  ```ts
32
- import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http";
32
+ import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
33
33
  import * as ActionHttpClient from "@gjermundgaraba/effect-actions/ActionHttpClient";
34
34
  import { Http, UserNotFound } from "./contracts.js";
35
35
 
@@ -65,7 +65,7 @@ Reacting to a response every call may meet (a proxy's 401), or a token read per
65
65
  ## Rules
66
66
 
67
67
  - A call rejects with exactly what the native client's Effect fails with. Declared errors arrive as their decoded values: the action's own (its group's `errors` included), its group's schema-error policy's, and the binding's surface `errors`. Classify them with `instanceof`, their `_tag`, or `Schema.is`.
68
- - Anything the contract does not account for rejects with Effect's own error. `HttpClientError`: the server could not be reached (`response` is `undefined`), or answered with a status no schema declares, or with a body that could not be read. `SchemaError`: the input did not encode, or the success body did not decode.
68
+ - Anything the contract does not account for rejects with Effect's own error. `HttpClientError`: the server could not be reached (`response` is `undefined`), or answered with a status no schema declares, or with a body that could not be read. `SchemaError`: the input did not encode (including a field the action does not declare; nothing is sent), or the success body did not decode.
69
69
  - The library adds no error type of its own and interprets no status. Which failures mean "signed out" or "try again" is the caller's decision.
70
70
  - Nothing is retried. A rejected write may or may not have happened; only a declared error says what the server did.
71
71
  - Every action of a bound group has a method. A group without actions has no namespace.
@@ -74,7 +74,7 @@ Reacting to a response every call may meet (a proxy's 401), or a token read per
74
74
 
75
75
  ## Failure modes
76
76
 
77
- - Rejects with `HttpClientError` whose `reason._tag` is `StatusCodeError` for a 401 or 403: the surface answered with an error the binding does not declare. Add it to `ActionHttp.make`'s `errors`, and have the middleware encode it.
77
+ - Rejects with `HttpClientError` whose `reason._tag` is `DecodeError` (`Decode error (401 POST ...)`) for a 401 or 403: the surface answered with an error the binding does not declare. Add it to `ActionHttp.make`'s `errors`, and have the middleware encode it.
78
78
  - Rejects with `HttpClientError` whose `reason._tag` is `InvalidUrlError` outside a browser: `baseUrl` is omitted, and there is no page to resolve relative routes against. Set `baseUrl`.
79
79
  - A stubbed global `fetch` is not used: `fetch` was passed as an option, which takes precedence.
80
80
  - Property does not exist on the client: the action's group is not bound in this `Http`.
package/docs/ActionMcp.md CHANGED
@@ -74,7 +74,7 @@ choose its policy for every route it covers.
74
74
 
75
75
  ```ts
76
76
  import { Effect, Layer } from "effect";
77
- import { HttpRouter } from "effect/unstable/http";
77
+ import { HttpRouter } from "effect/http";
78
78
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
79
79
  import { Actions } from "./quickstart.js";
80
80
 
@@ -143,13 +143,15 @@ Layer.launch(layer).pipe(
143
143
  ## Rules
144
144
 
145
145
  - Both transports serve MCP 2026-07-28 and no other revision; there is no `protocols` option. Over HTTP the endpoint is stateless: no initialize handshake, no session, and every request stands alone. Effect owns version checks and rejects any other revision. Stdio has no sessions, so it could serve an older host, but it refuses one deliberately, for uniformity: a client that works over one transport works over the other.
146
- - `path` has no default. `layerHttp` uses the single-endpoint Streamable HTTP transport, never the two-endpoint HTTP+SSE form, whatever revision is negotiated.
146
+ - `path` has no default. `layerHttp` uses the single-endpoint Streamable HTTP transport, never the two-endpoint HTTP+SSE form.
147
147
  - Requests reaching the native MCP handler with an `Origin` header receive **403** unless that exact origin is listed in `allowedOrigins`. Requests without `Origin` pass this check. Authentication middleware wrapping the endpoint runs first and may reject the request before native Origin validation; the allowlist does not protect authentication from untrusted-origin requests.
148
148
  - `allowedOrigins` is an Origin allowlist, not CORS configuration. Cross-origin browser clients also need outer CORS middleware or a proxy to handle preflight and add response headers. Without it, an allowed-origin `OPTIONS` request receives **405** and even a successful `POST` has no `Access-Control-Allow-Origin`. Keep preflight outside authentication and apply CORS headers to refusals too.
149
149
  - An endpoint is one route. Middleware provided to `layerHttp` covers all of its tools. To serve tools under different middleware, mount them on different paths with separate `layerHttp` calls.
150
150
  - Only MCP-enabled actions become tools, under `mcp.name` with the resolved hints. A group with no tools is not built. The layer's types drop an action's requirements only when its type is certainly hidden (a literal `mcp: false`, see [Action.md](Action.md)); an `mcp` that may be `false` at runtime keeps them.
151
151
  - Every MCP-enabled action must have object-root input; the native server refuses anything else when the layer is built. Omit `input` for a tool with no arguments.
152
- - Success is `structuredContent: { value: <encoded success> }`. A declared error is an `isError` result whose text content is the error's JSON encoding, the same bytes HTTP sends as the body, and no `structuredContent`.
152
+ - Success is `structuredContent: <encoded success>`, of any JSON type, and one text block holding the same JSON, as the specification recommends. The tool's `outputSchema` describes the encoded success. A declared error is an `isError` result whose text content is the error's JSON encoding, the same bytes HTTP sends as the body, and no `structuredContent`.
153
+ - With a text field (`mcp.text`), a success is `content: [<field>, <JSON of the rest>]` and `structuredContent: <the rest>`. The field is sent once, verbatim, and is left out of `structuredContent` and of the listed `outputSchema`. Use it for a body the model should read raw, such as a page of Markdown: in structured content it would be JSON-escaped, and sent twice. The field must be a top-level property of the success's JSON Schema. A success without the field as a string, such as one that omits an optional field, is sent whole.
154
+ - Besides `content`, `structuredContent` and `isError: false`, the server adds `_meta["io.modelcontextprotocol/serverInfo"]` and `resultType: "complete"` to every result. `serverInfo` is the layer's `name`, `version`, `description`, `websiteUrl` and `icons`, as given, so a result's encoded size is the size of its own fields plus a fixed overhead per endpoint, the same over HTTP and stdio.
153
155
  - Invalid arguments and unencodable results are answered by the native `McpServer`: an `isError` result with a message for the model. Group `schemaError` policies do not apply.
154
156
  - Defects and encoding failures produce the generic `isError` text `Tool execution failed due to an internal server error.`; the cause is logged, not sent.
155
157
  - Tool discovery is not filtered by actor. Every caller sees every tool of the endpoint. Authorization happens in `before`.
@@ -163,7 +165,7 @@ Layer.launch(layer).pipe(
163
165
  ## Failure modes
164
166
 
165
167
  - Layer build dies while registering tools, with a defect whose `SchemaError` message says `Expected "object"` or `Missing key`: an MCP-enabled action has scalar, array, or empty-struct input, which the native server refuses. It is not in the layer's error channel, so it cannot be caught by tag. Wrap the input in a struct with at least one field, omit `input` for no arguments, or set `mcp: false`.
166
- - `Duplicate MCP tool: <name>` thrown at the `layerHttp` or `layerStdio` call: two apps on one endpoint expose the same tool name. Rename with `mcp.name` or split the endpoint.
168
+ - `Duplicate MCP tool: <name>` thrown at the `layerHttp` or `layerStdio` call: two actions on one endpoint expose the same tool name. Rename with `mcp.name` or split the endpoint.
167
169
  - Type error listing `HttpRouter.Request.From<"Requires", ...>`: a handler yields a request service and the endpoint has no middleware providing it. Provide it with `Layer.provide(middleware.layer)` on that `layerHttp`.
168
170
  - Public tool requires a token: it shares an endpoint with protected tools. Give it its own path.
169
171
  - Client reports a broken transport from a stdio subprocess: something printed to stdout. Set `Logger.LogToStderr` and remove `console.log`.
@@ -172,4 +174,5 @@ Layer.launch(layer).pipe(
172
174
  - A disallowed Origin receives 401 instead: wrapping authentication rejected it before the native Origin check. Put any required pre-authentication Host/Origin policy in outer host middleware.
173
175
  - Browser calls fail despite an allowed Origin: configure CORS outside authentication and the MCP handler (see the browser example above). The native allowlist alone neither handles preflight nor adds CORS response headers.
174
176
  - `Object literal may only specify known properties, and 'protocols'`: the revision is fixed. Delete the option.
177
+ - Layer build dies with `MCP tool '<name>' cannot send '<field>' as text: it is not a top-level property of its success`: the success's JSON Schema has no such top-level property, as for a union of structs. Make the success one struct.
175
178
  - A refusal arrives as a generic internal-error result: the hook failed with an error this transport does not declare, which is a defect. Add its schema to `errors`.
@@ -52,7 +52,7 @@ With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText
52
52
  ## Rules
53
53
 
54
54
  - Selects MCP-enabled actions only, named by `mcp.name`, with resolved hints. Groups with no selected tools are not built. The toolkit's type follows `ActionMcp`'s rule: only an action whose `mcp` type is exactly `false` has no tool; one whose `mcp` may be `false` at runtime is typed as a tool with its handler's requirements. `make(apps, options?)` accepts a readonly implementation collection; options may be omitted.
55
- - Successes are the action's native values. There is no `{ value }` wrapper. Declared failures are returned as native tool results (`failureMode: "return"`), not raised.
55
+ - Successes are the action's native values, whole: `mcp.text` does not apply. Declared failures are returned as native tool results (`failureMode: "return"`), not raised.
56
56
  - `tools.handle(name, encodedInput)` takes encoded arguments and returns an Effect producing a result stream. The handler starts while that stream is constructed, so request services must be provided around the entire `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not only around the stream.
57
57
  - `binding.layer` acquires each implementation once, in the layer's scope. Build-time requirements belong to the whole selected implementation.
58
58
  - Each tool carries only its own handler's request requirements, plus the hook's, not those of sibling or disabled tools.
@@ -65,7 +65,7 @@ With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText
65
65
 
66
66
  - Tool missing from the toolkit: the action has `mcp: false`. The Toolkit selects by MCP enablement.
67
67
  - Type error on `before`'s error channel: it fails with an error this binding does not declare. Add its schema to `errors`.
68
- - `Duplicate MCP tool: <name>` thrown at `make`: two apps expose the same tool name. Rename with `mcp.name`.
69
- - `Service not found` for a request tag at call time: it was provided only to the stream, or only to the layer. Provide it around the whole call effect.
68
+ - `Duplicate MCP tool: <name>` thrown at `make`: two selected actions expose the same tool name. Rename with `mcp.name`.
69
+ - `Service not found` for a request tag at call time: it was provided only to the stream. Provide it around the whole call effect. Providing it to `binding.layer` does not fail: the startup value is captured and served to every call (see [guarantees.md](guarantees.md)).
70
+ - A returned failure that is an `AiError` with reason `ToolParameterValidationError`, not a declared error: the arguments did not decode. The hook and the handler did not run.
70
71
  - Type error on `binding.layer` requirements: a build-time service is missing. Provide its Layer before `binding.layer`.
71
- - Result is `{ value: ... }` when a plain value was expected: you are reading an MCP response, not a Toolkit result. The Toolkit never wraps.
@@ -29,7 +29,7 @@ to routes requiring that identity.
29
29
 
30
30
  ```ts
31
31
  import { Context, Effect, Layer, Option, Schema } from "effect";
32
- import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
32
+ import { HttpServerRequest, HttpServerResponse } from "effect/http";
33
33
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
34
34
 
35
35
  interface Actor {
@@ -84,7 +84,7 @@ export const routes = Layer.mergeAll(
84
84
  - Downstream action errors are handled by their transport. They are never serialized as authentication failures.
85
85
  - Use distinct tags for startup capabilities and request identities. Never provide `CurrentActor` or any identity or tenant tag in a startup layer or root context. Native context capture can let a startup value shadow the request value or satisfy a missing one, and the adapters add no isolation boundary. Types track that the tag is required, not where its value came from.
86
86
  - `protectedResource` publishes what it is given. The deployment must ensure `resource` and `authorizationServers` are valid OAuth URLs (HTTPS, or loopback HTTP in development).
87
- - Discovery is served at `/.well-known/oauth-protected-resource` followed by the resource's path and query, for `GET` and `HEAD`, matching that literal path and query. Other requests fall through to the host router. Mount `discovery.layer` outside the authenticated layers. Caching policy is the host's.
87
+ - Discovery is served at `/.well-known/oauth-protected-resource` followed by the resource's path and query, for `GET` and `HEAD`, matching that literal path and query. Other requests fall through to the host router. `discovery.layer` is global router middleware: it answers before any route middleware, so authentication never covers it. Caching policy is the host's.
88
88
  - `challenge()` quotes and escapes parameter values; it never rejects them. Name only the scopes needed for the request in `scope`; `scopesSupported` advertises the full set in metadata.
89
89
  - Tool discovery is never filtered by actor. Authorization belongs in each surface's `before` hook, which runs with the action contract in hand; write the rule against `action.access` rather than repeating a check in each handler.
90
90
  - A response this middleware renders is not part of any endpoint's contract. Declare its schema in `ActionHttp.make`'s `errors` so typed clients decode the 401 instead of reporting a decode error ([ActionHttp.md](ActionHttp.md)).
@@ -94,6 +94,5 @@ export const routes = Layer.mergeAll(
94
94
  - Handler sees a stale or wrong actor: an identity tag was provided at startup. Remove it from every startup layer; provide it only through the middleware.
95
95
  - Type error `HttpRouter.Request.From<"Requires", CurrentActor>` unsatisfied: the layer serving that implementation was not wrapped with `authentication.layer`.
96
96
  - Discovery returns 404: the request path does not match `resource`'s path and query exactly, or `discovery.layer` is not merged into the served layer.
97
- - Discovery requires a token: `discovery.layer` was placed under the authentication middleware. Mount it separately.
98
97
  - 401 response lacks `WWW-Authenticate`: the host's failure response did not set it. Use `discovery.challenge(...)` in its headers.
99
98
  - A typed client reports `Decode error (401 ...)` instead of the failure schema: the response this middleware renders is not declared. Add it to `ActionHttp.make`'s `errors`.
package/docs/CONTEXT.md CHANGED
@@ -16,13 +16,14 @@ use these words with these meanings.
16
16
  - **Build-time requirement**: a service yielded in the builder. Resolved when an adapter layer is built.
17
17
  - **Request-time requirement**: a service yielded inside a handler. Supplied per invocation: by router middleware for HTTP-hosted adapters, by the host for Toolkit, CLI, and stdio.
18
18
  - **Adapter** (also **projection**): a module that maps implementations or contracts onto one surface: `ActionHttp`, `ActionMcp`, `ActionToolkit`, `ActionCli`, `ActionCliClient`, `ActionCatalog`. Each adapter reads only what it serves and validates only its own namespace.
19
- - **Binding**: the value an adapter returns before anything runs. `ActionHttp.make` returns the HTTP binding (`groups`, `api`, `layer`); `ActionToolkit.make` returns the Toolkit binding (`toolkit`, `layer`).
19
+ - **Binding**: the value an adapter returns before anything runs. `ActionHttp.make` returns the HTTP binding (`groups`, `api`, `layer`, `openApi`); `ActionToolkit.make` returns the Toolkit binding (`toolkit`, `layer`).
20
20
  - **Contract map**: the record `ActionGroup.contracts` returns, keyed `<group>.<action>`, with each action's exact type. The shared identity of an action across routes, tools and commands.
21
21
  - **Declared error**: an error schema listed on an action or inherited from its group. Handlers may fail with it. HTTP encodes it with its `httpApiStatus`; MCP returns encoded error text in an `isError` result. Toolkit returns a native failure result, and CLI fails its command Effect without serializing the error.
22
22
  - **Schema-error policy**: a group option answering Effect's `HttpApiSchemaError` with a declared policy error and its status: `invalid` when the request did not decode, `internal` when the handler's result did not encode. The library decides which applies. HTTP only.
23
23
  - **Policy error**: an error a schema-error policy may answer with. Part of the transport contract, never returnable by a handler.
24
24
  - **Surface error**: an error declared on an adapter binding rather than on an action. Produced by middleware around the surface or by its pre-handler hook, never by a handler; declared so typed callers decode it.
25
25
  - **Tool**: the MCP or Toolkit projection of an MCP-enabled action, named by `mcp.name` and carrying its hints.
26
+ - **Text field**: the string field of an action's encoded success that `mcp.text` names. When a success has it, MCP sends it once, raw, as a text block, and leaves it out of the structured content. Every other surface serves the whole success.
26
27
  - **Endpoint**: one `ActionMcp.layerHttp` mount. One route, one middleware set, one tool registry.
27
28
  - **Document**: the OpenAPI output of `Http.api`, produced by Effect (`OpenApi.fromApi`) and served by `Http.openApi`.
28
29
  - **Promise client**: `ActionHttpClient.promise(Http)`, the native `HttpApiClient` as one Promise method per action of the binding, for code that does not run Effects. It rejects with what the native client fails with.
@@ -31,10 +32,10 @@ use these words with these meanings.
31
32
 
32
33
  ## Seams
33
34
 
34
- - `src/Action.ts` and `src/ActionGroup.ts` are contracts. They import nothing transport-specific and know nothing about HTTP, MCP, or the CLI.
35
+ - `src/Action.ts` and `src/ActionGroup.ts` are contracts. They import no transport runtime. They carry only transport metadata (`mcp`, `httpApiStatus`, the HTTP schema-error policy).
35
36
  - `src/internal/actions.ts` and `src/internal/implementation.ts` hold the shared shapes adapters consume: the contract half of a group, the schema-error policy, and the nominal `Implementation` class. Everything under `src/internal` is not public API; the public modules re-export the types consumers need.
36
37
  - Each adapter owns its own mapping from contracts to its surface and never reaches into another adapter. HTTP does not know about tool names; MCP does not know about routes.
37
- - Adapters build on Effect's own servers and clients: `HttpApi` for HTTP, `McpServer` for MCP, `Toolkit` for the AI toolkit, `Command` for the CLI. The library adds no protocol runtime and defines no application error types.
38
+ - Adapters build on Effect's own servers and clients: `HttpApi` for HTTP, `McpServer` for MCP, `Toolkit` for the AI toolkit, `Command` for the CLI. The library adds no protocol runtime.
38
39
  - Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application. Authorization is the application's pre-handler hook, bound to each surface, which the library runs but never writes.
39
40
  - `Testing` depends only on `effect`. `TestingClient` is the single module that loads the optional MCP client peer.
40
41
  - Docs: `README.md` is the pitch for humans. `docs/` is the reference for agents, one card per module. `skills/effect-actions` is a copy of `docs/`, with this vocabulary included. `CONTRIBUTING.md` and `AGENTS.md` are for maintainers.
package/docs/README.md CHANGED
@@ -1,15 +1,16 @@
1
1
  # effect-actions reference
2
2
 
3
- Reference for `@gjermundgaraba/effect-actions`, written for coding agents. Every page is a
3
+ Reference for `@gjermundgaraba/effect-actions`, written for coding agents. Every module page is a
4
4
  card with the same sections: **API** (inventory and options), **Canonical** (the one right way to write
5
5
  it), **Rules** (must and never), **Failure modes** (what you see when it is wrong, and the fix).
6
+ [setup.md](setup.md), [guarantees.md](guarantees.md), and [CONTEXT.md](CONTEXT.md) are cross-cutting pages with their own structure.
6
7
  Read the card for a module before writing code that uses it. Exported TypeScript declarations are the exact signature reference. Vocabulary is defined in
7
8
  [CONTEXT.md](CONTEXT.md); the pages use those terms exactly.
8
9
 
9
10
  Package facts that apply everywhere:
10
11
 
11
12
  - Every module is a subpath import: `import * as Action from "@gjermundgaraba/effect-actions/Action"`. There is no package root.
12
- - The `effect` peer accepts any 4.0 release candidate from `4.0.0-rc.116` on (`>=4.0.0-rc.116 <4.0.0`). The package is built and tested against `4.0.0-rc.117`.
13
+ - The `effect` peer accepts any 4.0 release candidate from `4.0.0-rc.118` on (`>=4.0.0-rc.118 <4.0.0`). The package is built and tested against `4.0.0-rc.118`.
13
14
  - Contracts are pure values. Nothing runs, binds, or acquires services until an adapter layer is built.
14
15
 
15
16
  ## Pages
@@ -83,9 +84,8 @@ Repository directory `examples/` ([on GitHub](https://github.com/gjermundgaraba/
83
84
 
84
85
  - `quickstart.ts`, `quickstart-client.ts`: the minimal program and its typed client.
85
86
  - `promise-client.ts`: the Promise client, with a declared error and an unreachable server.
86
- - `contracts.ts`, `handlers.ts`, `app.ts`, `server.ts`: an authenticated application with three groups, per-group middleware, two MCP endpoints, OpenAPI and Swagger.
87
+ - `contracts.ts`, `auth.ts`, `users.ts`, `handlers.ts`, `app.ts`, `server.ts`, `client.ts`: an authenticated application with three groups, per-group middleware, two MCP endpoints, OpenAPI and Swagger.
87
88
  - `toolkit.ts`, `catalog.ts`, `cli.ts`, `cli-client.ts`, `mcp-stdio.ts`: one file per other projection.
88
-
89
89
  - `mcp-browser.ts`: a stateless MCP endpoint with a separate browser CORS policy.
90
90
  - `toolkit-authorized.ts`: native Toolkit invocation with authorization and correctly scoped identity.
91
91
  - `testing.ts`: in-memory HTTP and MCP calls with cleanup.
package/docs/Testing.md CHANGED
@@ -16,7 +16,7 @@ Import `@gjermundgaraba/effect-actions/Testing`; official-client helpers are in
16
16
  | `TestingClient.withMcpClient(options, run)` | Connect the official client, run an asynchronous callback, then close it. |
17
17
 
18
18
  `httpClient` takes native client options; `baseUrl` defaults to `http://localhost`.
19
- Its exported `Handler` type is a web request to response Promise.
19
+ Its exported `Handler` type is a web request to response Promise, not an action handler.
20
20
 
21
21
  | MCP option | Meaning |
22
22
  | ------------------------------------- | ----------------------------------------------------------- |
@@ -34,7 +34,7 @@ Exported option/value types: `McpRequestOptions`, `McpRequestParams`, `McpReques
34
34
 
35
35
  ```ts
36
36
  import { Effect, Layer } from "effect";
37
- import { HttpRouter, HttpServer } from "effect/unstable/http";
37
+ import { HttpRouter, HttpServer } from "effect/http";
38
38
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
39
39
  import * as TestingClient from "@gjermundgaraba/effect-actions/TestingClient";
40
40
  import { Http, routes } from "./quickstart.js";
@@ -78,17 +78,20 @@ try {
78
78
  - `httpClient` is the same `HttpApiClient` consumers use, with its transport replaced by `handler`. Same `{ payload }` calls, same error channel.
79
79
  - `mcpRequest` pins protocol version 2026-07-28 in both the header and `_meta`. Caller `params._meta` fields override the defaulted client capabilities and info; the merge is shallow. Application metadata is preserved.
80
80
  - `params` accepts what `JSON.stringify` accepts, so tests can send malformed arguments on purpose. `undefined` fields are dropped.
81
- - `mcpCall` sends one `tools/call` through `mcpRequest` and resolves with `{ isError: false, value }`, the success taken from `structuredContent.value`, or `{ isError: true, error }`, the result's error text parsed as JSON. A declared error or a hook refusal is its JSON encoding, the same body HTTP sends; the native server's own messages (invalid arguments, defects) are not JSON and stay text. It reads a JSON or an event-stream response.
81
+ - `mcpCall` sends one `tools/call` through `mcpRequest` and resolves with `{ isError: false, value }`, the success taken from `structuredContent`, with `text` holding the text field when a success with `mcp.text` sent one, or `{ isError: true, error }`, the result's error text parsed as JSON. A declared error or a hook refusal is its JSON encoding, the same body HTTP sends; the native server's own messages (invalid arguments, defects) are not JSON and stay text. It reads a JSON or an event-stream response.
82
+ - To assert on a result's exact bytes, such as a size bound, send `mcpRequest` through the handler and parse the reply: `JSON.stringify` of the parsed message reproduces the message the server wrote, so `JSON.stringify(message.result)` is the result as encoded.
82
83
  - `mcpCall` throws when the answer is not a tool result: a status other than 200 (an authentication refusal), or a JSON-RPC error (an unknown tool). Use `mcpRequest` to assert on those responses.
83
84
  - `withMcpClient` pins the official client to MCP 2026-07-28, the only revision `ActionMcp` serves, and closes the transport in a `finally` block.
84
85
  - Add `Authorization` through `headers` in the MCP helpers, or through `transformClient` in `httpClient` options.
85
- - Direct handler tests can use `app.build` under `Effect.scoped`, but they bypass decoding, encoding, and middleware. Keep at least one adapter-level test per transport.
86
+ - Direct handler tests can use `app.build` under `Effect.scoped`, but they bypass decoding, encoding, middleware, and the `before` hook. Keep at least one adapter-level test per transport.
86
87
 
87
88
  ## Failure modes
88
89
 
89
90
  - `@modelcontextprotocol/client` not found: only `TestingClient` needs it. Install the peer in devDependencies, or use `Testing.mcpRequest` instead.
90
- - `withMcpClient` rejects its options with `versionNegotiation`: the revision is fixed at 2026-07-28. Delete the option.
91
+ - Type error on `versionNegotiation` in `withMcpClient` options: the revision is fixed at 2026-07-28. Delete the option.
91
92
  - `MCP tools/call "<name>" answered 401`: the call reached authentication without a credential. Pass `headers: { authorization: "Bearer ..." }`.
93
+ - `MCP tools/call "<name>" failed with <code>: <message>`: a JSON-RPC error, such as an unknown tool. Assert on it with `mcpRequest`.
94
+ - `MCP tools/call "<name>" had no reply` or `returned no structured content`: the response was not a tool result. Inspect it with `mcpRequest`.
92
95
  - Handler leaks between tests: `web.dispose()` was not called. Register it with the test runner's cleanup hook.
93
96
  - 404 from `httpClient`: the requested route was not mounted, or the path/base URL is wrong. Include `Http.layer([app])` in the served routes.
94
97
  - Missing platform-service requirements when constructing the web handler: provide `HttpServer.layerServices` to the routes before `toWebHandler`.
@@ -24,7 +24,7 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
24
24
  | Case | HTTP | MCP |
25
25
  | --------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
26
26
  | Input | decoded with the schema's JSON codec; failure is an empty **400** | decoded with the schema's JSON codec; failure is `InvalidParams`, presented as an `isError` result |
27
- | Success | the success codec's encoding as the body: `{"id":"1","name":"Ada"}`, `42` | `structuredContent: { value: <encoded> }` |
27
+ | Success | the success codec's encoding as the body: `{"id":"1","name":"Ada"}`, `42` | `structuredContent: <encoded>`, and its JSON as text content; a text field is sent as text instead |
28
28
  | Declared error | encoded by its schema with its `httpApiStatus` (unannotated: 500): `{"_tag":"UserNotFound","id":"x"}` | `isError: true`; text content is the same JSON; no `structuredContent` |
29
29
  | Pre-handler hook failure | the same, using the hook's error schema, after input decoding; the handler never runs | the same `isError` result, after arguments are decoded; the handler never runs |
30
30
  | Invalid output encoding | empty **400** (`HttpApiSchemaError`) | `isError: true`, text `Tool execution failed due to an internal server error.`; cause logged, not sent |
@@ -32,15 +32,16 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
32
32
  | Unknown path or method | 404 | n/a |
33
33
  | Invalid JSON / content type | 400 / 415 | n/a |
34
34
 
35
+ - `mcp.text` changes only the MCP success result. HTTP, OpenAPI, typed clients, the Toolkit, the CLI and its client, and the catalog serve and describe the whole success. A text field's other outcomes are answered as in this table.
35
36
  - A group `schemaError` policy replaces the empty 400s on HTTP with a declared error and status: its `invalid` error for input that does not decode, its `internal` error for output that does not encode. It never affects MCP. Details in [ActionGroup.md](ActionGroup.md).
36
37
  - `ActionHttp` sets no headers of its own. The host owns cache and challenge headers; `Authentication.middleware` marks its responses `cache-control: no-store`. Hook refusals and handler failures are ordinary declared error responses.
37
38
  - Routes are `POST <apiPath>/<group>/<action>`. Operation IDs are `<group>.<action>`. Effect generates OpenAPI component names and references.
38
39
  - `ActionHttp.make`'s `errors` are declared on every endpoint so clients decode the surface's own failures (401, 403, 429, 503). They are produced by middleware or by the binding's hook, never by a handler. `ActionMcp` and `ActionToolkit` declare their own `errors` the same way, joined into every tool's failure schema. Two errors may share an HTTP status; their `_tag`s must differ, because the client decodes a status by trying the schemas declared for it.
39
- - 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.
40
+ - Input is closed. Both transports publish `additionalProperties: false` for input (so does the catalog) and refuse undeclared fields: over HTTP an extra field is an input failure (an empty 400, or the group's `schemaError` `invalid` answer); an MCP tool (`Tool.Strict`) returns an invalid-arguments result. Typed HTTP clients encode input from `Http.api` and fail with `SchemaError` on an undeclared field, before sending. The native Toolkit, `ActionCli` and `ActionCliClient` decode input locally with default options, which drop an undeclared field instead of refusing it.
40
41
 
41
42
  ## Namespaces
42
43
 
43
- - Group names are unique within one `ActionHttp.make`. Action names are unique within a group. MCP tool names are unique within a group and within each Toolkit or MCP projection that serves it.
44
+ - Group names are unique within one `ActionHttp.make`. Action names are unique within a group. MCP tool names are unique within each Toolkit or MCP projection that serves them; a group does not check them.
44
45
  - Adapters validate only the namespace they serve. HTTP does not check tool names; MCP does not check route names.
45
46
  - Each adapter builds only what it serves. HTTP serves every action of a group it binds, so `Http.layer` builds every implementation it is given (except a group without actions); a group with no tools is not built by MCP or Toolkit.
46
47
 
package/docs/setup.md CHANGED
@@ -5,12 +5,12 @@ Install, version pins, entry points, and the boundaries of the package.
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.117
8
+ pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.118
9
9
  ```
10
10
 
11
11
  | Need | Add |
12
12
  | ------------------------------ | ----------------------------------------------------- |
13
- | Node HTTP server or stdio host | `@effect/platform-node@4.0.0-rc.117` |
13
+ | Node HTTP server or stdio host | `@effect/platform-node@4.0.0-rc.118` |
14
14
  | `TestingClient.withMcpClient` | `@modelcontextprotocol/client@^2.0.0` (optional peer) |
15
15
 
16
16
  Node `^22.12.0 || ^24.0.0 || >=26.0.0`. ESM only.
@@ -35,12 +35,12 @@ import * as TestingClient from "@gjermundgaraba/effect-actions/TestingClient";
35
35
  ```
36
36
 
37
37
  Every module imports from the `effect` package only, with two exceptions worth knowing when
38
- bundling: `ActionMcp` and `ActionToolkit` are the only modules that import `effect/unstable/ai`,
38
+ bundling: `ActionMcp` and `ActionToolkit` are the only modules that import `effect/ai`,
39
39
  and `TestingClient` is the only module that imports the optional `@modelcontextprotocol/client`
40
40
  peer. Because there is no package root, importing a contract module never pulls in an MCP
41
41
  server or the peer.
42
42
 
43
- Companion Effect modules you will import alongside: `effect/unstable/httpapi` (`HttpApiClient`, `OpenApi`, `HttpApiSwagger`, `HttpApiScalar`), `effect/unstable/http` (`HttpRouter`, `HttpServerResponse`, `FetchHttpClient`, `HttpClientError`), `effect/unstable/cli` (`Command`, `Flag`, `Argument`).
43
+ Companion Effect modules you will import alongside: `effect/http-api` (`HttpApiClient`, `OpenApi`, `HttpApiSwagger`, `HttpApiScalar`), `effect/http` (`HttpRouter`, `HttpServerResponse`, `FetchHttpClient`, `HttpClientError`), `effect/cli` (`Command`, `Flag`, `Argument`).
44
44
 
45
45
  ## Rules
46
46
 
@@ -60,5 +60,6 @@ What the package does, and nothing else:
60
60
  ## Failure modes
61
61
 
62
62
  - `Cannot find module '@gjermundgaraba/effect-actions'`: there is no root export. Import a subpath.
63
- - Type errors inside `effect/unstable/*` after install: `effect` version drift. Install one `effect` release candidate within the peer range for every package.
63
+ - Type errors inside `effect/*` modules after install: `effect` version drift. Install one `effect` release candidate within the peer range for every package.
64
+ - `Cannot find module 'effect/http'` (or `effect/http-api`, `effect/cli`, `effect/ai`): `effect` is older than `4.0.0-rc.118`, which moved these modules out of `effect/unstable/*`. Install a release candidate within the peer range, and import from the new paths.
64
65
  - `@modelcontextprotocol/client` resolution errors in a server build: something imported `TestingClient`. Only tests should.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, native Toolkits, catalogs, and CLIs.",
5
5
  "homepage": "https://github.com/gjermundgaraba/effect-actions#readme",
6
6
  "bugs": {
@@ -35,18 +35,18 @@
35
35
  "access": "public"
36
36
  },
37
37
  "devDependencies": {
38
- "@effect/platform-node": "4.0.0-rc.117",
38
+ "@effect/platform-node": "4.0.0-rc.118",
39
39
  "@modelcontextprotocol/client": "2.0.0",
40
40
  "@types/node": "26.6.2",
41
- "effect": "4.0.0-rc.117",
41
+ "effect": "4.0.0-rc.118",
42
42
  "tsx": "4.23.15",
43
43
  "typescript": "7.0.2",
44
- "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
45
- "vite-plus": "0.3.3"
44
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
45
+ "vite-plus": "1.0.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@modelcontextprotocol/client": "^2.0.0",
49
- "effect": ">=4.0.0-rc.116 <4.0.0"
49
+ "effect": ">=4.0.0-rc.118 <4.0.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@modelcontextprotocol/client": {
@@ -58,6 +58,11 @@
58
58
  "name": "pnpm",
59
59
  "version": "12.5.1",
60
60
  "onFail": "download"
61
+ },
62
+ "runtime": {
63
+ "name": "node",
64
+ "version": "^22.18.0 || ^24.11.0 || >=26.0.0",
65
+ "onFail": "error"
61
66
  }
62
67
  },
63
68
  "engines": {