@gjermundgaraba/effect-actions 0.14.0 → 0.16.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 +23 -7
  6. package/dist/ActionMcp.mjs +78 -90
  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 +7 -2
  29. package/docs/ActionMcp.md +43 -46
  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
@@ -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.
package/docs/Testing.md CHANGED
@@ -16,23 +16,13 @@ When something fails, search [Testing-failures.md](Testing-failures.md). Reading
16
16
 
17
17
  Import `@gjermundgaraba/effect-actions/Testing`.
18
18
 
19
- | API | Purpose |
20
- | --------------------------------------- | ----------------------------------------------------------------------------- |
21
- | `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory. |
22
- | `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves. |
23
- | `mcpClient(actions, options?)` | An Effect of a client calling each action's tool, like `ActionHttp.client`. |
24
- | `mcpRequest(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` the test sends. |
25
- | `McpCallError` | A client method's failure for an answer it cannot decode; `message` holds it. |
26
-
27
- | Option | Meaning |
28
- | ------------------------------ | ------------------------------------------------------------------------------- |
29
- | `url` | The endpoint; default `/mcp`, the `ActionMcp.layerHttp` default. |
30
- | `mcpClient`: `transformClient` | Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token. |
31
- | `mcpRequest`: `headers` | Request headers. |
32
-
33
- Exported types: `McpClient<Actions>`, `McpClientOptions`, `McpRequestOptions`, and `McpParams`,
34
- the `params` of `mcpRequest`. A helper passing on a tool's `arguments` types them `Schema.Json`,
35
- and whole `params` `McpParams`, never `unknown`.
19
+ | API | Purpose |
20
+ | ---------------- | ---------------------------------------------------------------------------- |
21
+ | `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory. |
22
+ | `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves. |
23
+
24
+ MCP tools are tested with `ActionMcp.client` and `ActionMcp.request`
25
+ ([Test MCP tools](#test-mcp-tools)).
36
26
 
37
27
  ## Test an implementation in process
38
28
 
@@ -113,7 +103,7 @@ const as = (token: string) => ({
113
103
  ```
114
104
 
115
105
  Both clients take it: `ActionHttp.client(Http, as("reader"))` and
116
- `Testing.mcpClient([GetStatus, ...Notes], as("ada"))`.
106
+ `ActionMcp.client([GetStatus, ...Notes], as("ada"))`.
117
107
 
118
108
  - Never provide the identity to `layer`, around it or to the routes. It is a type error: only a
119
109
  provider of their own descriptor satisfies them.
@@ -130,16 +120,18 @@ each case are the table in [guarantees.md](guarantees.md#wire-behavior).
130
120
 
131
121
  ## Test MCP tools
132
122
 
133
- `Testing.mcpClient(actions, options?)` gives one method per action, calling its tool and decoding
123
+ `ActionMcp.client(actions, options?)` gives one method per action, calling its tool and decoding
134
124
  the result as `ActionHttp.client` decodes a route's, an image included. Pass the contract values
135
- the endpoint serves, such as `implementation.actions`.
125
+ the endpoint serves, such as `implementation.actions`. Its failures:
126
+ [ActionMcp-failures.md](ActionMcp-failures.md).
136
127
 
137
128
  A method fails with the action's declared errors and the built-in ones as decoded values, and with
138
129
  `McpCallError` for any other answer, a result without its structured content included;
139
130
  structured content that does not decode as the success is a `SchemaError`, and a request that
140
131
  fails Effect's `HttpClientError`. Only a tool's result carries the action's own errors: an HTTP
141
- answer other than 200 decodes as a refusal alone, so an error a verifier declares, typed over
142
- `ActionHttp.client`, is an `McpCallError` here
132
+ answer other than 200 decodes as a refusal, or, given the endpoint's `authentication`, as an error
133
+ its verifier declares for a protected tool, as `ActionHttp.client` types it; otherwise it is an
134
+ `McpCallError`
143
135
  ([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)).
144
136
 
145
137
  A method decodes a refusal alike whether the endpoint answered a 401 or a 403 or returned a tool
@@ -148,8 +140,8 @@ yourself and read its status.
148
140
 
149
141
  For that, and for what the client's types will not send or its decoding hides (`tools/list`, an
150
142
  `Origin` header, a refusal's challenge, a malformed call), build the request with
151
- `Testing.mcpRequest`. It returns a native `HttpClientRequest`:
152
- `yield* HttpClient.execute(Testing.mcpRequest("tools/call", { name: "whoAmI", arguments: {} }))`
143
+ `ActionMcp.request`. It returns a native `HttpClientRequest`:
144
+ `yield* HttpClient.execute(ActionMcp.request("tools/call", { name: "whoAmI", arguments: {} }))`
153
145
  succeeds with the response whatever its status, to read `response.status`, `response.headers`
154
146
  and `yield* response.json`
155
147
  ([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)
@@ -6,21 +6,22 @@ What a caller sees on each surface for each outcome of a call, and what the libr
6
6
 
7
7
  One call, on each surface. An HTTP route is `POST <prefix>/<action>` with a JSON body, the prefix `/api` by default. "Its JSON" is the value as its schema's JSON codec encodes it, such as `{"_tag":"NoteNotFound","id":"9"}`. A built-in error's JSON is `{"_tag":"InvalidInput","message":"…","issues":[{"path":["name"],"message":"…"}]}`, `{"_tag":"Unauthenticated","message":"…"}` or `{"_tag":"Forbidden","message":"…"}`, a `Forbidden` with the `scopes` it names. The CLI column is a command under `Command.run`.
8
8
 
9
- | Case | HTTP | MCP | RPC | Toolkit | CLI |
10
- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | Success | its JSON as the body; an empty **200** for an action without `success` | `isError: false`, `structuredContent` its JSON, and the same JSON as one text block; both `null` for an action without `success` | the call's success | a result with `isFailure: false`: `result` the value, `encodedResult` its JSON | its JSON on stdout, indented, or `render`'s text; nothing for an action without `success` |
12
- | Input that does not decode | **400** `InvalidInput` | `isError: true`, text `Invalid parameters for tool '<name>': …` | the typed `InvalidInput` | a result with `isFailure: true`: `result` an `AiError` whose `reason` is a `ToolParameterValidationError` | `InvalidInput`'s JSON on stderr, exit 1; a missing flag or a rejected choice is Effect CLI's own parse error instead |
13
- | Undeclared input field | refused, nested ones too: **400** `InvalidInput` naming its path | refused, as input that does not decode | refused: `InvalidInput` naming its path | **dropped**: the call runs without it | refused, in `--input` and in a flag's JSON value: `InvalidInput` |
14
- | Declared error | its JSON with its `httpApiStatus`, **422** without one | `isError: true`, its JSON as one text block, no `structuredContent` | the call's typed failure | a result with `isFailure: true`: `result` the error, `encodedResult` its JSON | its JSON on stderr; the command fails with a `UserError` whose `cause` is the error; exit 1 by default |
15
- | Protected action, credential missing or not verified | **401** `Unauthenticated` with the scheme's challenge, or what the verifier fails with, before the body is read; a public route reads no credential | the same HTTP answer in place of a JSON-RPC one, before the arguments are decoded; on an endpoint that also serves a public tool, that tool's call and `tools/list` need no credential, but one a request presents is still verified ([Protect tools](ActionMcp.md#protect-tools)) | the typed `Unauthenticated`, or what the verifier fails with, before the payload is decoded; never a status | no credential: the host provides the identity around the call ([Name the caller](ActionToolkit.md#name-the-caller)) | no credential: the host provides the identity to the command ([Name the caller](ActionCli.md#name-the-caller)) |
16
- | `Unauthenticated` or `Forbidden` from the authorizer or a handler | its JSON with **401** or **403**, after the input is decoded; on a protected route under Bearer, a 401 and a `Forbidden` naming `scopes` also carry a challenge | `isError: true`, its JSON as text; on an endpoint under Bearer, a protected tool's `Unauthenticated` or `Forbidden` naming `scopes` is the HTTP **401** or **403** instead, with its JSON and challenge | the call's typed failure; never a status or a challenge | a result with `isFailure: true`, as a declared error | as a declared error |
17
- | Defect, undeclared failure, or a success or error that does not encode | an empty **500** | `isError: true`, text `Tool execution failed due to an internal server error.` | that call's defect, an `Error` whose message is `Internal server error`; the connection's other calls go on | a defect is the call's defect; the rest fail the call with an `AiError`, `Failed to encode result for tool …` | a failure no schema encodes is a `UserError` whose `cause` is it, printed as its tag and message; a defect or a success that does not encode is the command's defect |
18
- | Unknown path, method or name | a path no layer serves, or a method other than `POST` on a route: **404** | another HTTP method: an empty **405** with `Allow: POST, DELETE`, signed out the **401** of an endpoint with authentication; an unknown tool: a JSON-RPC error `-32602`, with **200** over HTTP | an rpc the layer does not serve ends its call with `Unknown request tag` | Effect's own | Effect CLI's own |
19
- | Body that is not JSON, or not typed as JSON | not JSON: **400** `InvalidInput`; `Content-Type` missing or not JSON: **415**; both after authentication on a protected route | not JSON: a JSON-RPC error `-32700` with **200**; `Content-Type` missing or not JSON: an empty **415**; over stdio, a line that is not JSON goes unanswered | the protocol's own | — | a flag or `--input` that is not the JSON its field takes: `InvalidInput` |
20
-
21
- - **Typed clients.** `ActionHttp.client`, `ActionRpc.client`, `Testing.mcpClient` and `Action.client` fail with a declared or built-in error as its decoded value. Each drops undeclared fields as it encodes an input, since TypeScript lets a wider value through, and fails with `InvalidInput` for an input that does not encode, sending nothing. `Action.client` has no wire but passes input, success and failure through the same JSON codecs; an undeclared failure or a success that does not encode is its call's defect. A verifier's declared error reaches `Testing.mcpClient` as an `McpCallError`: over MCP it is the endpoint's HTTP answer.
9
+ | Case | HTTP | MCP | RPC | Toolkit | CLI |
10
+ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
11
+ | Success | its JSON as the body; an empty **200** for an action without `success` | `isError: false`, `structuredContent` its JSON, and the same JSON as one text block; both `null` for an action without `success` | the call's success | a result with `isFailure: false`: `result` the value, `encodedResult` its JSON | its JSON on stdout, indented, or `render`'s text; nothing for an action without `success` |
12
+ | Input that does not decode | **400** `InvalidInput` | `isError: true`, text `Invalid parameters for tool '<name>': …` | the typed `InvalidInput` | a result with `isFailure: true`: `result` an `AiError` whose `reason` is a `ToolParameterValidationError` | `InvalidInput`'s JSON on stderr, exit 1; a missing flag or a rejected choice is Effect CLI's own parse error instead |
13
+ | Undeclared input field | refused, nested ones too: **400** `InvalidInput` naming its path | refused, as input that does not decode | refused: `InvalidInput` naming its path | **dropped**: the call runs without it | refused, in `--input` and in a flag's JSON value: `InvalidInput` |
14
+ | Declared error | its JSON with its `httpApiStatus`, **422** without one | `isError: true`, its JSON as one text block, no `structuredContent` | the call's typed failure | a result with `isFailure: true`: `result` the error, `encodedResult` its JSON | its JSON on stderr; the command fails with a `UserError` whose `cause` is the error; exit 1 by default |
15
+ | Protected action, credential missing or not verified | **401** `Unauthenticated` with the scheme's challenge, or what the verifier fails with, before the body is read; a public route reads no credential | the same HTTP answer in place of a JSON-RPC one, before the arguments are decoded; on an endpoint that also serves a public tool, that tool's call and `tools/list` need no credential, but one a request presents is still verified ([Protect tools](ActionMcp.md#protect-tools)) | the typed `Unauthenticated`, or what the verifier fails with, before the payload is decoded; never a status | no credential: the host provides the identity around the call ([Name the caller](ActionToolkit.md#name-the-caller)) | no credential: the host provides the identity to the command ([Name the caller](ActionCli.md#name-the-caller)) |
16
+ | `Unauthenticated` or `Forbidden` from the authorizer or a handler | its JSON with **401** or **403**, after the input is decoded; on a protected route under Bearer, a 401 and a `Forbidden` naming `scopes` also carry a challenge | `isError: true`, its JSON as text, in a **200** over HTTP; on an endpoint under Bearer, a protected tool's step-up refusal is the HTTP **401** or **403** instead, with its JSON and challenge, unless a handler's notification has started the response | the call's typed failure; never a status or a challenge | a result with `isFailure: true`, as a declared error | as a declared error |
17
+ | Defect, undeclared failure, or a success or error that does not encode | an empty **500** | `isError: true`, text `Tool execution failed due to an internal server error.` | that call's defect, an `Error` whose message is `Internal server error`; the connection's other calls go on | a defect or a rejected failure is the call's defect; the rest fail it with an `AiError`, `Failed to encode result for tool …` | an accepted error that does not encode is a `UserError` printed as its tag and message; the rest are the command's defect |
18
+ | Unknown path, method or name | a path no layer serves, or a method other than `POST` on a route: **404** | another HTTP method: an empty **405** with `Allow: POST, DELETE`, signed out the **401** of an endpoint with authentication; an unknown tool: a JSON-RPC error `-32602`, with **200** over HTTP | an rpc the layer does not serve ends its call with `Unknown request tag` | Effect's own | Effect CLI's own |
19
+ | Body that is not JSON, or not typed as JSON | not JSON: **400** `InvalidInput`; `Content-Type` missing or not JSON: **415**; both after authentication on a protected route | not JSON: a JSON-RPC error `-32700` with **200**; `Content-Type` missing or not JSON: an empty **415**; over stdio, a line that is not JSON goes unanswered | the protocol's own | — | a flag or `--input` that is not the JSON its field takes: `InvalidInput` |
20
+
21
+ - **Typed clients.** `ActionHttp.client`, `ActionRpc.client`, `ActionMcp.client` and `Action.client` fail with a declared or built-in error as its decoded value. Each drops undeclared fields as it encodes an input, since TypeScript lets a wider value through, and fails with `InvalidInput` for an input that does not encode, sending nothing. `Action.client` has no wire but passes input, success and failure through the same JSON codecs; an undeclared failure or a success that does not encode is its call's defect. Over MCP a verifier's declared error is the endpoint's HTTP answer: `ActionMcp.client` decodes it given the endpoint's `authentication`, and fails with an `McpCallError` without.
22
22
  - **An action without input** takes `{}` and nothing else, on every surface. Over HTTP, send the body `{}` typed as JSON: no body is a **400**, and no `Content-Type` a **415**.
23
23
  - **A media field**, `Action.Image`, is `{ "data": "<base64>", "mimeType": "image/png" }` inside the JSON on every surface but MCP, which lifts it out of `structuredContent` into a block of its own: [Return images and other media](ActionMcp.md#return-images-and-other-media).
24
+ - **A failure the action's errors reject**, undeclared or breaking a check, is its call's defect on every surface, beside the `SchemaError` saying why. A class error's checks are not rerun: one built with `disableChecks` is accepted, and fails to encode instead.
24
25
  - **Internal failures are never sent.** The cause of a defect goes to the log and, over HTTP, MCP and RPC, to the call's `ErrorReporter`s: those the server runs with, as provided around `HttpRouter.serve` or `Testing.layer`, win over those provided to one surface's layer, which only fill in what a request lacks.
25
26
  - **Messages for invalid input.** A message for input that does not decode names paths and what the schema expects, never a field's value. A path names every key it passes through, a record's keys and an undeclared key included, so never key a record by a secret such as an email or a token. A schema's own `message` annotation, or a filter's message, is sent as written, so never put the input in one.
26
27
  - **Earlier MCP revisions.** The MCP column is MCP 2026-07-28. A 2025-11-25 or 2025-06-18 client, over HTTP or stdio, gets `structuredContent` only for a success that is an object, and any other success as its text block alone, a string unquoted; a 2025-06-18 client gets invalid arguments as a JSON-RPC error instead of a result. Over HTTP such a client opens a session, which on an endpoint serving a protected tool only a signed-in caller does: it signs in for every request there, a public tool's call and `tools/list` included. Stdio authenticates nothing: the host provides the identity around `runStdio` ([Serve tools over stdio](ActionMcp.md#serve-tools-over-stdio)).
@@ -38,6 +39,6 @@ What the library does not do, where a reader might expect it to:
38
39
  - **Limit requests.** A rate limit is the handler's, failing with an error its action declares, or middleware's.
39
40
  - **Allow browsers across origins.** CORS for JSON routes is the host's, with `HttpRouter.cors`; an MCP endpoint refuses a browser `Origin` it does not list ([Allow browser clients](ActionMcp.md#allow-browser-clients)).
40
41
  - **Stream, upload or retry.** Actions are unary JSON calls. One decoded input, one success or one declared error. Every HTTP route is a `POST`, a read-only action's included; RPC needs a protocol speaking JSON.
41
- - **Expire MCP sessions, or serve MCP features other than tools.** A session a client of a 2025 revision opens over HTTP lasts until that client ends it or the process stops ([Serve tools over HTTP](ActionMcp.md#serve-tools-over-http)). Prompts and resources are Effect's native ones, given as `features` ([Add native prompts and resources](ActionMcp.md#add-native-prompts-and-resources)).
42
+ - **Expire MCP sessions, or serve MCP features other than tools.** A session a client of a 2025 revision opens over HTTP lasts until that client ends it or the process stops, and a `requestScoped` endpoint opens none ([Serve tools over HTTP](ActionMcp.md#serve-tools-over-http)). Prompts and resources are Effect's native ones, given as `features` ([Add native prompts and resources](ActionMcp.md#add-native-prompts-and-resources)).
42
43
  - **Mix identities.** An action names one identity service, and a remote surface one authentication descriptor.
43
44
  - **Harden a deployment.** Real token validation, request limits and error reporting are the application's. The examples mark each shortcut they take `DEMO ONLY`: [examples](../examples/README.md).
@@ -226,16 +226,20 @@ search for in the file.
226
226
 
227
227
  ### ActionMcp
228
228
 
229
- | Export or option | File |
230
- | ------------------------------------------------ | --------------------------------------------------- |
231
- | `layerHttp`, public and protected tools together | `src/routes.ts` (`mcp`) |
232
- | `layerHttp`: `authentication`, `instructions` | `src/routes.ts` (`mcp`) |
233
- | `layerHttp`: `actions`, one action left off | `src/routes.ts` (`mcp`), `src/Actions.ts` (`Tools`) |
234
- | `layerHttp`: `allowedOrigins` | `src/routes.ts` (`mcp`), `test/routes.test.ts` |
235
- | `layerHttp`: `features`, a resource and a prompt | `src/issues/guide.ts` |
236
- | CORS for a browser MCP client | `src/request-policy.ts` (`cors`) |
237
- | `runStdio`, the host naming the caller | `src/stdio.ts` |
238
- | An image block from a media field | `src/attachments/Attachments.ts` (`GetAttachment`) |
229
+ | Export or option | File |
230
+ | ------------------------------------------------ | ------------------------------------------------------------- |
231
+ | `layerHttp`, public and protected tools together | `src/routes.ts` (`mcp`) |
232
+ | `layerHttp`: `authentication`, `instructions` | `src/routes.ts` (`mcp`) |
233
+ | `layerHttp`: `actions`, one action left off | `src/routes.ts` (`mcp`), `src/Actions.ts` (`Tools`) |
234
+ | `layerHttp`: `allowedOrigins` | `src/routes.ts` (`mcp`), `test/routes.test.ts` |
235
+ | `layerHttp`: `features`, a resource and a prompt | `src/issues/guide.ts` |
236
+ | CORS for a browser MCP client | `src/request-policy.ts` (`cors`) |
237
+ | `runStdio`, the host naming the caller | `src/stdio.ts` |
238
+ | An image block from a media field | `src/attachments/Attachments.ts` (`GetAttachment`) |
239
+ | `client`: `transformClient` | `test/routes.test.ts` |
240
+ | `client`, decoding an image | `test/attachments/attachments.test.ts` |
241
+ | `client`: `authentication`, a verifier's error | `test/identity/identity.test.ts` |
242
+ | `request`: `headers`, `url` | `test/attachments/attachments.test.ts`, `test/routes.test.ts` |
239
243
 
240
244
  ### ActionRpc
241
245
 
@@ -271,22 +275,18 @@ search for in the file.
271
275
 
272
276
  ### Testing
273
277
 
274
- | Export or option | File |
275
- | ------------------------------------------------ | ------------------------------------------------------------- |
276
- | `layer(routes)` | `test/identity/identity.test.ts` |
277
- | `layer(routes)`, twice, over shared builders | `test/routes.test.ts` (`shared`) |
278
- | `layer(handler)`, over `HttpRouter.toWebHandler` | `test/request-policy.test.ts` |
279
- | `mcpClient`: `transformClient` | `test/routes.test.ts` |
280
- | `mcpClient`, decoding an image | `test/attachments/attachments.test.ts` |
281
- | `McpCallError` | `test/identity/identity.test.ts` |
282
- | `mcpRequest`: `headers`, `url` | `test/attachments/attachments.test.ts`, `test/routes.test.ts` |
283
- | RPC in memory, over Effect's HTTP protocol | `test/identity/session.test.ts` |
284
- | A service the test and the routes both read | `test/identity/session.test.ts` (`served`) |
285
- | A test replacing one service | `test/identity/identity.test.ts` (`servedWith`) |
286
- | `Action.client`, a caller per call | `test/issues/issues.test.ts` |
287
- | A scripted `LanguageModel` | `test/issues/assistant.test.ts`, `test/support/scripted.ts` |
288
- | A test replacing the model the routes are given | `test/assistant/assistant.test.ts` (`served`) |
289
- | An error's message asserted beside its fields | `test/issues/issues.test.ts` |
278
+ | Export or option | File |
279
+ | ------------------------------------------------ | ----------------------------------------------------------- |
280
+ | `layer(routes)` | `test/identity/identity.test.ts` |
281
+ | `layer(routes)`, twice, over shared builders | `test/routes.test.ts` (`shared`) |
282
+ | `layer(handler)`, over `HttpRouter.toWebHandler` | `test/request-policy.test.ts` |
283
+ | RPC in memory, over Effect's HTTP protocol | `test/identity/session.test.ts` |
284
+ | A service the test and the routes both read | `test/identity/session.test.ts` (`served`) |
285
+ | A test replacing one service | `test/identity/identity.test.ts` (`servedWith`) |
286
+ | `Action.client`, a caller per call | `test/issues/issues.test.ts` |
287
+ | A scripted `LanguageModel` | `test/issues/assistant.test.ts`, `test/support/scripted.ts` |
288
+ | A test replacing the model the routes are given | `test/assistant/assistant.test.ts` (`served`) |
289
+ | An error's message asserted beside its fields | `test/issues/issues.test.ts` |
290
290
 
291
291
  ## Not shown
292
292
 
@@ -4,6 +4,7 @@ import { Array, Effect, flow, Layer } from "effect";
4
4
  import { HttpClient, HttpClientRequest } from "effect/http";
5
5
  import * as Action from "@gjermundgaraba/effect-actions/Action";
6
6
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
7
8
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
8
9
  import { Http } from "../../src/Api.js";
9
10
  import { GetAttachment } from "../../src/attachments/Attachments.js";
@@ -42,7 +43,7 @@ describe("attachments, over HTTP and MCP", () => {
42
43
  Effect.gen(function* () {
43
44
  const attachment = yield* attached;
44
45
  const http = yield* ActionHttp.client(Http, as("ada", "acme"));
45
- const mcp = yield* Testing.mcpClient([GetAttachment], as("ada", "acme"));
46
+ const mcp = yield* ActionMcp.client([GetAttachment], as("ada", "acme"));
46
47
 
47
48
  expect(attachment).toMatchObject({ name: "crash.png", mimeType: "image/png", size: 68 });
48
49
 
@@ -56,7 +57,7 @@ describe("attachments, over HTTP and MCP", () => {
56
57
  // As sent: the tool's result holds the rest of the success as JSON, and the image as a
57
58
  // content block of its own, which a model receives as an image.
58
59
  const call = yield* HttpClient.execute(
59
- Testing.mcpRequest(
60
+ ActionMcp.request(
60
61
  "tools/call",
61
62
  { name: "getAttachment", arguments: { id: attachment.id } },
62
63
  { headers: { authorization: "Bearer ada", "x-workspace": "acme" } },
@@ -3,10 +3,11 @@ import { Array, Effect, flow, Layer } from "effect";
3
3
  import { HttpClient, HttpClientRequest } from "effect/http";
4
4
  import * as Action from "@gjermundgaraba/effect-actions/Action";
5
5
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
6
7
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
8
  import { Http } from "../../src/Api.js";
8
9
  import { Directory } from "../../src/identity/directory.js";
9
- import { IssuerUnavailable, QuotaExceeded, WhoAmI } from "../../src/identity/Identity.js";
10
+ import { IssuerUnavailable, Login, QuotaExceeded, WhoAmI } from "../../src/identity/Identity.js";
10
11
  import { memory } from "../../src/memory.js";
11
12
  import { publicRoutes } from "../../src/routes.js";
12
13
 
@@ -97,7 +98,7 @@ describe("a bearer token", () => {
97
98
 
98
99
  // A protected tool's call without a token: the endpoint's own 401, before any tool runs.
99
100
  const refused = yield* HttpClient.execute(
100
- Testing.mcpRequest("tools/call", { name: "whoAmI", arguments: {} }),
101
+ ActionMcp.request("tools/call", { name: "whoAmI", arguments: {} }),
101
102
  );
102
103
 
103
104
  expect(refused.status).toBe(401);
@@ -110,14 +111,20 @@ describe("a bearer token", () => {
110
111
  it.effect("fails with the verifier's declared error, a typed 503, when the issuer is down", () =>
111
112
  Effect.gen(function* () {
112
113
  const http = yield* ActionHttp.client(Http, as("ada", "acme"));
113
- const mcp = yield* Testing.mcpClient([WhoAmI], as("ada", "acme"));
114
+
115
+ // Over MCP it is the endpoint's HTTP answer, before any tool runs, not a tool's result: the
116
+ // client decodes it given the descriptor the endpoint serves with.
117
+ const mcp = yield* ActionMcp.client([WhoAmI], {
118
+ ...as("ada", "acme"),
119
+ authentication: Login,
120
+ });
114
121
 
115
122
  expect(yield* Effect.flip(http.whoAmI())).toEqual(
116
123
  new IssuerUnavailable({ retryAfterSeconds: 30 }),
117
124
  );
118
- // Over MCP it is the endpoint's HTTP answer, before any tool runs, not a tool's result:
119
- // the client fails with the error that holds such an answer.
120
- expect(yield* Effect.flip(mcp.whoAmI())).toBeInstanceOf(Testing.McpCallError);
125
+ expect(yield* Effect.flip(mcp.whoAmI())).toEqual(
126
+ new IssuerUnavailable({ retryAfterSeconds: 30 }),
127
+ );
121
128
  }).pipe(Effect.provide(servedWith(issuerDown))),
122
129
  );
123
130
  });
@@ -3,6 +3,7 @@ import { Effect, flow, Layer } from "effect";
3
3
  import { HttpClient, HttpClientRequest } from "effect/http";
4
4
  import * as Action from "@gjermundgaraba/effect-actions/Action";
5
5
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
6
7
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
8
  import { Tools } from "../src/Actions.js";
8
9
  import { AdminHttp, Http } from "../src/Api.js";
@@ -43,7 +44,7 @@ describe("the public port", () => {
43
44
 
44
45
  it.effect("lists the tools to a signed-out MCP client, the unlisted action left out", () =>
45
46
  Effect.gen(function* () {
46
- const listing = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
47
+ const listing = yield* HttpClient.execute(ActionMcp.request("tools/list"));
47
48
  const text = yield* listing.text;
48
49
 
49
50
  expect(listing.status).toBe(200);
@@ -55,7 +56,7 @@ describe("the public port", () => {
55
56
  it.effect("serves one store to both surfaces: an issue filed over HTTP is read as a tool", () =>
56
57
  Effect.gen(function* () {
57
58
  const http = yield* ActionHttp.client(Http, ada);
58
- const mcp = yield* Testing.mcpClient(Tools, ada);
59
+ const mcp = yield* ActionMcp.client(Tools, ada);
59
60
  const issue = yield* http.createIssue({ title: "Crash", body: "On save." });
60
61
 
61
62
  expect(yield* mcp.getIssue({ id: issue.id })).toEqual(issue);
@@ -66,7 +67,7 @@ describe("the public port", () => {
66
67
  Effect.gen(function* () {
67
68
  // The request policy lets both through: one is the request's own origin, one is listed.
68
69
  const from = (url: string, origin: string) =>
69
- HttpClient.execute(Testing.mcpRequest("tools/list", {}, { url, headers: { origin } }));
70
+ HttpClient.execute(ActionMcp.request("tools/list", {}, { url, headers: { origin } }));
70
71
 
71
72
  const listed = yield* from("/mcp", "http://localhost:6274");
72
73
  const sameHost = yield* from("http://127.0.0.1/mcp", "http://127.0.0.1");
@@ -7,6 +7,9 @@ identity of its own. A message naming no room is about the default one, `lobby`.
7
7
 
8
8
  Start here if a client keeps a connection open and calls often, or if the application already
9
9
  speaks Effect RPC. For a JSON API or MCP tools, start from [notes-service](../notes-service/).
10
+ To serve these same actions as JSON too, see
11
+ [ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs): kitchen-sink's `src/routes.ts` and
12
+ `src/server.ts` merge both.
10
13
  Actions are unary, so nothing streams: the page reads again, passing the last id it holds.
11
14
 
12
15
  ## Run it
@@ -28,10 +31,10 @@ Effect program, `connect(url)` for one that calls with promises. The tokens are
28
31
 
29
32
  ## When you copy this
30
33
 
31
- Three choices here show a feature. Decide each for your application before keeping it:
34
+ Four choices here show a feature. Decide each for your application before keeping it:
32
35
 
33
36
  - **`listRooms` is public** to show a public rpc beside protected ones. It tells a signed-out
34
- caller every room's name.
37
+ caller every room's name and how many messages it holds; a room appears once someone posts.
35
38
  - **A message naming no room is about `lobby`**, to show a default in request context. Yours may
36
39
  refuse instead.
37
40
  - **The room is a header, not an input**, because `authorize` sees the action and not its input:
@@ -39,6 +42,8 @@ Three choices here show a feature. Decide each for your application before keepi
39
42
  check belongs in the store, never in a helper each handler must remember to call: the store's
40
43
  method takes the member and refuses, as `MessageStore.remove` in `src/chat/store.ts` takes the
41
44
  author and refuses with `NotAuthor`.
45
+ - **`browser.ts` takes a message already gone as removed**, `MessageNotFound` included, since
46
+ that is what the page asked for. Yours may tell the user it was not there.
42
47
 
43
48
  ## Layout
44
49
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "@effect/vitest";
2
2
  import { ConfigProvider, Effect, Layer, Schema } from "effect";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
4
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
4
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
6
  import { fakeFarm, picture } from "../src/printers/fake.js";
6
7
  import { PrintFarm } from "../src/printers/farm.js";
@@ -59,7 +60,7 @@ describe("the tools, over MCP", () => {
59
60
  it.effect("lists every tool with the hints its contract states", () =>
60
61
  Effect.gen(function* () {
61
62
  // A raw request, for what no typed client shows: here, the listing itself.
62
- const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
63
+ const response = yield* HttpClient.execute(ActionMcp.request("tools/list"));
63
64
  const { result } = yield* HttpClientResponse.schemaBodyJson(Listed)(response);
64
65
 
65
66
  const hints = (readOnlyHint: boolean, destructiveHint: boolean, idempotentHint: boolean) => ({
@@ -97,7 +98,7 @@ describe("the tools, over MCP", () => {
97
98
  Effect.gen(function* () {
98
99
  // A client method per tool: it sends `tools/call` and decodes the result, the image
99
100
  // from its image block, as the handler returned it.
100
- const mcp = yield* Testing.mcpClient(Printers);
101
+ const mcp = yield* ActionMcp.client(Printers);
101
102
 
102
103
  expect(yield* mcp.get_camera_snapshot({ printer_id: "mk4-1" })).toEqual({
103
104
  printer: {
@@ -113,7 +114,7 @@ describe("the tools, over MCP", () => {
113
114
 
114
115
  it.effect("answers the farm's refusal as the tool's declared error, message and all", () =>
115
116
  Effect.gen(function* () {
116
- const mcp = yield* Testing.mcpClient(Printers);
117
+ const mcp = yield* ActionMcp.client(Printers);
117
118
  const error = yield* Effect.flip(mcp.get_camera_snapshot({ printer_id: "mini-1" }));
118
119
 
119
120
  expect(error).toBeInstanceOf(PrintFarmError);
@@ -124,7 +125,7 @@ describe("the tools, over MCP", () => {
124
125
  it.effect("accepts the browser origins the configuration lists, and no other", () =>
125
126
  Effect.gen(function* () {
126
127
  const from = (origin: string) =>
127
- HttpClient.execute(Testing.mcpRequest("tools/list", {}, { headers: { origin } }));
128
+ HttpClient.execute(ActionMcp.request("tools/list", {}, { headers: { origin } }));
128
129
 
129
130
  expect((yield* from("https://console.example.test")).status).toBe(200);
130
131
  expect((yield* from("https://wall.example.test")).status).toBe(200);
@@ -142,7 +143,7 @@ describe("the tools, over MCP", () => {
142
143
  expect(asked.headers["access-control-allow-headers"]).toContain("MCP-Protocol-Version");
143
144
 
144
145
  const listed = yield* HttpClient.execute(
145
- Testing.mcpRequest(
146
+ ActionMcp.request(
146
147
  "tools/list",
147
148
  {},
148
149
  { headers: { origin: "https://console.example.test" } },
@@ -170,7 +171,7 @@ describe("the tools, over MCP", () => {
170
171
  expect(asked.headers["access-control-allow-origin"]).toBe("https://console.example.test");
171
172
 
172
173
  const called = yield* HttpClient.execute(
173
- Testing.mcpRequest(
174
+ ActionMcp.request(
174
175
  "tools/list",
175
176
  {},
176
177
  { headers: { origin: "https://elsewhere.example.test" } },
@@ -25,6 +25,10 @@ node --import tsx examples/notes-service/src/cli.ts list-notes
25
25
  node --import tsx examples/notes-service/src/cli.ts export notes.md # a command written by hand
26
26
  node --import tsx examples/notes-service/src/cli.ts import notes.md # one importNotes call
27
27
 
28
+ # The stdio server, given one MCP 2026-07-28 request on stdin
29
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getStatus","arguments":{},"_meta":{"io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}' |
30
+ node --import tsx examples/notes-service/src/stdio.ts
31
+
28
32
  vp test examples/notes-service
29
33
  ```
30
34
 
@@ -35,7 +39,9 @@ claude mcp add --transport http notes http://127.0.0.1:3000/mcp --header "Author
35
39
  codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES_TOKEN
36
40
  ```
37
41
 
38
- `src/stdio.ts` is the other way in: a subprocess the host starts, with a store of its own.
42
+ `src/stdio.ts` is another way in: a subprocess the host starts, with a store of its own.
43
+ `src/worker.ts` serves the same routes as a `fetch` handler, for a serverless host such as
44
+ Cloudflare Workers ([ActionMcp.md](../../docs/ActionMcp.md#serve-tools-over-http)).
39
45
 
40
46
  The server reads `HOST` (default `127.0.0.1`), `PORT` (default 3000) and `PUBLIC_URL`, the
41
47
  address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
@@ -64,6 +70,7 @@ src/
64
70
  ├── Api.ts the HTTP binding of every domain's actions
65
71
  ├── routes.ts every surface of the host: /api, /mcp, /healthz, the OpenAPI document
66
72
  ├── server.ts entrypoint: the HTTP server
73
+ ├── worker.ts entrypoint: a fetch handler for a serverless host
67
74
  ├── stdio.ts entrypoint: MCP for a local agent
68
75
  ├── commands.ts the commands calling the server, one written by hand, and their connection
69
76
  ├── cli.ts entrypoint: the commands, given that connection
@@ -71,6 +78,7 @@ src/
71
78
  test/
72
79
  ├── notes/ in process, with Action.client
73
80
  ├── routes.test.ts over HTTP and MCP in memory, with Testing.layer
81
+ ├── worker.test.ts the worker's own fetch handler, with Testing.layer
74
82
  └── commands.test.ts the commands and their connection, against the same routes in memory
75
83
  ```
76
84
 
@@ -81,8 +89,9 @@ test/
81
89
  on the server, each named for its role, never the domain: `notes.ts` beside `Notes.ts` is one
82
90
  file on a disk that ignores case (TS1149).
83
91
  - **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
84
- picks the store and the HTTP server, `stdio.ts` names the local user, a test provides its own
85
- store. Nothing below an entrypoint knows which it got.
92
+ picks the store and the HTTP server, `worker.ts` the routes for a serverless host, `stdio.ts`
93
+ names the local user, a test provides its own store. Nothing below an entrypoint knows which
94
+ it got.
86
95
  - **In a monorepo** the contract files and `Api.ts` are the package the server, the CLI and the
87
96
  website all depend on; the lowercase files and `routes.ts` are the server.
88
97
 
@@ -99,6 +108,7 @@ test/
99
108
  | One implementation served over HTTP and MCP | `src/routes.ts` |
100
109
  | Config, a body limit and the host's own routes | `src/server.ts`, `src/routes.ts` |
101
110
  | The same actions over stdio, the host naming the caller | `src/stdio.ts` |
111
+ | A serverless entrypoint, the MCP endpoint `requestScoped` | `src/worker.ts`, `src/routes.ts` |
102
112
  | A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
103
113
  | A command written by hand beside the derived ones | `src/commands.ts` |
104
114
  | A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
@@ -83,7 +83,8 @@ export const UpdateNote = Action.make("updateNote", {
83
83
  readOnly: false,
84
84
  caller: CurrentUser,
85
85
  // No `destructiveHint`: an update replaces the body, so it is destructive, MCP's default for
86
- // a write. `createNote` and `importNotes`, which only add, say `false`.
86
+ // a write, as is any update of what is stored, a flag such as "read" included.
87
+ // `createNote` and `importNotes`, which only add, say `false`.
87
88
  });
88
89
 
89
90
  export const DeleteNote = Action.make("deleteNote", {
@@ -98,12 +99,13 @@ export const DeleteNote = Action.make("deleteNote", {
98
99
  // tools and the CLI then all have it, and the whole list is decoded, so checked, before the
99
100
  // handler writes any of it. A command of your own only reads the file and calls this once
100
101
  // (commands.ts). `importNotes` adds and removes nothing, so `destructiveHint` is `false`.
102
+ // Each note is `createNote`'s input: an action's `input` is a schema already, used as it is.
101
103
  export const ImportNotes = Action.make("importNotes", {
102
104
  description:
103
105
  "Create several notes at once, such as those `notes export` wrote. Every note is checked " +
104
106
  "before any is written: an import holding one that is not valid writes none.",
105
107
  input: {
106
- notes: Schema.Array(Schema.Struct({ title: Title, body: Body }))
108
+ notes: Schema.Array(CreateNote.input)
107
109
  .check(
108
110
  Schema.isMinLength(1, { message: "An import holds at least one note." }),
109
111
  Schema.isMaxLength(100, { message: "An import holds at most 100 notes." }),
@@ -17,17 +17,6 @@ const implementations = [status, notes, identity];
17
17
  // reads its body; `getStatus` answers anyone.
18
18
  const api = ActionHttp.layer(Http, implementations);
19
19
 
20
- // The same actions as MCP tools at `/mcp`. Listing the tools and calling `getStatus` need no
21
- // token, because `getStatus` is public: an endpoint of protected tools alone asks a token for
22
- // the listing too. Any other call answers 401 with where to sign in, and so does a client of
23
- // an MCP revision with sessions when it opens one, since a session may call any tool.
24
- const mcp = ActionMcp.layerHttp(implementations, {
25
- name: "notes-service",
26
- version: "1.0.0",
27
- instructions: "Notes belong to the signed-in user. Read a note before updating it.",
28
- authentication: Login,
29
- });
30
-
31
20
  // The host's own routes sit beside the actions' on the same router.
32
21
  const health = HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok"));
33
22
 
@@ -40,6 +29,27 @@ const openApi = HttpRouter.add(
40
29
  HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api)),
41
30
  );
42
31
 
43
- // What the host serves. It still requires `NoteStore`: the entrypoint chooses which one, and a
44
- // test chooses its own.
45
- export const routes = Layer.mergeAll(api, mcp, health, openApi).pipe(Layer.provide(authenticate));
32
+ // What the host serves: the JSON routes, the same actions as MCP tools at `/mcp`, and the host's
33
+ // own routes. Listing the tools and calling `getStatus` need no token, because `getStatus` is
34
+ // public: an endpoint of protected tools alone asks a token for the listing too. Any other call
35
+ // answers 401 with where to sign in, and so does a client of an MCP revision with sessions when
36
+ // it opens one, since a session may call any tool. It still requires `NoteStore`: the entrypoint
37
+ // chooses which one, and a test chooses its own. The entrypoint also chooses how the MCP
38
+ // endpoint runs: one server for every request, or one per request.
39
+ const serving = (requestScoped: boolean) =>
40
+ Layer.mergeAll(
41
+ api,
42
+ ActionMcp.layerHttp(implementations, {
43
+ name: "notes-service",
44
+ version: "1.0.0",
45
+ instructions: "Notes belong to the signed-in user. Read a note before updating it.",
46
+ authentication: Login,
47
+ requestScoped,
48
+ }),
49
+ health,
50
+ openApi,
51
+ ).pipe(Layer.provide(authenticate));
52
+
53
+ export const routes = serving(false);
54
+
55
+ export const requestScopedRoutes = serving(true);
@@ -0,0 +1,20 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter, HttpServer } from "effect/http";
3
+ import { NoteStore } from "./notes/store.js";
4
+ import { requestScopedRoutes } from "./routes.js";
5
+
6
+ // Entrypoint for a host that calls `fetch` per request and runs nothing between requests, such
7
+ // as Cloudflare Workers, which takes this default export. The handler is built once, when the
8
+ // module loads, as Effect's `HttpRouter.toWebHandler` asks on Workers: a build started inside
9
+ // `fetch` would belong to that request. Building these routes does no I/O, which Workers refuses
10
+ // outside a request. `toWebHandler` reads no `MaxBodySize`, so the host limits request bodies.
11
+ const app = HttpRouter.toWebHandler(
12
+ requestScopedRoutes.pipe(
13
+ // DEMO ONLY: each instance of the worker holds notes of its own, lost when it stops. A
14
+ // deployment provides a store every instance reaches.
15
+ Layer.provide(NoteStore.layerMemory),
16
+ Layer.provide(HttpServer.layerServices),
17
+ ),
18
+ );
19
+
20
+ export default { fetch: (request: Request) => app.handler(request) };