@gjermundgaraba/effect-actions 0.14.0 → 0.15.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 (55) hide show
  1. package/dist/Action.d.mts +1 -1
  2. package/dist/ActionCli.d.mts +4 -6
  3. package/dist/ActionCli.mjs +11 -9
  4. package/dist/ActionHttp.mjs +18 -30
  5. package/dist/ActionMcp.d.mts +16 -7
  6. package/dist/ActionMcp.mjs +76 -89
  7. package/dist/ActionRpc.mjs +17 -29
  8. package/dist/ActionToolkit.mjs +26 -11
  9. package/dist/Authentication.mjs +1 -2
  10. package/dist/Testing.d.mts +6 -73
  11. package/dist/Testing.mjs +3 -102
  12. package/dist/authentication/provider.mjs +1 -6
  13. package/dist/authentication/refusal.mjs +14 -4
  14. package/dist/cli/commands.mjs +8 -8
  15. package/dist/contract/call.d.mts +2 -1
  16. package/dist/contract/call.mjs +14 -1
  17. package/dist/contract/errors.d.mts +2 -3
  18. package/dist/contract/errors.mjs +6 -12
  19. package/dist/contract/implementation.mjs +33 -5
  20. package/dist/contract/rules.d.mts +7 -0
  21. package/dist/contract/rules.mjs +50 -3
  22. package/dist/http/client.mjs +4 -7
  23. package/dist/mcp/client.d.mts +87 -0
  24. package/dist/mcp/client.mjs +122 -0
  25. package/dist/mcp/protocol.mjs +25 -37
  26. package/docs/Action.md +6 -5
  27. package/docs/ActionCli.md +4 -3
  28. package/docs/ActionMcp-failures.md +6 -2
  29. package/docs/ActionMcp.md +30 -33
  30. package/docs/ActionToolkit-failures.md +2 -1
  31. package/docs/ActionToolkit.md +2 -2
  32. package/docs/Authentication-failures.md +2 -2
  33. package/docs/Authentication.md +10 -5
  34. package/docs/CONTEXT.md +1 -1
  35. package/docs/Testing-failures.md +3 -3
  36. package/docs/Testing.md +16 -24
  37. package/docs/guarantees.md +15 -14
  38. package/examples/kitchen-sink/README.md +26 -26
  39. package/examples/kitchen-sink/test/attachments/attachments.test.ts +3 -2
  40. package/examples/kitchen-sink/test/identity/identity.test.ts +13 -6
  41. package/examples/kitchen-sink/test/routes.test.ts +4 -3
  42. package/examples/live-rpc/README.md +7 -2
  43. package/examples/mcp-tools/test/routes.test.ts +7 -6
  44. package/examples/notes-service/README.md +13 -3
  45. package/examples/notes-service/src/notes/Notes.ts +4 -2
  46. package/examples/notes-service/src/routes.ts +24 -14
  47. package/examples/notes-service/src/worker.ts +20 -0
  48. package/examples/notes-service/test/commands.test.ts +2 -1
  49. package/examples/notes-service/test/routes.test.ts +6 -3
  50. package/examples/notes-service/test/worker.test.ts +34 -0
  51. package/examples/quickstart/test/greeting/greeting.test.ts +2 -1
  52. package/package.json +1 -1
  53. package/dist/http/declared.mjs +0 -20
  54. package/dist/mcp/protocol.d.mts +0 -18
  55. package/dist/toolkit/tools.mjs +0 -29
@@ -0,0 +1,87 @@
1
+ import { BuiltIn } from "../contract/errors.mjs";
2
+ import { Call } from "../contract/call.mjs";
3
+ import { Action_d_exports, Any } from "../Action.mjs";
4
+ import { Any as Any$1, VerifierError } from "../authentication/provider.mjs";
5
+ import { Effect, Schema } from "effect";
6
+ import { Headers, HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
7
+ //#region src/mcp/client.d.ts
8
+ /**
9
+ * An MCP request's parameters, as `request` takes them: JSON, with any `_meta`, leaving
10
+ * out an `undefined` one, as a caller may. A helper passing on whole params types them as this,
11
+ * and a tool's `arguments` as `Schema.Json`, never `unknown`.
12
+ */
13
+ export interface Params {
14
+ readonly _meta?: {
15
+ readonly [key: string]: Schema.Json;
16
+ };
17
+ readonly [key: string]: Schema.Json | undefined;
18
+ }
19
+ /** Where `client` sends, through what client, and what the endpoint's authentication declares. */
20
+ export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined> {
21
+ /**
22
+ * The endpoint, resolved by the `HttpClient`: relative under `Testing.layer`. Defaults to `/mcp`,
23
+ * the default `layerHttp` path.
24
+ */
25
+ readonly url?: string;
26
+ /**
27
+ * Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token is
28
+ * `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
29
+ */
30
+ readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
31
+ /**
32
+ * The endpoint's descriptor, as `layerHttp` takes it: an error its verifier declares, the
33
+ * endpoint's HTTP answer for a protected tool, is then that error's decoded value, as over
34
+ * `ActionHttp.client`.
35
+ */
36
+ readonly authentication?: D;
37
+ }
38
+ /** Where a `request` goes, and with what headers. */
39
+ export interface RequestOptions {
40
+ /**
41
+ * The endpoint, resolved by the `HttpClient` that sends the request: relative under `Testing.layer`.
42
+ * Defaults to `/mcp`, as `client`'s does; converting to a web `Request` needs an absolute
43
+ * one.
44
+ */
45
+ readonly url?: string;
46
+ readonly headers?: Headers.Input;
47
+ }
48
+ declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<"McpCallError", {
49
+ readonly message: Schema.String;
50
+ }>, import("effect/Cause").YieldableError>;
51
+ /**
52
+ * An answer a client method cannot read as the action's success or a declared error, such as
53
+ * the native server's message for invalid arguments or an unknown tool, or a result without
54
+ * its structured content. Its message holds the answer. Structured content that does not
55
+ * decode as the success is a `SchemaError` instead, and a request that fails an
56
+ * `HttpClientError`.
57
+ */
58
+ export declare class McpCallError extends McpCallError_base {}
59
+ type CallError<A extends Any, D> = A["error"][number]["Type"] | VerifierError<A, D>["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
60
+ /** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
61
+ export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D>>>; };
62
+ /**
63
+ * A client of an MCP endpoint served by `layerHttp`, one method per action calling
64
+ * its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
65
+ * encoded with the action's schema, and the success decoded from its structured content, each
66
+ * media field from the blocks whose `_meta` names it, and a success that is media, or whose
67
+ * every field is, from its blocks alone. A declared error the tool returns, the action's own
68
+ * or a refusal, is its decoded value, and so is a refusal the endpoint's authentication
69
+ * answers with, or an error its verifier declares, given the `authentication`: a test telling a 401 or 403 from a tool result asserts the status of a request
70
+ * `request` builds. The argument may be omitted when `{}` is a valid input. Requires the
71
+ * native `HttpClient`, such as the one `Testing.layer` provides.
72
+ */
73
+ export declare function client<const Actions extends ReadonlyArray<Any>, D extends Any$1 | undefined = undefined>(actions: Actions, options?: ClientOptions<D>): Effect.Effect<Client<Actions, D>, never, HttpClient.HttpClient>;
74
+ /**
75
+ * One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
76
+ * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
77
+ * `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
78
+ * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
79
+ * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
80
+ * version is always the request's own. The test sends it: `HttpClient.execute` answers the
81
+ * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
82
+ * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
83
+ * asserting on what `client` decodes away, such as `tools/list`, a refusal's challenge, or
84
+ * a call its types would not send.
85
+ */
86
+ export declare const request: (method: string, params?: Params, { headers, url }?: RequestOptions) => HttpClientRequest.HttpClientRequest;
87
+ //#endregion
@@ -0,0 +1,122 @@
1
+ import { Refusal } from "../contract/errors.mjs";
2
+ import { Anyone, assertOnce, projectedErrors } from "../contract/rules.mjs";
3
+ import { checked } from "../contract/call.mjs";
4
+ import { defaultPath, httpProtocol, liftOf, unliftedFrom } from "./protocol.mjs";
5
+ import { Effect, Option, Predicate, Schema, identity } from "effect";
6
+ import { McpSchema } from "effect/ai";
7
+ import { HttpClient, HttpClientRequest } from "effect/http";
8
+ import { Sse } from "effect/encoding";
9
+ //#region src/mcp/client.ts
10
+ /**
11
+ * One stateless MCP request of `method` with `params`, as `layerHttp` serves it: the headers
12
+ * routing it, which repeat what its body says, and its JSON-RPC body. The client metadata goes in
13
+ * `_meta`, under any given, such as a `progressToken`; the protocol version is always the
14
+ * request's own.
15
+ */
16
+ const statelessRequest = (method, params) => {
17
+ const { protocolVersion } = httpProtocol;
18
+ const name = method === "resources/read" ? params.uri : params.name;
19
+ return {
20
+ headers: {
21
+ accept: "application/json, text/event-stream",
22
+ "mcp-protocol-version": protocolVersion,
23
+ "mcp-method": method,
24
+ ...Predicate.isString(name) ? { "mcp-name": name } : {}
25
+ },
26
+ body: {
27
+ jsonrpc: "2.0",
28
+ id: 1,
29
+ method,
30
+ params: {
31
+ ...params,
32
+ _meta: {
33
+ "io.modelcontextprotocol/clientCapabilities": {},
34
+ "io.modelcontextprotocol/clientInfo": {
35
+ name: "effect-actions",
36
+ version: "0"
37
+ },
38
+ ...params._meta,
39
+ "io.modelcontextprotocol/protocolVersion": protocolVersion
40
+ }
41
+ }
42
+ }
43
+ };
44
+ };
45
+ /**
46
+ * An answer a client method cannot read as the action's success or a declared error, such as
47
+ * the native server's message for invalid arguments or an unknown tool, or a result without
48
+ * its structured content. Its message holds the answer. Structured content that does not
49
+ * decode as the success is a `SchemaError` instead, and a request that fails an
50
+ * `HttpClientError`.
51
+ */
52
+ var McpCallError = class extends Schema.TaggedError()("McpCallError", { message: Schema.String }) {};
53
+ const textOf = (block) => block?.type === "text" ? block.text : void 0;
54
+ const ToolCallReply = Schema.Union([Schema.Struct({ result: McpSchema.CallToolResult }), Schema.Struct({ error: Schema.Struct({
55
+ code: Schema.Finite,
56
+ message: Schema.String
57
+ }) })]);
58
+ const decodeReply = Schema.decodeUnknownOption(Schema.fromJsonString(ToolCallReply));
59
+ const replyOf = (response, text) => {
60
+ if (!(response.headers["content-type"] ?? "").startsWith("text/event-stream")) return decodeReply(text);
61
+ const data = [];
62
+ Sse.makeParser((event) => {
63
+ if (Predicate.isTagged(event, "Event")) data.push(event.data);
64
+ }).feed(text);
65
+ return Option.fromNullishOr(data.flatMap((message) => Option.toArray(decodeReply(message))).at(-1));
66
+ };
67
+ const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
68
+ const callTool = (client, url, action, lift, refusedWith, input) => {
69
+ const { name } = action;
70
+ const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
71
+ return Effect.gen(function* () {
72
+ const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
73
+ const response = yield* client.execute(request("tools/call", {
74
+ name,
75
+ arguments: encoded
76
+ }, { url }));
77
+ const text = yield* response.text;
78
+ if (response.status !== 200) return yield* failWith(refusedWith, text, other(`answered ${response.status}: ${text}`));
79
+ const reply = replyOf(response, text);
80
+ if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
81
+ if ("error" in reply.value) {
82
+ const { code, message } = reply.value.error;
83
+ return yield* Effect.fail(other(`failed with ${code}: ${message}`));
84
+ }
85
+ const { result } = reply.value;
86
+ if (result.isError === true) {
87
+ const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
88
+ return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
89
+ }
90
+ if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
91
+ return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(unliftedFrom(lift, result));
92
+ });
93
+ };
94
+ function client(actions, { url = defaultPath, transformClient = identity, authentication } = {}) {
95
+ assertOnce("action", actions);
96
+ return Effect.map(HttpClient.HttpClient, (native) => {
97
+ const http = transformClient(native);
98
+ return Object.fromEntries(actions.map((action) => {
99
+ const lift = liftOf(action.success);
100
+ const refusedWith = [...Refusal.members, ...action.caller === Anyone ? [] : authentication?.error ?? []];
101
+ return [action.name, checked(action, (input) => callTool(http, url, action, lift, refusedWith, input))];
102
+ }));
103
+ });
104
+ }
105
+ /**
106
+ * One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
107
+ * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
108
+ * `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
109
+ * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
110
+ * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
111
+ * version is always the request's own. The test sends it: `HttpClient.execute` answers the
112
+ * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
113
+ * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
114
+ * asserting on what `client` decodes away, such as `tools/list`, a refusal's challenge, or
115
+ * a call its types would not send.
116
+ */
117
+ const request = (method, params = {}, { headers = {}, url = defaultPath } = {}) => {
118
+ const { headers: routingHeaders, body } = statelessRequest(method, params);
119
+ return HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.setHeaders(routingHeaders), HttpClientRequest.bodyJsonUnsafe(body));
120
+ };
121
+ //#endregion
122
+ export { McpCallError, client, request, statelessRequest };
@@ -2,6 +2,7 @@ import { unsuspended } from "../contract/rules.mjs";
2
2
  import { Media, isMarked, kindOf } from "../contract/media.mjs";
3
3
  import { Predicate, Schema, SchemaAST } from "effect";
4
4
  import { McpProtocol } from "effect/ai";
5
+ import { Base64 } from "effect/encoding";
5
6
  //#region src/mcp/protocol.ts
6
7
  const httpProtocol = McpProtocol.v2026_07_28;
7
8
  const protocols = [
@@ -10,42 +11,6 @@ const protocols = [
10
11
  McpProtocol.v2025_06_18
11
12
  ];
12
13
  const defaultPath = "/mcp";
13
- const mcpNameHeaderOf = (method, params) => method === "resources/read" ? params.uri : params.name;
14
- /**
15
- * One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it:
16
- * the headers routing it, which repeat what its body says, and its JSON-RPC body. The client
17
- * metadata goes in `_meta`, under any given, such as a `progressToken`; the protocol version
18
- * is always the request's own.
19
- */
20
- const statelessRequest = (method, params) => {
21
- const { protocolVersion } = httpProtocol;
22
- const name = mcpNameHeaderOf(method, params);
23
- return {
24
- headers: {
25
- accept: "application/json, text/event-stream",
26
- "mcp-protocol-version": protocolVersion,
27
- "mcp-method": method,
28
- ...Predicate.isString(name) ? { "mcp-name": name } : {}
29
- },
30
- body: {
31
- jsonrpc: "2.0",
32
- id: 1,
33
- method,
34
- params: {
35
- ...params,
36
- _meta: {
37
- "io.modelcontextprotocol/clientCapabilities": {},
38
- "io.modelcontextprotocol/clientInfo": {
39
- name: "effect-actions",
40
- version: "0"
41
- },
42
- ...params._meta,
43
- "io.modelcontextprotocol/protocolVersion": protocolVersion
44
- }
45
- }
46
- }
47
- };
48
- };
49
14
  const fieldKey = "effect-actions/field";
50
15
  const isMedia = Schema.is(Media);
51
16
  const isMediaArray = Schema.is(Schema.Array(Media));
@@ -145,5 +110,28 @@ const liftedFrom = (lift, success) => lift.fields.flatMap(({ name, kind, many })
145
110
  value: media
146
111
  }));
147
112
  });
113
+ const mediaOf = ({ name, kind, many }, result) => {
114
+ const values = result.content.flatMap((block) => block.type === kind && block._meta?.["effect-actions/field"] === name ? [{
115
+ data: Base64.encode(block.data),
116
+ mimeType: block.mimeType
117
+ }] : []);
118
+ return many || values.length > 1 ? values : values[0];
119
+ };
120
+ /**
121
+ * The encoded success a tool result carries, `liftedFrom` undone: its structured content, each
122
+ * lifted field from the blocks whose `_meta` names it, and a success that is media from its
123
+ * blocks alone.
124
+ */
125
+ const unliftedFrom = (lift, result) => {
126
+ const [whole] = lift.fields;
127
+ if (whole !== void 0 && whole.name === void 0) return mediaOf(whole, result);
128
+ const { structuredContent = {} } = result;
129
+ if (!Predicate.isObject(structuredContent)) return structuredContent;
130
+ const lifted = new Set(lift.fields.map(({ name }) => name));
131
+ return Object.fromEntries([...Object.entries(structuredContent).filter(([key]) => !lifted.has(key)), ...lift.fields.flatMap((field) => {
132
+ const value = mediaOf(field, result);
133
+ return field.name === void 0 || value === void 0 ? [] : [[field.name, value]];
134
+ })]);
135
+ };
148
136
  //#endregion
149
- export { defaultPath, fieldKey, httpProtocol, isMisplaced, liftOf, liftedFrom, protocols, statelessRequest };
137
+ export { defaultPath, fieldKey, httpProtocol, isMisplaced, liftOf, liftedFrom, protocols, unliftedFrom };
package/docs/Action.md CHANGED
@@ -29,7 +29,7 @@ Import `@gjermundgaraba/effect-actions/Action`.
29
29
  | `Action`, `Any`, `Implementation` | Concrete and erased contracts, and bound implementations. |
30
30
  | `AnyImplementation`, `AnyImplementation<A>` | Any implementation, or any of actions `A`, erased. |
31
31
  | `BuildServices`, `BuildError` | What builders read and fail with at startup. |
32
- | `implementation.actions` | Its exact contracts, as `Testing.mcpClient` takes them. |
32
+ | `implementation.actions` | Its exact contracts, as `ActionMcp.client` takes them. |
33
33
  | `Handler`, `Handlers`, `Authorize` | A typed handler, a list's record of them, and an authorizer. |
34
34
  | `Options`, `Mcp` | What `make` takes, and its `mcp` options. |
35
35
 
@@ -197,8 +197,8 @@ Derive the rule from `action.readOnly`, never from an `mcp` hint: changing an ac
197
197
  - **Name `scopes` in a `Forbidden` only where re-authorizing can grant them.** An OAuth
198
198
  challenge answers them, which a client follows only where the server publishes discovery
199
199
  ([Authentication.md](Authentication.md#publish-oauth-discovery)); with fixed tokens, such as
200
- API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error). Without scopes it is a 403 over HTTP and, over MCP, an `isError` tool
201
- result in a 200 ([guarantees.md](guarantees.md#wire-behavior)).
200
+ API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error). What a
201
+ refusal answers on each surface is in [guarantees.md](guarantees.md#wire-behavior).
202
202
  - **What a plain authorizer yields is a request-time requirement of every surface.** To read a
203
203
  store, make `authorize` an Effect that yields the store once and returns the function, as a
204
204
  builder does
@@ -225,8 +225,9 @@ failure compiles. What each surface answers is in [guarantees.md](guarantees.md#
225
225
  - **Fail with `Action.InvalidInput` for input that decodes but cannot be served**, naming
226
226
  `issues` of your own, instead of declaring a second error for bad input
227
227
  ([kitchen-sink/src/attachments/handlers.ts](../examples/kitchen-sink/src/attachments/handlers.ts)).
228
- - **For input you decode yourself**, such as a header or a route parameter, answer as the
229
- surfaces do: `Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(Action.InvalidInput.fromSchemaError))`.
228
+ - **For input you decode yourself** on the server, such as a header or a route parameter,
229
+ answer as the surfaces do, with every issue:
230
+ `Schema.decodeUnknownEffect(schema)(value, { errors: "all" }).pipe(Effect.mapError(Action.InvalidInput.fromSchemaError))`.
230
231
  - **Map another service's refusals before failing with them.** A client that decodes the
231
232
  built-in errors, such as `ActionHttp.client`, hands a handler that service's `Unauthenticated`
232
233
  or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
package/docs/ActionCli.md CHANGED
@@ -148,9 +148,10 @@ In an aggregate, a command's options go under `commands`, keyed by the action's
148
148
  ### Add a command of your own
149
149
 
150
150
  Work every surface should offer, a bulk import for one, is an action first: a command of your
151
- own then only reads the file and calls it, as `notes import <file>` reads `Argument.FileText`
152
- and calls `importNotes`. For a syntax the options cannot express, such as a flag named apart
153
- from its field, build the command with native `Command.make`, `Flag` and `Argument`, and pass it
151
+ own then only reads the file and calls it. Its client checks the input as it encodes it, naming
152
+ a bad entry by its path, so a decode of your own only repeats that check. `notes import <file>`
153
+ reads `Argument.FileText` and calls `importNotes`. For a syntax the options cannot express, such
154
+ as a flag named apart from its field, build the command with native `Command.make`, `Flag` and `Argument`, and pass it
154
155
  in `subcommands`: `notes export <file>` takes `Argument.String("file")`
155
156
  ([notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)):
156
157
 
@@ -18,9 +18,10 @@ Search for a fragment of the message. The tasks are in [ActionMcp.md](ActionMcp.
18
18
  - A protected tool's call answers 401 to a signed-in client after a while: its token expired. The client refreshes it, or signs in again, on the challenge's `invalid_token`.
19
19
  - An MCP client gets an `isError` refusal, not the 401 or 403 it re-authorizes on: the handler sent a notification first, which starts the response, so its refusal is a tool result. Refuse in `authorize`.
20
20
  - A client that opens with `initialize` gets 401 where it lists the tools or calls a public one, on an endpoint that also serves protected tools: a session's every request authenticates. Sign the client in, or serve the public tools on an endpoint of their own.
21
+ - A client that opens with `initialize`, such as Codex, gets 400 with JSON-RPC error `-32020`, `MCP-Protocol-Version header is required`, or 401 signed out where the endpoint serves a protected tool, and a `DELETE` gets 405: the endpoint is `requestScoped`, which speaks MCP 2026-07-28 alone and keeps no session. Connect that client to an endpoint without it, on a host that keeps a process running.
21
22
  - A request over HTTP answers 400, `MCP-Session-Id header is required; send initialize to start a session`: its `MCP-Protocol-Version` header is absent or names a 2025 revision, so it is a session's request, and it follows no `initialize`. One answering 404 names a session that ended, or one of a process since restarted: the client opens another.
22
- - A hand-written HTTP request answers 400 with JSON-RPC error `-32602`, `Required request metadata is missing` or `io.modelcontextprotocol/clientCapabilities request metadata is required`: it names 2026-07-28 in `MCP-Protocol-Version` and its `params._meta` lacks `io.modelcontextprotocol/protocolVersion`, for the first message, or `io.modelcontextprotocol/clientCapabilities`, for the second. Build it with `Testing.mcpRequest`, or copy the `curl` of [ActionMcp.md](ActionMcp.md#serve-tools-over-stdio).
23
- - A hand-written HTTP request answers 400 with JSON-RPC error `-32020`, `MCP-Protocol-Version header is required`, `Mcp-Method header does not match request method` or `Mcp-Name header does not match request parameters`: those headers repeat a 2026-07-28 request's revision, method and `tools/call` name, and one is absent or differs from its body. Build it with `Testing.mcpRequest`.
23
+ - A hand-written HTTP request answers 400 with JSON-RPC error `-32602`, `Required request metadata is missing` or `io.modelcontextprotocol/clientCapabilities request metadata is required`: it names 2026-07-28 in `MCP-Protocol-Version` and its `params._meta` lacks `io.modelcontextprotocol/protocolVersion`, for the first message, or `io.modelcontextprotocol/clientCapabilities`, for the second. Build it with `ActionMcp.request`, or copy the `curl` of [mcp-tools](../examples/mcp-tools/README.md#connect-a-client).
24
+ - A hand-written HTTP request answers 400 with JSON-RPC error `-32020`, `MCP-Protocol-Version header is required`, `Mcp-Method header does not match request method` or `Mcp-Name header does not match request parameters`: those headers repeat a 2026-07-28 request's revision, method and `tools/call` name, and one is absent or differs from its body. Build it with `ActionMcp.request`.
24
25
  - A hand-written HTTP request answers an empty 406: its `Accept` header does not list both `application/json` and `text/event-stream`, as `*/*` does not. Send both.
25
26
  - A client asking for 2025-03-26 or 2024-11-05 is offered 2025-11-25, and disconnects if it cannot speak it: HTTP and stdio serve 2026-07-28, 2025-11-25 and 2025-06-18. Update the client.
26
27
  - A request written by hand to a stdio server answers JSON-RPC error `-32602`, `Invalid request metadata`: it is neither preceded by `initialize` nor carries the 2026-07-28 metadata. Open with `initialize` and `notifications/initialized`, or give the request's `_meta` its `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities`.
@@ -29,3 +30,6 @@ Search for a fragment of the message. The tasks are in [ActionMcp.md](ActionMcp.
29
30
  - A disallowed `Origin` gets 401, not 403: the endpoint's authentication, which runs before its Origin check, refused the request.
30
31
  - Browser calls fail despite an allowed origin, the preflight answering 405, or 401 on an endpoint with `authentication`, which every method but `POST` meets first: `allowedOrigins` is no CORS. Add `HttpRouter.cors` ([browser clients](ActionMcp.md#allow-browser-clients)).
31
32
  - `Tool <name> has an output schema but did not return structured content` raised by a Python MCP client for an action that returns nothing: its success is sent as `null`, which a Python SDK release before the fix of python-sdk#3345 reads as missing. Update the SDK.
33
+ - `MCP tools/call "<name>" answered 404`: the endpoint is not at `/mcp`, the default. Pass `ActionMcp.client` its `url`.
34
+ - `MCP tools/call "<name>" returned an error: Invalid parameters for tool ...`: the endpoint serves another contract under that name. Pass `ActionMcp.client` the served contract value.
35
+ - `UrlError` from `HttpClientRequest.toWebResult` or `toWeb` of an `ActionMcp.request`, or an `HttpClientError` whose reason is `InvalidUrlError` from a client method or a sent `ActionMcp.request`: the `url` is relative, such as the default `/mcp`, which only the `HttpClient` of `Testing.layer` resolves. Pass an absolute one.
package/docs/ActionMcp.md CHANGED
@@ -18,7 +18,10 @@ Import `@gjermundgaraba/effect-actions/ActionMcp`.
18
18
  | `layerHttp(implementations, options)` | A Layer serving the actions as tools at one HTTP endpoint. |
19
19
  | `runStdio(implementations, options)` | An Effect serving them on standard I/O until stdin closes. |
20
20
  | `Options` | Type of `runStdio`'s options. |
21
- | `LayerHttpOptions` | Type of `layerHttp`'s options: `Options` and four for HTTP. |
21
+ | `LayerHttpOptions` | Type of `layerHttp`'s options: `Options` and HTTP's own. |
22
+ | `client(actions, options?)` | An Effect of a client calling each action's tool. |
23
+ | `request(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` to send. |
24
+ | `McpCallError` | A client method's failure for an answer it cannot decode. |
22
25
 
23
26
  `implementations` is one implementation or a list.
24
27
 
@@ -33,10 +36,15 @@ Import `@gjermundgaraba/effect-actions/ActionMcp`.
33
36
  | `path` | HTTP only: the endpoint's path; defaults to `/mcp`. |
34
37
  | `allowedOrigins` | HTTP only: the exact origins allowed; none by default. |
35
38
  | `allowSubscriptions` | HTTP only: `false` turns off 2026-07-28 subscriptions. |
39
+ | `requestScoped` | HTTP only: `true` runs a server per request. |
36
40
  | `authentication` | HTTP only: the protected tools' authentication descriptor. |
37
41
 
38
- All but `actions`, `features` and `authentication` are Effect's own `McpServer` options, passed
39
- through. Its `protocols` and `allowSessionTermination` are not options.
42
+ All but `actions`, `features`, `authentication` and `requestScoped` are Effect's own `McpServer`
43
+ options, passed through. Its `protocols` and `allowSessionTermination` are not options.
44
+
45
+ `client` takes `url`, default `/mcp`, `transformClient`, and the endpoint's `authentication`, so an
46
+ error its verifier declares decodes; `request` takes `url` and `headers`.
47
+ Types: `Client<Actions, D>`, `ClientOptions`, `RequestOptions`, `Params`.
40
48
 
41
49
  ## Serve tools over HTTP
42
50
 
@@ -66,6 +74,9 @@ see [Action-in-process.md](Action-in-process.md#share-one-build-across-servers).
66
74
  Effect expires none, so limit request bodies, which bounds what one holds. Where an endpoint
67
75
  serves a protected tool, only a signed-in caller opens one, and that client signs in for every
68
76
  request, a public tool's call included. An endpoint of public tools alone lets anyone open them.
77
+ - **On a serverless host**, such as Cloudflare Workers, pass `requestScoped: true`: a server per
78
+ request, speaking 2026-07-28 alone with no session. Connect a client that opens with
79
+ `initialize` to a long-lived endpoint: [notes-service/src/worker.ts](../examples/notes-service/src/worker.ts).
69
80
  - An endpoint without `authentication` answers whoever reaches its port. Bind it to loopback or to
70
81
  a network only trusted clients are on, and limit request bodies
71
82
  ([ActionHttp.md](ActionHttp.md#serve-the-routes)):
@@ -90,16 +101,9 @@ ActionMcp.runStdio([status, notes, identity], { name: "notes-service", version:
90
101
  See [notes-service/src/stdio.ts](../examples/notes-service/src/stdio.ts), and
91
102
  [mcp-tools/src/stdio.ts](../examples/mcp-tools/src/stdio.ts) for public tools.
92
103
 
93
- To try a server by hand, send it one request, its 2026-07-28 metadata in `_meta` as
94
- `Testing.mcpRequest` builds it. Over HTTP it also needs all five headers:
95
-
96
- ```sh
97
- BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getStatus","arguments":{},"_meta":{"io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
98
- echo "$BODY" | node --import tsx examples/notes-service/src/stdio.ts
99
- curl http://127.0.0.1:3000/mcp -d "$BODY" -H 'Content-Type: application/json' \
100
- -H 'Accept: application/json, text/event-stream' -H 'MCP-Protocol-Version: 2026-07-28' \
101
- -H 'MCP-Method: tools/call' -H 'MCP-Name: getStatus'
102
- ```
104
+ To try a server by hand, send one request as `request` builds it, its 2026-07-28 metadata in
105
+ `_meta`: [mcp-tools](../examples/mcp-tools/README.md#connect-a-client) has the `curl`, with
106
+ HTTP's five headers, and [notes-service](../examples/notes-service/README.md#run-it) the stdio command.
103
107
 
104
108
  - **stdout carries the protocol alone.** `runStdio` sends its own program's Effect logs and
105
109
  `Console` output to stderr. For the layers provided around it, apply `ActionCli.logToStderr`
@@ -112,10 +116,7 @@ curl http://127.0.0.1:3000/mcp -d "$BODY" -H 'Content-Type: application/json' \
112
116
  protected tools' identity around `runStdio`, whose one caller is whoever started it. Each
113
117
  implementation's `authorize` still runs, so give that caller what it checks. Tools served
114
118
  behind a token over HTTP keep their one protected contract here: never a public copy.
115
- - **Test the tools without a process**: the implementations in process over a fake upstream
116
- ([mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts)),
117
- an endpoint in memory
118
- ([mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts)).
119
+ - **Test the tools without a process**: [Testing.md](Testing.md#test-mcp-tools).
119
120
 
120
121
  ## Describe tools to a model
121
122
 
@@ -156,8 +157,8 @@ success: { printer: Printer, image: Action.Image },
156
157
 
157
158
  The tool sends the field as an image block, after the JSON of the rest. Every other surface sends
158
159
  it as JSON, the bytes in base64. See
159
- [mcp-tools/src/printers/Printers.ts](../examples/mcp-tools/src/printers/Printers.ts), and
160
- [Testing.md](Testing.md#test-mcp-tools) for the client that decodes it back.
160
+ [mcp-tools/src/printers/Printers.ts](../examples/mcp-tools/src/printers/Printers.ts); `client`
161
+ decodes it back.
161
162
 
162
163
  - An image is a top-level field of a struct success, bare, optional or a required array, or the
163
164
  whole success or an array of it. Anywhere else in a served action, as in a `Schema.NullOr`, a
@@ -196,24 +197,22 @@ sign in. What a signed-in caller may do is each implementation's
196
197
  to signed-out callers, or public ones open to a session's client, serve the public tools on an
197
198
  endpoint of their own: a second `layerHttp` on another `path`.
198
199
  - No listing is filtered by caller: `authorize` refuses the call, not the sight of the tool.
199
- - On a Bearer endpoint, a protected tool's `Unauthenticated`, or `Forbidden` naming `scopes`,
200
- answers the request with 401 or 403 and a challenge the client acts on, in place of a tool
201
- result ([Authentication.md](Authentication.md#step-up-for-a-scope)). A `Forbidden` naming none
202
- is an `isError` tool result, in a 200. Once a handler has sent a notification the response has
203
- started, and its refusal is a tool result: refuse in `authorize`.
200
+ - On a Bearer endpoint, a protected tool's step-up refusal answers the request, not a tool
201
+ result ([Authentication.md](Authentication.md#step-up-for-a-scope),
202
+ [guarantees.md](guarantees.md#wire-behavior)). Once a handler has sent a notification the
203
+ response has started, and its refusal is a tool result: refuse in `authorize`.
204
204
  - Test as a signed-in caller ([Testing.md](Testing.md#test-as-a-signed-in-caller)):
205
205
  [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts).
206
- `Testing.mcpClient` decodes a refusal alike from a 401, a 403 or a tool result: to assert the
207
- status, send `Testing.mcpRequest`.
208
206
 
209
207
  ## Allow browser clients
210
208
 
211
209
  A browser MCP client sends its page's `Origin`, and needs two things.
212
210
 
213
211
  - **`allowedOrigins`.** A request carrying an `Origin` is refused with 403 unless that exact
214
- origin is listed, so a page a user happens to visit cannot call the tools; one without, as
215
- other clients send, passes. Read the list from configuration:
216
- [mcp-tools/src/routes.ts](../examples/mcp-tools/src/routes.ts).
212
+ origin is listed, so a page a user visits cannot call the tools; one without, as other
213
+ clients send, passes. Authentication runs first, so the verifier sees a token from any origin:
214
+ a request that must authenticate, or sends a token, gets 401 without a valid one. Read the
215
+ list from config: [mcp-tools/src/routes.ts](../examples/mcp-tools/src/routes.ts).
217
216
  - **CORS.** `allowedOrigins` only refuses: it answers no preflight and adds no header. Add
218
217
  `HttpRouter.cors`, global to the router, only where an origin is configured: given an empty
219
218
  list, it answers `Access-Control-Allow-Origin: *`, and given one origin it names that origin
@@ -224,9 +223,8 @@ A browser MCP client sends its page's `Origin`, and needs two things.
224
223
  [kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts) with
225
224
  authentication.
226
225
 
227
- The endpoint's authentication runs before its Origin check, so `allowedOrigins` keeps no request
228
- from the verifier. A `Host` allowlist, for a request that sends no `Origin`, or an Origin check
229
- that must run first is a request policy:
226
+ A `Host` allowlist, for a request that sends no `Origin`, or an Origin check that must run first
227
+ is a request policy:
230
228
  [Authentication-advanced.md](Authentication-advanced.md#run-a-request-policy-before-authentication).
231
229
 
232
230
  ## Add native prompts and resources
@@ -239,7 +237,6 @@ one and pass it as `features`:
239
237
  around the endpoint, never to the `features` layer itself: there it is built again for the
240
238
  features alone, so a resource reads another store than the tools write.
241
239
 
242
- - Merged beside the endpoint, not given as `features`, they build and are never served.
243
240
  - No `authorize` runs for them, and they run with the services they were built with, never a
244
241
  request's, so they cannot read the caller. Content that depends on the caller is an action.
245
242
  - On an endpoint naming `authentication`, every prompt and resource request needs a credential,
@@ -6,7 +6,8 @@ The tasks are in [ActionToolkit.md](ActionToolkit.md).
6
6
  - An approved call never runs, and nothing is raised: a `tool-approval-response` finds its request by `approvalId` in the prompt history, and was sent without it. Use a `Chat`, or resend the assistant message holding the call and its request before the response.
7
7
  - `Type 'X' is not assignable to type 'never'` where a turn or a call by hand runs, or `Service not found` in plain JavaScript: a request service is provided around the turn, or around the whole call effect, not to the stream alone, and a protected tool owes its identity whatever its handler reads. A startup service's Layer is provided to `layer`.
8
8
  - A returned failure whose `result` is an `AiError` with reason `ToolParameterValidationError` (`failureOrigin: "parameters"`), not a declared error: the arguments did not decode. Neither `authorize` nor the handler ran.
9
- - A turn fails with an `AiError`, `Failed to encode result for tool '<name>'`: the handler failed with an error its action does not declare, which a cast or plain JavaScript lets through. Declare it on the action.
9
+ - A call dies with the handler's failure and a `SchemaError` beside it: the handler failed with an error its action does not declare, which a cast or plain JavaScript lets through, or with one breaking a check of its schema, such as `Infinity` in a struct error's `Schema.Finite` field. Declare the error on the action, or fix the value.
10
+ - A turn fails with an `AiError`, `Failed to encode result for tool '<name>'`: the handler's success does not encode, such as `Infinity` for a `Schema.Finite`.
10
11
  - A model reads base64 text where an image was meant: a tool's success holds an `Action.Image`, which a Toolkit gives the model as JSON text. Leave the action out of `actions`, or serve it over MCP, which sends an image block ([ActionMcp.md](ActionMcp.md#return-images-and-other-media)).
11
12
  - `Listed in actions, but no implementation holds it: <names>` thrown at `make`: those actions are not the implementations', by identity. `(another contract)` marks one whose name an implementation holds, as a second copy of the contracts module makes. Pass the implementation holding the action, or list the contract it implements.
12
13
  - `Duplicate tool: <name>` thrown at `make`: two implementations serve actions of one name. Rename one action; of two implementations of one contract, pass one.
@@ -50,11 +50,11 @@ conversations.pipe(
50
50
 
51
51
  - **One `generateText` is one request to the model.** Its tool calls run, and their results come back in the response, unread by the model. For the model to read them, ask the `Chat` again with `prompt: []` while a response has `toolCalls` and no `tool-approval-request`.
52
52
  - Without `actions`, every action of the implementations is a tool, one added later included. Where a model must not get them all, list the tools ([Action.md](Action.md#define-a-contract)): [kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts).
53
- - A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads.
53
+ - A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads. So are arguments that do not decode, an `AiError` (`ToolParameterValidationError`), not `InvalidInput`: neither `authorize` nor the handler ran.
54
54
  - A turn's tool calls, the approved calls of an answering turn included, run concurrently unless `generateText` is given a `concurrency`. A service they share must tolerate it: [assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
55
55
  - Arguments the input does not declare are dropped, where HTTP and a CLI refuse them ([guarantees.md](guarantees.md#wire-behavior)): a misspelled optional argument is ignored silently.
56
56
  - Beside other tools, merge natively and provide every handler layer: `Toolkit.merge(toolkit, others)` with `Layer.mergeAll(layer, othersHandlers)`.
57
- - **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), which the entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts), and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts).
57
+ - **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), which the entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts), and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts). Name the files as it does, the route's contract `Assistant.ts` and the loop `handlers.ts`, from the first one written: a lowercase `assistant.ts` beside the contract fails with TS1149 ([setup-failures.md](setup-failures.md)).
58
58
  - **The real model comes later?** Provide a scripted one from the entrypoint until then, as the kitchen-sink does. Reading the model with `Effect.serviceOption` hides the requirement: the server builds without one and every request fails.
59
59
 
60
60
  ### Call a tool by hand
@@ -64,9 +64,9 @@ Tasks: [Authentication.md](Authentication.md),
64
64
  too ([ActionMcp.md](ActionMcp.md#protect-tools)).
65
65
  - A verifier's declared error arrives as 422: the error states no `httpApiStatus`. Give it the
66
66
  status it means, such as `{ httpApiStatus: 503 }`.
67
- - `Testing.mcpClient` fails with an `McpCallError`, not a typed error, when the verifier fails
67
+ - `ActionMcp.client` fails with an `McpCallError`, not a typed error, when the verifier fails
68
68
  with an error its descriptor declares: over MCP that failure is the endpoint's HTTP answer,
69
- before any tool runs. The `McpCallError` names its status and body.
69
+ before any tool runs. Pass the endpoint's descriptor as the client's `authentication`.
70
70
  - A route's `Cache-Control` is replaced by `no-store`: only a route's own answer keeps its
71
71
  caching, and this response is a failure that enclosing middleware serialized.
72
72
 
@@ -70,6 +70,12 @@ does an RPC binding ([live-rpc/src/Rpc.ts](../examples/live-rpc/src/Rpc.ts)).
70
70
  `ActionRpc.layer`, `ActionMcp.layerHttp` and `Authentication.protect` refuse, when they build,
71
71
  a descriptor that is another object than the one the provider was given, equal name and
72
72
  options or not.
73
+ - Protecting an action that tests already call breaks them. An `Action.client` call then owes
74
+ `Type 'CurrentUser' is missing … from type 'Scope'`: provide the caller around each call
75
+ ([Testing-failures.md](Testing-failures.md)). A test that builds `ActionMcp.layerHttp` itself
76
+ gets `Property 'authentication' is missing`: serve `routes` with `Testing.layer`
77
+ ([ActionMcp-failures.md](ActionMcp-failures.md)). A call over HTTP without a credential gets
78
+ `Unauthenticated` at run time.
73
79
  - A local surface has no remote caller and takes no descriptor: the host provides the identity
74
80
  itself, for a CLI ([ActionCli.md](ActionCli.md#name-the-caller)), a Toolkit
75
81
  ([ActionToolkit.md](ActionToolkit.md#name-the-caller)) and an MCP server over stdio
@@ -142,15 +148,14 @@ added. The authorizer, a handler or the verifier may fail with one:
142
148
 
143
149
  - Step-up is a Bearer descriptor's, for protected actions and routes under `protect`: the
144
150
  refusal answers the request with 401 or 403 and its challenge, over MCP in place of the tool
145
- result.
151
+ result unless the response has started ([guarantees.md](guarantees.md#wire-behavior)).
146
152
  - On a public action or under another scheme, an authorizer's or a handler's refusal is
147
153
  answered like a declared error, and RPC never steps up: a refusal is the call's typed failure
148
154
  ([guarantees.md](guarantees.md#wire-behavior)).
149
155
  - Name scopes only when re-authorizing can grant them: a server with fixed tokens, such as API
150
- keys, leaves `scopes` out. A `Forbidden` without them prompts no login. Over HTTP it is a 403 without a
151
- challenge. Over MCP it is an `isError` tool result, in a 200, from the authorizer or a
152
- handler, and the endpoint's 403 only from the verifier
153
- ([kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts)).
156
+ keys, leaves `scopes` out. A `Forbidden` without them prompts no login
157
+ ([kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts));
158
+ what it answers on each surface is in [guarantees.md](guarantees.md#wire-behavior).
154
159
  - Map another service's refusals before failing with them
155
160
  ([Action.md](Action.md#fail-with-declared-and-built-in-errors)): passed on, a `Forbidden`
156
161
  naming that service's scopes sends this server's caller to re-authorize for them.
package/docs/CONTEXT.md CHANGED
@@ -23,7 +23,7 @@ page.
23
23
  - **Host**: the application that serves the surfaces: it builds their layers, provides their services and runs the server, subprocess or CLI.
24
24
  - **Surface**: where callers reach actions. A **remote surface** authenticates its callers (`ActionHttp.layer`, `ActionMcp.layerHttp`, `ActionRpc.layer`). A **local surface** has no one to authenticate (`ActionToolkit`, a local `ActionCli` command, `ActionMcp.runStdio`, `Action.client`): the host provides the identity.
25
25
  - **Binding**: what `ActionHttp.make` or `ActionRpc.make` returns: the actions its surface serves, as browser-safe data the server and every client share.
26
- - **Client**: one method per action, `client.<action>(input)`: `ActionHttp.client`, `ActionRpc.client`, `Testing.mcpClient` and, in process, `Action.client`.
26
+ - **Client**: one method per action, `client.<action>(input)`: `ActionHttp.client`, `ActionRpc.client`, `ActionMcp.client` and, in process, `Action.client`.
27
27
  - **Authorizer**: `authorize`, a function of the called action that an implementation of protected actions states. It succeeds or fails with a refusal ([Action.md](Action.md#authorize-calls)).
28
28
  - **Authentication descriptor**: what `Authentication.make` returns: a named, browser-safe declaration of how a remote caller proves an identity. A binding or an MCP endpoint serving protected actions names it.
29
29
  - **Authentication provider**: what `Authentication.layer` returns for a descriptor, holding its **verifier**: the server-only function from a credential to the identity.
@@ -1,6 +1,9 @@
1
1
  # Testing failure modes
2
2
 
3
3
  What you see when something is wrong, and the fix. Search for a fragment of the message.
4
+ A missing service's error can start
5
+ `… with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined'`: search a line
6
+ beneath it, `is missing the following properties from type 'Scope'`, instead.
4
7
  The tasks are in [Testing.md](Testing.md).
5
8
 
6
9
  - A route answers an empty 500, which a client method reports as `HttpClientError: Decode error (500 …)`: a defect, or a result that does not encode. `Testing.layer` logs the cause as an error on the process console, under `it.effect` too, and nothing for a request that succeeds or is refused: read the test's output, or collect it with a `Logger` or an `ErrorReporter` provided around the layer.
@@ -14,8 +17,5 @@ The tasks are in [Testing.md](Testing.md).
14
17
  - `Property '<field>' does not exist on type 'MethodError<Action<…>>'`, or on a built-in error, after `expect(error).toBeInstanceOf(…)`: `toBeInstanceOf` does not narrow the union of the action's errors. Compare with `toEqual(new X({ … }))`, or narrow with `instanceof` ([Assert on errors](Testing.md#assert-on-errors)).
15
18
  - A test passes whatever message an error carries: `toEqual` never compares a tagged error's `message`. Assert it separately ([Assert on errors](Testing.md#assert-on-errors)).
16
19
  - The test reads a service the handlers never changed: it was provided inside the routes and again to the test, two instances. Provide it once, around `Testing.layer`, with `Layer.provideMerge`.
17
- - `MCP tools/call "<name>" answered 404`: the endpoint is not at `/mcp`, the default. Pass `mcpClient` its `url`.
18
- - `MCP tools/call "<name>" returned an error: Invalid parameters for tool ...`: the endpoint serves another contract under that name. Pass `mcpClient` the served contract value.
19
20
  - A builder or handler fails with `NotFound: FileSystem.<method> (<path>)` for a file that exists, or a file route answers 500: with no `FileSystem` provided around `Testing.layer`, the routes read a no-op one. Provide `NodeFileSystem.layer` or `NodeServices.layer`. In a command test, the nearer of two provided is read: provide a fake `FileSystem` on the command, inside `NodeServices.layer`.
20
21
  - `Argument of type 'Layer<…, FileSystem | Generator | HttpPlatform | HttpRouter | Path>' is not assignable` at `HttpRouter.toWebHandler(routes)`: a web handler's routes need the platform services `Testing.layer(routes)` supplies itself. Provide `HttpServer.layerServices` to them.
21
- - `UrlError` from `HttpClientRequest.toWebResult` or `toWeb` of an `mcpRequest`, or an `HttpClientError` whose reason is `InvalidUrlError` from a client method or a sent `mcpRequest`: the `url` is relative, such as the default `/mcp`, which only the `HttpClient` of `Testing.layer` resolves. Pass an absolute one.