@gjermundgaraba/effect-actions 0.10.0 → 0.12.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 (214) hide show
  1. package/README.md +103 -43
  2. package/dist/Action.d.mts +31 -118
  3. package/dist/Action.mjs +15 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +25 -31
  9. package/dist/ActionMcp.mjs +140 -74
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +5 -8
  13. package/dist/ActionToolkit.mjs +3 -3
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +32 -25
  17. package/dist/Testing.mjs +45 -30
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +17 -0
  38. package/dist/mcp/protocol.mjs +149 -0
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +206 -424
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -292
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -281
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +229 -193
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -117
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +142 -392
  59. package/docs/CONTEXT.md +39 -23
  60. package/docs/README.md +71 -35
  61. package/docs/Testing-failures.md +20 -0
  62. package/docs/Testing.md +186 -151
  63. package/docs/guarantees.md +35 -95
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +88 -87
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +317 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +75 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +136 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +119 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +30 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +108 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +89 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +91 -0
  183. package/examples/notes-service/src/notes/handlers.ts +33 -0
  184. package/examples/notes-service/src/notes/store.ts +73 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +134 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +32 -14
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -11
  211. package/dist/internal/mcp.mjs +0 -52
  212. package/dist/internal/refusal.mjs +0 -67
  213. package/dist/internal/schema-errors.mjs +0 -20
  214. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,119 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+
4
+ export const Printer = Schema.Struct({
5
+ id: Schema.String,
6
+ name: Schema.String,
7
+ state: Schema.Literals(["idle", "printing", "paused", "offline"]),
8
+ job: Schema.NullOr(
9
+ Schema.Struct({
10
+ file: Schema.String,
11
+ progress: Schema.Finite.annotate({ description: "Percent done, 0 to 100." }),
12
+ }),
13
+ ),
14
+ });
15
+
16
+ export type Printer = typeof Printer.Type;
17
+
18
+ // The one error every tool declares: the farm could not do what was asked. A model reads its
19
+ // `message` and decides what to do next, so the message says what went wrong in plain words.
20
+ // A handler failing with an error its action does not declare does not compile.
21
+ export class PrintFarmError extends Schema.TaggedError<PrintFarmError>()("PrintFarmError", {
22
+ message: Schema.String,
23
+ }) {}
24
+
25
+ // A field's description is part of the tool's input schema, which is all a model knows of it.
26
+ // The pattern is there for safety: an id becomes a segment of the farm's URL, and it comes
27
+ // from a model. An id that does not match is refused as invalid input, on every surface,
28
+ // before a handler runs, so `..` or `a/b` never reaches the farm. The `u` flag is what puts the
29
+ // pattern in the tool's schema, where the model reads it.
30
+ const PrinterId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u)).annotate({
31
+ description: "A printer's id, as list_printers gives it, such as mk4-1.",
32
+ });
33
+
34
+ // What every tool here shares. `caller: Action.Anyone` makes an action public: this server
35
+ // authenticates nobody, so it is reachable by whoever can reach the process.
36
+ const tool = { caller: Action.Anyone, error: PrintFarmError } as const;
37
+
38
+ // The names are snake_case because this server is tools only, and that is the convention MCP
39
+ // tool names usually follow. An application whose actions are also typed client methods or CLI
40
+ // commands names them in camelCase, as notes-service does.
41
+ //
42
+ // A description is written for the model that picks the tool: what it does, when to call it,
43
+ // and what it answers. `readOnly` is sent as the tool's read-only hint; `mcp` holds the other
44
+ // hints and the title a client shows its user.
45
+ export const ListPrinters = Action.make("list_printers", {
46
+ ...tool,
47
+ description:
48
+ "List every printer of the farm: its id, name, state (idle, printing, paused or offline) " +
49
+ "and, when it has a job, the file being printed and its progress. Call this first: every " +
50
+ "other tool takes a printer id from this list.",
51
+ success: { printers: Schema.Array(Printer) },
52
+ readOnly: true,
53
+ mcp: { title: "List printers" },
54
+ });
55
+
56
+ // `image` is an `Action.Image` beside an ordinary field. Over MCP it is sent as an image block
57
+ // the model can look at, after the JSON of `{ printer }`.
58
+ export const GetCameraSnapshot = Action.make("get_camera_snapshot", {
59
+ ...tool,
60
+ description:
61
+ "Take a picture with a printer's camera, to judge a print by eye before pausing or " +
62
+ "cancelling it. Answers with the printer's state at that moment and the picture. An " +
63
+ "offline printer has no picture.",
64
+ input: { printer_id: PrinterId },
65
+ success: { printer: Printer, image: Action.Image },
66
+ readOnly: true,
67
+ mcp: { title: "Camera snapshot" },
68
+ });
69
+
70
+ // A write that adds something and removes nothing says so: MCP assumes a write is destructive
71
+ // unless `destructiveHint` is false. A second call is refused, so it is not idempotent.
72
+ export const StartPrint = Action.make("start_print", {
73
+ ...tool,
74
+ description:
75
+ "Start printing a file on an idle printer. The file is one already uploaded to the farm, " +
76
+ "named as the farm stores it. Refused when the printer is not idle.",
77
+ input: {
78
+ printer_id: PrinterId,
79
+ file: Schema.String.annotate({ description: "A G-code file's name, such as bracket.gcode." }),
80
+ },
81
+ success: Printer,
82
+ readOnly: false,
83
+ mcp: { title: "Start a print", destructiveHint: false },
84
+ });
85
+
86
+ // Idempotent: the same call again leaves the printer as the first left it.
87
+ export const SetPaused = Action.make("set_paused", {
88
+ ...tool,
89
+ description:
90
+ "Pause a printer's job, or resume a paused one. A paused job keeps its progress, so " +
91
+ "pausing is the safe move while deciding what to do with a print that looks wrong.",
92
+ input: {
93
+ printer_id: PrinterId,
94
+ paused: Schema.Boolean.annotate({ description: "true to pause the job, false to resume it." }),
95
+ },
96
+ success: Printer,
97
+ readOnly: false,
98
+ mcp: { title: "Pause or resume a print", destructiveHint: false, idempotentHint: true },
99
+ });
100
+
101
+ // Destructive, which is MCP's default for a write, so no `destructiveHint` is stated.
102
+ export const CancelPrint = Action.make("cancel_print", {
103
+ ...tool,
104
+ description:
105
+ "Cancel a printer's job for good. The print is abandoned and cannot be resumed, only " +
106
+ "started again from the beginning. A printer without a job is left as it is.",
107
+ input: { printer_id: PrinterId },
108
+ success: Printer,
109
+ readOnly: false,
110
+ mcp: { title: "Cancel a print", idempotentHint: true },
111
+ });
112
+
113
+ export const Printers = [
114
+ ListPrinters,
115
+ GetCameraSnapshot,
116
+ StartPrint,
117
+ SetPaused,
118
+ CancelPrint,
119
+ ] as const;
@@ -0,0 +1,115 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { Effect, Layer, Option, Schema } from "effect";
3
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
4
+ import type { Printer } from "./Printers.js";
5
+
6
+ // DEMO ONLY: the farm's HTTP API answered in memory, so the example and its tests run without
7
+ // a farm. It is an `HttpClient`, so `PrintFarm.layer` sends it the requests it would send over
8
+ // the network. A real application has no such file: its entrypoints provide a real client,
9
+ // `FetchHttpClient.layer` from `effect/http`.
10
+
11
+ // A one-pixel PNG stands in for a camera's picture.
12
+ export const picture = Uint8Array.from(
13
+ Buffer.from(
14
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
15
+ "base64",
16
+ ),
17
+ );
18
+
19
+ const token = "demo-session";
20
+
21
+ const Login = Schema.fromJsonString(
22
+ Schema.Struct({ user: Schema.NonEmptyString, password: Schema.NonEmptyString }),
23
+ );
24
+
25
+ const Job = Schema.fromJsonString(Schema.Struct({ file: Schema.NonEmptyString }));
26
+
27
+ const refuse = (status: number, error: string) => Response.json({ error }, { status });
28
+
29
+ export const fakeFarm = Layer.sync(HttpClient.HttpClient, () => {
30
+ const printers = new Map<string, Printer>([
31
+ [
32
+ "mk4-1",
33
+ {
34
+ id: "mk4-1",
35
+ name: "MK4 by the door",
36
+ state: "printing",
37
+ job: { file: "bracket.gcode", progress: 62 },
38
+ },
39
+ ],
40
+ ["mk4-2", { id: "mk4-2", name: "MK4 by the window", state: "idle", job: null }],
41
+ ["mini-1", { id: "mini-1", name: "Mini on the shelf", state: "offline", job: null }],
42
+ ]);
43
+
44
+ const answer = (
45
+ method: string,
46
+ path: string,
47
+ authorization: string | undefined,
48
+ body: string,
49
+ ): Response => {
50
+ if (method === "POST" && path === "/api/login") {
51
+ return Option.match(Schema.decodeUnknownOption(Login)(body), {
52
+ onNone: () => refuse(400, "Give a user and a password."),
53
+ onSome: () => Response.json({ token }),
54
+ });
55
+ }
56
+
57
+ if (authorization !== `Bearer ${token}`) return refuse(401, "Sign in first.");
58
+
59
+ if (method === "GET" && path === "/api/printers") {
60
+ return Response.json({ printers: Array.from(printers.values()) });
61
+ }
62
+
63
+ const [id = "", action = ""] = path.replace("/api/printers/", "").split("/");
64
+ const printer = printers.get(id);
65
+
66
+ if (printer === undefined) return refuse(404, `No printer has the id ${id}.`);
67
+
68
+ const becomes = (state: Printer["state"], job: Printer["job"]) => {
69
+ const changed = { ...printer, state, job };
70
+
71
+ printers.set(printer.id, changed);
72
+
73
+ return Response.json(changed);
74
+ };
75
+
76
+ if (printer.state === "offline" && action !== "") {
77
+ return refuse(409, `${printer.name} is offline.`);
78
+ }
79
+
80
+ switch (`${method} ${action}`) {
81
+ case "GET ":
82
+ return Response.json(printer);
83
+ case "GET camera":
84
+ return new Response(picture, { headers: { "content-type": "image/png" } });
85
+ case "POST job":
86
+ return printer.job === null
87
+ ? Option.match(Schema.decodeUnknownOption(Job)(body), {
88
+ onNone: () => refuse(400, "Name the file to print."),
89
+ onSome: ({ file }) => becomes("printing", { file, progress: 0 }),
90
+ })
91
+ : refuse(409, `${printer.name} is busy with ${printer.job.file}.`);
92
+ case "POST pause":
93
+ case "POST resume":
94
+ return printer.job === null
95
+ ? refuse(409, `${printer.name} has no job.`)
96
+ : becomes(action === "pause" ? "paused" : "printing", printer.job);
97
+ case "DELETE job":
98
+ return becomes("idle", null);
99
+ default:
100
+ return refuse(404, `No such route: ${method} ${path}.`);
101
+ }
102
+ };
103
+
104
+ return HttpClient.make((request, url) =>
105
+ Effect.gen(function* () {
106
+ const sent = yield* Effect.orDie(HttpClientRequest.toWeb(request));
107
+ const body = yield* Effect.promise(() => sent.text());
108
+
109
+ return HttpClientResponse.fromWeb(
110
+ request,
111
+ answer(request.method, url.pathname, request.headers["authorization"], body),
112
+ );
113
+ }),
114
+ );
115
+ });
@@ -0,0 +1,136 @@
1
+ import { Config, Context, Effect, Layer, Redacted, Schema } from "effect";
2
+ import {
3
+ HttpBody,
4
+ HttpClient,
5
+ type HttpClientError,
6
+ HttpClientRequest,
7
+ HttpClientResponse,
8
+ } from "effect/http";
9
+ import type * as Action from "@gjermundgaraba/effect-actions/Action";
10
+ import { Printer, PrintFarmError } from "./Printers.js";
11
+
12
+ const fail = (message: string) => Effect.fail(new PrintFarmError({ message }));
13
+
14
+ const unreadable = "The print farm sent an answer this server could not read.";
15
+
16
+ // The farm answers a request it refuses with a status that is not 2xx and `{ "error": "..." }`.
17
+ // Its reason is passed on to the model, which can act on "The printer is busy with a job".
18
+ const refuseUnlessOk = HttpClientResponse.matchStatus({
19
+ "2xx": (response) => Effect.succeed(response),
20
+ orElse: (response) =>
21
+ HttpClientResponse.schemaBodyJson(Schema.Struct({ error: Schema.String }))(response).pipe(
22
+ Effect.matchEffect({
23
+ onFailure: () => fail(`The print farm refused the request (HTTP ${response.status}).`),
24
+ onSuccess: ({ error }) => fail(`The print farm refused: ${error}`),
25
+ }),
26
+ ),
27
+ });
28
+
29
+ // Every other way a call to the farm fails, as the same declared error: no connection, an
30
+ // answer of another shape than expected, or none in time. Nothing of the HTTP client's own
31
+ // errors leaves this file, so no tool can fail with something its contract does not declare.
32
+ const asPrintFarmError = <A>(
33
+ effect: Effect.Effect<A, PrintFarmError | HttpClientError.HttpClientError | Schema.SchemaError>,
34
+ ) =>
35
+ effect.pipe(
36
+ Effect.catchTag("HttpClientError", ({ response }) =>
37
+ fail(response === undefined ? "The print farm could not be reached." : unreadable),
38
+ ),
39
+ Effect.catchTag("SchemaError", () => fail(unreadable)),
40
+ Effect.timeoutOrElse({
41
+ duration: "30 seconds",
42
+ orElse: () => fail("The print farm did not answer in time."),
43
+ }),
44
+ );
45
+
46
+ const asImage = (response: HttpClientResponse.HttpClientResponse) =>
47
+ Effect.gen(function* () {
48
+ const mimeType = response.headers["content-type"];
49
+
50
+ if (mimeType === undefined || !mimeType.startsWith("image/")) return yield* fail(unreadable);
51
+
52
+ return { data: new Uint8Array(yield* response.arrayBuffer), mimeType };
53
+ });
54
+
55
+ /**
56
+ * The print farm's HTTP API, the one upstream of this server. Its methods require nothing and
57
+ * fail only with the error the tools declare: the layer has captured the HTTP client, the
58
+ * farm's URL and the session.
59
+ */
60
+ export class PrintFarm extends Context.Service<
61
+ PrintFarm,
62
+ {
63
+ readonly printers: Effect.Effect<ReadonlyArray<Printer>, PrintFarmError>;
64
+ readonly printer: (id: string) => Effect.Effect<Printer, PrintFarmError>;
65
+ readonly snapshot: (id: string) => Effect.Effect<typeof Action.Image.Type, PrintFarmError>;
66
+ readonly start: (id: string, file: string) => Effect.Effect<Printer, PrintFarmError>;
67
+ readonly setPaused: (id: string, paused: boolean) => Effect.Effect<Printer, PrintFarmError>;
68
+ readonly cancel: (id: string) => Effect.Effect<Printer, PrintFarmError>;
69
+ }
70
+ >()("mcp-tools/PrintFarm") {
71
+ // Built once, when the process starts. It requires an `HttpClient` and reads its settings
72
+ // from `Config`: the entrypoint decides which client, and the environment says which farm.
73
+ // Not `fetch`: as a service, a test replaces the network alone and opens no port
74
+ // (test/printers/printers.test.ts).
75
+ static readonly layer = Layer.effect(
76
+ PrintFarm,
77
+ Effect.gen(function* () {
78
+ const { url, user, password } = yield* Config.all({
79
+ url: Config.URL("FARM_URL"),
80
+ user: Config.NonEmptyString("FARM_USER"),
81
+ password: Config.Redacted("FARM_PASSWORD"),
82
+ });
83
+
84
+ const http = (yield* HttpClient.HttpClient).pipe(
85
+ HttpClient.mapRequest(HttpClientRequest.prependUrl(url.href)),
86
+ HttpClient.transformResponse(Effect.flatMap(refuseUnlessOk)),
87
+ );
88
+
89
+ // Signs in once, here, and every call below reuses the token, kept `Redacted` so no log
90
+ // prints it. A farm that refuses the sign-in stops the process at startup, with its
91
+ // reason, before any tool is listed. A real client also signs in again when a call
92
+ // answers 401, as tokens expire.
93
+ const session = yield* http
94
+ .post("/api/login", {
95
+ body: HttpBody.jsonUnsafe({ user, password: Redacted.value(password) }),
96
+ })
97
+ .pipe(
98
+ Effect.flatMap(
99
+ HttpClientResponse.schemaBodyJson(
100
+ Schema.Struct({ token: Schema.RedactedFromValue(Schema.String) }),
101
+ ),
102
+ ),
103
+ asPrintFarmError,
104
+ );
105
+
106
+ yield* Effect.logInfo(`Signed in to the print farm at ${url.host} as ${user}.`);
107
+
108
+ const api = HttpClient.mapRequest(http, HttpClientRequest.bearerToken(session.token));
109
+
110
+ // An id is written into the path as it is: the contract admits only letters, digits,
111
+ // `_` and `-`, so it is always one segment. Encoding would not make other text safe:
112
+ // `encodeURIComponent` leaves `..` alone, and a URL parser then walks up the path.
113
+ const at = (id: string, rest = "") => `/api/printers/${id}${rest}`;
114
+
115
+ const printer = (answer: ReturnType<typeof api.get>) =>
116
+ answer.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Printer)), asPrintFarmError);
117
+
118
+ return PrintFarm.of({
119
+ printers: api.get("/api/printers").pipe(
120
+ Effect.flatMap(
121
+ HttpClientResponse.schemaBodyJson(Schema.Struct({ printers: Schema.Array(Printer) })),
122
+ ),
123
+ Effect.map(({ printers }) => printers),
124
+ asPrintFarmError,
125
+ ),
126
+ printer: (id) => printer(api.get(at(id))),
127
+ snapshot: (id) =>
128
+ api.get(at(id, "/camera")).pipe(Effect.flatMap(asImage), asPrintFarmError),
129
+ start: (id, file) =>
130
+ printer(api.post(at(id, "/job"), { body: HttpBody.jsonUnsafe({ file }) })),
131
+ setPaused: (id, paused) => printer(api.post(at(id, paused ? "/pause" : "/resume"))),
132
+ cancel: (id) => printer(api.del(at(id, "/job"))),
133
+ });
134
+ }),
135
+ );
136
+ }
@@ -0,0 +1,29 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { PrintFarm } from "./farm.js";
4
+ import { Printers } from "./Printers.js";
5
+
6
+ // The builder runs once, when the server starts, whichever transport serves the tools: it is
7
+ // where the handlers get the farm, already signed in. Each handler is a step from a tool's
8
+ // input to the service and back to the tool's success. None handles a failure: the service
9
+ // fails only with `PrintFarmError`, which every tool declares, so it reaches the model as is.
10
+ // Public actions take no `authorize`.
11
+ export const printers = Action.implement(
12
+ Printers,
13
+ Effect.gen(function* () {
14
+ const farm = yield* PrintFarm;
15
+
16
+ return {
17
+ list_printers: () => Effect.map(farm.printers, (all) => ({ printers: all })),
18
+ // Returned as `{ data, mimeType }` like any other field: the MCP surface makes the block.
19
+ get_camera_snapshot: ({ printer_id }) =>
20
+ Effect.all(
21
+ { printer: farm.printer(printer_id), image: farm.snapshot(printer_id) },
22
+ { concurrency: "unbounded" },
23
+ ),
24
+ start_print: ({ printer_id, file }) => farm.start(printer_id, file),
25
+ set_paused: ({ printer_id, paused }) => farm.setPaused(printer_id, paused),
26
+ cancel_print: ({ printer_id }) => farm.cancel(printer_id),
27
+ };
28
+ }),
29
+ );
@@ -0,0 +1,52 @@
1
+ import { Config, Effect, Layer, Schema } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
4
+ import { serverInfo } from "./instructions.js";
5
+ import { printers } from "./printers/handlers.js";
6
+
7
+ // The tools at `/mcp` and the host's own route beside them. It is a Layer built from `Config`
8
+ // because the origins are known only where the server is deployed.
9
+ export const routes = Layer.unwrap(
10
+ Effect.gen(function* () {
11
+ // A browser sends its page's `Origin`, and the endpoint refuses every origin not listed
12
+ // here, so a web page a user happens to visit cannot call the tools. Other MCP clients
13
+ // send none and pass. `ALLOWED_ORIGINS=https://a.example,https://b.example`; none by default.
14
+ const allowedOrigins = yield* Config.Array(Schema.String, "ALLOWED_ORIGINS").pipe(
15
+ Config.withDefault([]),
16
+ );
17
+
18
+ // The Origin check only refuses. A browser also needs CORS: an answer to its preflight, and
19
+ // headers on the endpoint's answers. `HttpRouter.cors` is global to the router and, given
20
+ // no origin, allows every one, so it is added only where an origin is configured. The
21
+ // methods are the endpoint's, the headers those an MCP client sends and must be able to
22
+ // read; an endpoint with authentication adds `Authorization` and exposes `WWW-Authenticate`.
23
+ //
24
+ // Given several origins it names the request's own in `Access-Control-Allow-Origin` when
25
+ // it is listed, and sends no such header otherwise. Given exactly one it compares nothing:
26
+ // every answer names that origin, a preflight from elsewhere included. Both refuse a page
27
+ // elsewhere, whose browser allows a call only where the header names the page's own origin,
28
+ // and the Origin check above has answered its request 403 either way. So a test of one
29
+ // configured origin asserts which origin is named, never that the header is absent.
30
+ const cors =
31
+ allowedOrigins.length === 0
32
+ ? Layer.empty
33
+ : HttpRouter.cors({
34
+ allowedOrigins,
35
+ allowedMethods: ["POST", "DELETE"],
36
+ allowedHeaders: [
37
+ "Content-Type",
38
+ "MCP-Protocol-Version",
39
+ "MCP-Method",
40
+ "MCP-Name",
41
+ "Mcp-Session-Id",
42
+ ],
43
+ exposedHeaders: ["MCP-Protocol-Version", "Mcp-Session-Id"],
44
+ });
45
+
46
+ return Layer.mergeAll(
47
+ // No `authentication`: every tool is public, so the endpoint authenticates nothing.
48
+ ActionMcp.layerHttp(printers, { ...serverInfo, allowedOrigins }),
49
+ HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok")),
50
+ ).pipe(Layer.provide(cors));
51
+ }),
52
+ );
@@ -0,0 +1,30 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
3
+ import { ByteSize, Config, Effect, Layer } from "effect";
4
+ import { HttpRouter, HttpServerRequest } from "effect/http";
5
+ import { fakeFarm } from "./printers/fake.js";
6
+ import { PrintFarm } from "./printers/farm.js";
7
+ import { routes } from "./routes.js";
8
+
9
+ // Entrypoint: `node --import tsx examples/mcp-tools/src/server.ts`, with FARM_URL, FARM_USER
10
+ // and FARM_PASSWORD set. MCP over HTTP, for clients that connect to a URL.
11
+ // Nothing here authenticates a caller, so where it listens is its access control: the loopback
12
+ // interface by default, or a network only trusted clients are on.
13
+ const server = Layer.unwrap(
14
+ Effect.gen(function* () {
15
+ const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
16
+ const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
17
+
18
+ return HttpRouter.serve(routes).pipe(
19
+ Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
20
+ // Request bodies have no size limit unless the host sets one. A tool call is a few
21
+ // short fields, so anything larger is not one.
22
+ Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.kibibytes(64))),
23
+ // DEMO ONLY: `fakeFarm` answers the farm's API in memory. A real server provides
24
+ // `FetchHttpClient.layer` in its place, and `PrintFarm.layer` calls the farm at FARM_URL.
25
+ Layer.provide(PrintFarm.layer.pipe(Layer.provide(fakeFarm))),
26
+ );
27
+ }),
28
+ );
29
+
30
+ Layer.launch(server).pipe(NodeRuntime.runMain);
@@ -0,0 +1,26 @@
1
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node";
2
+ import { Effect, Layer } from "effect";
3
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
4
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
5
+ import { serverInfo } from "./instructions.js";
6
+ import { fakeFarm } from "./printers/fake.js";
7
+ import { PrintFarm } from "./printers/farm.js";
8
+ import { printers } from "./printers/handlers.js";
9
+
10
+ // Entrypoint: an MCP server a local agent starts as a subprocess, with FARM_URL, FARM_USER and
11
+ // FARM_PASSWORD in its environment. The README has the line that adds it to Claude Code and
12
+ // its entry in Claude Desktop's configuration.
13
+ // The same implementation and the same server information as the HTTP server, and no port:
14
+ // the agent's host starts the process, writes requests to its stdin, and closes it to stop it.
15
+ ActionMcp.runStdio(printers, serverInfo).pipe(
16
+ Effect.provide([
17
+ NodeStdio.layer,
18
+ // DEMO ONLY: `fakeFarm` answers the farm's API in memory. A real server provides
19
+ // `FetchHttpClient.layer` in its place.
20
+ PrintFarm.layer.pipe(Layer.provide(fakeFarm)),
21
+ ]),
22
+ // stdout carries the protocol alone. Applied last, this sends every log to stderr, the
23
+ // sign-in line `PrintFarm.layer` logs included, and a startup failure too.
24
+ ActionCli.logToStderr,
25
+ NodeRuntime.runMain,
26
+ );