@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.
- package/dist/Action.d.mts +1 -1
- package/dist/ActionCli.d.mts +4 -6
- package/dist/ActionCli.mjs +11 -9
- package/dist/ActionHttp.mjs +18 -30
- package/dist/ActionMcp.d.mts +23 -7
- package/dist/ActionMcp.mjs +78 -90
- package/dist/ActionRpc.mjs +17 -29
- package/dist/ActionToolkit.mjs +26 -11
- package/dist/Authentication.mjs +1 -2
- package/dist/Testing.d.mts +6 -73
- package/dist/Testing.mjs +3 -102
- package/dist/authentication/provider.mjs +1 -6
- package/dist/authentication/refusal.mjs +14 -4
- package/dist/cli/commands.mjs +8 -8
- package/dist/contract/call.d.mts +2 -1
- package/dist/contract/call.mjs +14 -1
- package/dist/contract/errors.d.mts +2 -3
- package/dist/contract/errors.mjs +6 -12
- package/dist/contract/implementation.mjs +33 -5
- package/dist/contract/rules.d.mts +7 -0
- package/dist/contract/rules.mjs +50 -3
- package/dist/http/client.mjs +4 -7
- package/dist/mcp/client.d.mts +87 -0
- package/dist/mcp/client.mjs +122 -0
- package/dist/mcp/protocol.mjs +25 -37
- package/docs/Action.md +6 -5
- package/docs/ActionCli.md +4 -3
- package/docs/ActionMcp-failures.md +7 -2
- package/docs/ActionMcp.md +43 -46
- package/docs/ActionToolkit-failures.md +2 -1
- package/docs/ActionToolkit.md +2 -2
- package/docs/Authentication-failures.md +2 -2
- package/docs/Authentication.md +10 -5
- package/docs/CONTEXT.md +1 -1
- package/docs/Testing-failures.md +3 -3
- package/docs/Testing.md +16 -24
- package/docs/guarantees.md +15 -14
- package/examples/kitchen-sink/README.md +26 -26
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +3 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +13 -6
- package/examples/kitchen-sink/test/routes.test.ts +4 -3
- package/examples/live-rpc/README.md +7 -2
- package/examples/mcp-tools/test/routes.test.ts +7 -6
- package/examples/notes-service/README.md +13 -3
- package/examples/notes-service/src/notes/Notes.ts +4 -2
- package/examples/notes-service/src/routes.ts +24 -14
- package/examples/notes-service/src/worker.ts +20 -0
- package/examples/notes-service/test/commands.test.ts +2 -1
- package/examples/notes-service/test/routes.test.ts +6 -3
- package/examples/notes-service/test/worker.test.ts +34 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +2 -1
- package/package.json +1 -1
- package/dist/http/declared.mjs +0 -20
- package/dist/mcp/protocol.d.mts +0 -18
- package/dist/toolkit/tools.mjs +0 -29
package/docs/Testing-failures.md
CHANGED
|
@@ -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
|
|
20
|
-
|
|
|
21
|
-
| `layer(routes)`
|
|
22
|
-
| `layer(handler)`
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
142
|
-
`ActionHttp.client
|
|
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
|
-
`
|
|
152
|
-
`yield* HttpClient.execute(
|
|
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)
|
package/docs/guarantees.md
CHANGED
|
@@ -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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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))
|
|
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
|
|
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
|
|
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
|
|
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 | —
|
|
20
|
-
|
|
21
|
-
- **Typed clients.** `ActionHttp.client`, `ActionRpc.client`, `
|
|
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
|
-
|
|
|
280
|
-
|
|
|
281
|
-
|
|
|
282
|
-
| `
|
|
283
|
-
|
|
|
284
|
-
| A
|
|
285
|
-
|
|
|
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*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
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*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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*
|
|
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*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, `
|
|
85
|
-
store. Nothing below an entrypoint knows which
|
|
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
|
|
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(
|
|
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
|
|
44
|
-
//
|
|
45
|
-
|
|
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) };
|