@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,8 @@
1
+ import type { Member } from "./Identity.js";
2
+
3
+ // DEMO ONLY: the people who may sign in, each under a token that is their name. A real service
4
+ // has no such table: its verifier reads the member from a signed token.
5
+ export const members = {
6
+ ada: { id: "ada", rooms: ["lobby", "ops"] },
7
+ grace: { id: "grace", rooms: ["lobby"] },
8
+ } as const satisfies Readonly<Record<string, Member>>;
@@ -0,0 +1,23 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+ import { Login } from "./Identity.js";
5
+ import { members } from "./members.js";
6
+
7
+ const isToken = (token: string): token is keyof typeof members => Object.hasOwn(members, token);
8
+
9
+ // DEMO ONLY: a token is a member's name. A real verifier checks a signed token with your
10
+ // authorization server's library, its audience included. An unknown token is the built-in
11
+ // `Unauthenticated`, which every client decodes.
12
+ const verify = (token: Redacted.Redacted<string>) => {
13
+ const name = Redacted.value(token);
14
+
15
+ return isToken(name)
16
+ ? Effect.succeed(members[name])
17
+ : Effect.fail(new Action.Unauthenticated({ message: "Unknown demo token." }));
18
+ };
19
+
20
+ // Server only: `Login`'s provider. Over RPC it runs for every message of a protected rpc, not
21
+ // once for the connection, so a socket has no identity of its own: each call is whoever its
22
+ // token says. It publishes no OAuth discovery, so it needs no router and no configuration.
23
+ export const authenticate = Authentication.layer(Login, verify);
@@ -0,0 +1,22 @@
1
+ import { Layer } from "effect";
2
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
3
+ import { messages, rooms } from "./chat/handlers.js";
4
+ import { ResolveRoom, resolveRoom } from "./chat/room.js";
5
+ import { authenticate } from "./identity/verifier.js";
6
+ import { Rpc } from "./Rpc.js";
7
+
8
+ // The binding's rpcs on Effect's own RpcServer. A protected rpc authenticates every message
9
+ // with `Login`'s verifier, so one connection may carry several callers; `listRooms` needs no
10
+ // token.
11
+ //
12
+ // `middleware` runs around every rpc, inside authentication and before the input is decoded.
13
+ // `ResolveRoom` provides `CurrentRoom`, which the handlers and `authorize` read. Only something
14
+ // that runs per message can: `Layer.succeed(CurrentRoom, "lobby")` provided here would leave
15
+ // the layer still requiring it, in its type.
16
+ //
17
+ // It still requires three things, each chosen by whoever serves it. The protocol and its
18
+ // serialization: server.ts gives it a WebSocket, and a test Effect's HTTP protocol, which
19
+ // answers in memory. And `MessageStore`.
20
+ export const rpc = ActionRpc.layer(Rpc, [rooms, messages], { middleware: [ResolveRoom] }).pipe(
21
+ Layer.provide([resolveRoom, authenticate]),
22
+ );
@@ -0,0 +1,43 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
3
+ import { Config, Effect, Layer } from "effect";
4
+ import { HttpRouter } from "effect/http";
5
+ import { RpcSerialization, RpcServer } from "effect/rpc";
6
+ import { MessageStore } from "./chat/store.js";
7
+ import { rpc } from "./routes.js";
8
+
9
+ // Entrypoint: `node --import tsx examples/live-rpc/src/server.ts`.
10
+ // The composition root of the server. It chooses the protocol, the store and the port, and
11
+ // starts Node's HTTP server. It provides no caller and no room: those arrive with each message.
12
+
13
+ // The rpcs over a WebSocket at `/rpc`, the router's one route: Node's HTTP server answers the
14
+ // upgrade. `ActionRpc.layer` serves a protocol speaking JSON, and the client must speak the
15
+ // same one.
16
+ const socket = rpc.pipe(
17
+ Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
18
+ Layer.provide(RpcSerialization.layerJson),
19
+ );
20
+
21
+ const server = Layer.unwrap(
22
+ Effect.gen(function* () {
23
+ const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
24
+
25
+ return HttpRouter.serve(socket).pipe(
26
+ Layer.provide(
27
+ NodeHttpServer.layer(createServer, {
28
+ // This machine alone. A server others reach reads its host from config:
29
+ // `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
30
+ host: "127.0.0.1",
31
+ port,
32
+ // A message on a WebSocket is no request body, so `HttpServerRequest.MaxBodySize`
33
+ // does not bound it: Node's WebSocket server reads one of up to 100 MiB whole unless
34
+ // told otherwise. A chat message is short, and a larger one closes the socket (1009).
35
+ websocket: { maxPayload: 64 * 1024 },
36
+ }),
37
+ ),
38
+ Layer.provide(MessageStore.layerMemory),
39
+ );
40
+ }),
41
+ );
42
+
43
+ Layer.launch(server).pipe(NodeRuntime.runMain);
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, Layer } from "effect";
3
+ import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
+ import { chatOver, openChat } from "../src/browser.js";
7
+ import { MessageStore } from "../src/chat/store.js";
8
+ import { rpc } from "../src/routes.js";
9
+
10
+ // The page's client against the real rpcs, in memory. `openChat` needs a client protocol and
11
+ // does not care which: a page gives it `connection(url)`, a WebSocket, and this test Effect's
12
+ // HTTP protocol, answered by `Testing.layer`, which cannot upgrade a WebSocket.
13
+ const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
14
+ Layer.provide(RpcSerialization.layerJson),
15
+ Layer.provide(
16
+ Testing.layer(
17
+ rpc.pipe(
18
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
19
+ Layer.provide(RpcSerialization.layerJson),
20
+ ),
21
+ ),
22
+ ),
23
+ Layer.provide(MessageStore.layerMemory),
24
+ );
25
+
26
+ describe("the page's client", () => {
27
+ it.effect("posts and reads in the room it names, as the member its token names", () =>
28
+ Effect.gen(function* () {
29
+ const chat = yield* openChat;
30
+ const first = yield* chat.room("lobby", "ada").post("Deploying at noon.");
31
+ const second = yield* chat.room("lobby", "grace").post("Ready.");
32
+
33
+ yield* chat.room("ops", "ada").post("Disk is full.");
34
+
35
+ expect(first).toEqual({ id: 1, author: "ada", text: "Deploying at noon." });
36
+ expect(yield* chat.room("lobby", "grace").read(first.id)).toEqual({
37
+ access: "member",
38
+ messages: [second],
39
+ });
40
+ expect(yield* chat.rooms).toEqual([
41
+ { name: "lobby", messages: 2 },
42
+ { name: "ops", messages: 1 },
43
+ ]);
44
+ }).pipe(Effect.provide(served)),
45
+ );
46
+
47
+ it.effect("turns each declared error of a removal into what the page shows", () =>
48
+ Effect.gen(function* () {
49
+ const chat = yield* openChat;
50
+ const ada = chat.room("lobby", "ada");
51
+ const grace = chat.room("lobby", "grace");
52
+ const { id } = yield* ada.post("Oops.");
53
+
54
+ expect(yield* grace.remove(id)).toBe("not-yours");
55
+ expect(yield* grace.read(0)).toMatchObject({ messages: [{ id }] });
56
+ expect(yield* ada.remove(id)).toBe("removed");
57
+ expect(yield* grace.read(0)).toEqual({ access: "member", messages: [] });
58
+ expect(yield* ada.remove(id)).toBe("removed");
59
+ }).pipe(Effect.provide(served)),
60
+ );
61
+
62
+ it.effect("turns each refusal of a read into the access the page shows", () =>
63
+ Effect.gen(function* () {
64
+ const chat = yield* openChat;
65
+
66
+ yield* chat.room("ops", "ada").post("Disk is full.");
67
+
68
+ expect(yield* chat.room("ops", "grace").read(0)).toEqual({
69
+ access: "not-a-member",
70
+ messages: [],
71
+ });
72
+ expect(yield* chat.room("ops", "expired").read(0)).toEqual({
73
+ access: "signed-out",
74
+ messages: [],
75
+ });
76
+ }).pipe(Effect.provide(served)),
77
+ );
78
+
79
+ // `connect(url)` is this with a WebSocket. Each test's runtime builds its own layer, and so
80
+ // its own store.
81
+ it("answers a page that is not an Effect program with promises, on one connection", async () => {
82
+ const chat = chatOver(served);
83
+
84
+ const posted = await chat.room("lobby", "ada").post("Deploying at noon.");
85
+
86
+ expect(posted).toEqual({ id: 1, author: "ada", text: "Deploying at noon." });
87
+ expect(await chat.room("lobby", "grace").read(0)).toEqual({
88
+ access: "member",
89
+ messages: [posted],
90
+ });
91
+ expect(await chat.room("lobby", "grace").remove(posted.id)).toBe("not-yours");
92
+ expect(await chat.room("ops", "expired").read(0)).toEqual({
93
+ access: "signed-out",
94
+ messages: [],
95
+ });
96
+ expect(await chat.rooms()).toEqual([{ name: "lobby", messages: 1 }]);
97
+
98
+ // A failure `openChat` does not handle rejects: here, a text the contract refuses.
99
+ await expect(chat.room("lobby", "ada").post("")).rejects.toBeInstanceOf(Action.InvalidInput);
100
+
101
+ await chat.close();
102
+
103
+ await expect(chat.rooms()).rejects.toThrow();
104
+ });
105
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, flow } from "effect";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import { NotAuthor } from "../../src/chat/Chat.js";
5
+ import { messages } from "../../src/chat/handlers.js";
6
+ import { CurrentRoom } from "../../src/chat/room.js";
7
+ import { MessageStore } from "../../src/chat/store.js";
8
+ import { CurrentMember } from "../../src/identity/Identity.js";
9
+ import { members } from "../../src/identity/members.js";
10
+
11
+ // What the implementation does, with no transport: `Action.client` runs the same decoding,
12
+ // `authorize` and handler a server runs. No authentication and no middleware run here, so
13
+ // each call is given what they would provide: its caller and its room. Neither is provided
14
+ // around the client or beside the store: a request-time service belongs to one call.
15
+ const as = (member: keyof typeof members, room: string) =>
16
+ flow(
17
+ Effect.provideService(CurrentMember, members[member]),
18
+ Effect.provideService(CurrentRoom, room),
19
+ );
20
+
21
+ describe("messages, in process", () => {
22
+ it.effect("keeps each room's messages its own, numbered from 1", () =>
23
+ Effect.gen(function* () {
24
+ const client = yield* Action.client(messages);
25
+
26
+ yield* client.postMessage({ text: "Deploying at noon." }).pipe(as("ada", "lobby"));
27
+ yield* client.postMessage({ text: "Disk is full." }).pipe(as("ada", "ops"));
28
+ yield* client.postMessage({ text: "Ready." }).pipe(as("grace", "lobby"));
29
+
30
+ expect(yield* client.readMessages({ after: 1 }).pipe(as("grace", "lobby"))).toEqual({
31
+ messages: [{ id: 2, author: "grace", text: "Ready." }],
32
+ });
33
+ expect(yield* client.readMessages({ after: 0 }).pipe(as("ada", "ops"))).toEqual({
34
+ messages: [{ id: 1, author: "ada", text: "Disk is full." }],
35
+ });
36
+ }).pipe(Effect.provide(MessageStore.layerMemory)),
37
+ );
38
+
39
+ it.effect("lets only its author delete a message", () =>
40
+ Effect.gen(function* () {
41
+ const client = yield* Action.client(messages);
42
+ const { id } = yield* client.postMessage({ text: "Oops." }).pipe(as("ada", "lobby"));
43
+
44
+ expect(yield* Effect.flip(client.deleteMessage({ id }).pipe(as("grace", "lobby")))).toEqual(
45
+ new NotAuthor({ id, author: "ada" }),
46
+ );
47
+
48
+ yield* client.deleteMessage({ id }).pipe(as("ada", "lobby"));
49
+
50
+ expect(yield* client.readMessages({ after: 0 }).pipe(as("grace", "lobby"))).toEqual({
51
+ messages: [],
52
+ });
53
+ }).pipe(Effect.provide(MessageStore.layerMemory)),
54
+ );
55
+
56
+ it.effect("refuses a member a room they have not joined, before the handler runs", () =>
57
+ Effect.gen(function* () {
58
+ const client = yield* Action.client(messages);
59
+ const post = client.postMessage({ text: "Hello?" }).pipe(as("grace", "ops"));
60
+ const refused = yield* Effect.flip(post);
61
+
62
+ expect(refused).toBeInstanceOf(Action.Forbidden);
63
+ expect(refused.message).toBe("Not a member of ops.");
64
+ expect(yield* client.readMessages({ after: 0 }).pipe(as("ada", "ops"))).toEqual({
65
+ messages: [],
66
+ });
67
+ }).pipe(Effect.provide(MessageStore.layerMemory)),
68
+ );
69
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, Layer } from "effect";
3
+ import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
6
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
+ import { roomHeader } from "../src/chat/Chat.js";
8
+ import { MessageStore } from "../src/chat/store.js";
9
+ import { rpc } from "../src/routes.js";
10
+ import { Rpc } from "../src/Rpc.js";
11
+
12
+ // What the wire adds: the real rpcs, behind their real authentication and middleware,
13
+ // answering in memory. The server gives them a WebSocket, which `Testing.layer` cannot
14
+ // upgrade: it answers HTTP requests alone. So the test gives the same layer Effect's HTTP
15
+ // protocol, and its client posts to it. No port is opened.
16
+ const server = rpc.pipe(
17
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
18
+ Layer.provide(RpcSerialization.layerJson),
19
+ );
20
+
21
+ const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
22
+ Layer.provide(RpcSerialization.layerJson),
23
+ Layer.provide(Testing.layer(server)),
24
+ Layer.provide(MessageStore.layerMemory),
25
+ );
26
+
27
+ // Each call carries its caller's token, so one client serves every caller of a test.
28
+ const as = (token: string) => RpcClient.withHeaders({ authorization: `Bearer ${token}` });
29
+
30
+ const inRoom = (room: string) => RpcClient.withHeaders({ [roomHeader]: room });
31
+
32
+ describe("the chat, over RPC", () => {
33
+ it.effect("answers the public rpc without a token and refuses the rest", () =>
34
+ Effect.gen(function* () {
35
+ const client = yield* ActionRpc.client(Rpc);
36
+ const signedOut = yield* Effect.flip(client.readMessages({ after: 0 }));
37
+ const stranger = yield* Effect.flip(client.readMessages({ after: 0 }).pipe(as("mallory")));
38
+
39
+ expect(yield* client.listRooms()).toEqual({ rooms: [] });
40
+ expect(signedOut).toBeInstanceOf(Action.Unauthenticated);
41
+ expect(signedOut.message).toBe("A bearer token is required.");
42
+ expect(stranger).toBeInstanceOf(Action.Unauthenticated);
43
+ expect(stranger.message).toBe("Unknown demo token.");
44
+ }).pipe(Effect.provide(served)),
45
+ );
46
+
47
+ it.effect("authenticates each call by its own token: two callers on one client", () =>
48
+ Effect.gen(function* () {
49
+ const client = yield* ActionRpc.client(Rpc);
50
+
51
+ yield* client.postMessage({ text: "Deploying at noon." }).pipe(as("ada"));
52
+ yield* client.postMessage({ text: "Ready." }).pipe(as("grace"));
53
+
54
+ expect(yield* client.readMessages({ after: 0 }).pipe(as("grace"))).toEqual({
55
+ messages: [
56
+ { id: 1, author: "ada", text: "Deploying at noon." },
57
+ { id: 2, author: "grace", text: "Ready." },
58
+ ],
59
+ });
60
+ }).pipe(Effect.provide(served)),
61
+ );
62
+
63
+ it.effect("gives each call the room its header names, and the lobby when it names none", () =>
64
+ Effect.gen(function* () {
65
+ const client = yield* ActionRpc.client(Rpc);
66
+
67
+ yield* client.postMessage({ text: "Disk is full." }).pipe(as("ada"), inRoom("ops"));
68
+ yield* client.postMessage({ text: "Hello." }).pipe(as("ada"));
69
+
70
+ expect(yield* client.readMessages({ after: 0 }).pipe(as("ada"), inRoom("ops"))).toEqual({
71
+ messages: [{ id: 1, author: "ada", text: "Disk is full." }],
72
+ });
73
+ expect(yield* client.readMessages({ after: 0 }).pipe(as("ada"), inRoom("lobby"))).toEqual({
74
+ messages: [{ id: 1, author: "ada", text: "Hello." }],
75
+ });
76
+ }).pipe(Effect.provide(served)),
77
+ );
78
+
79
+ it.effect("refuses a member a room they have not joined, naming it", () =>
80
+ Effect.gen(function* () {
81
+ const client = yield* ActionRpc.client(Rpc);
82
+ const read = client.readMessages({ after: 0 }).pipe(as("grace"), inRoom("ops"));
83
+ const refused = yield* Effect.flip(read);
84
+
85
+ expect(refused).toBeInstanceOf(Action.Forbidden);
86
+ expect(refused.message).toBe("Not a member of ops.");
87
+ }).pipe(Effect.provide(served)),
88
+ );
89
+
90
+ it.effect("serves a native RpcGroup: Effect's own client calls the same rpcs", () =>
91
+ Effect.gen(function* () {
92
+ // `Rpc.group` is what Effect's RPC tooling takes. Its methods take the payload as sent,
93
+ // `{}` included; `ActionRpc.client` is the one to write an application with.
94
+ const native = yield* RpcClient.make(Rpc.group);
95
+
96
+ expect(yield* native.listRooms({})).toEqual({ rooms: [] });
97
+ }).pipe(Effect.provide(served)),
98
+ );
99
+ });
@@ -0,0 +1,75 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer } from "@effect/platform-node";
3
+ import { describe, expect, it } from "@effect/vitest";
4
+ import { Effect, Layer } from "effect";
5
+ import { HttpRouter, HttpServer } from "effect/http";
6
+ import { RpcSerialization, RpcServer } from "effect/rpc";
7
+ import { connect } from "../src/browser.js";
8
+ import { MessageStore } from "../src/chat/store.js";
9
+ import { rpc } from "../src/routes.js";
10
+
11
+ // What the socket adds, which no in-memory test reaches: the upgrade, the page's `connect(url)`
12
+ // and the bound on a message. The rpcs are served as server.ts serves them, over a WebSocket
13
+ // at `/rpc` on Node's HTTP server, on port 0: the system picks a free one, so tests run side
14
+ // by side. The server closes with the test's scope.
15
+ const socket = rpc.pipe(
16
+ Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
17
+ Layer.provide(RpcSerialization.layerJson),
18
+ );
19
+
20
+ // server.ts allows 64 KiB. A bound this small is passed by a message the contract accepts.
21
+ const maxPayload = 256;
22
+
23
+ const listening = HttpRouter.serve(socket, { disableLogger: true, disableListenLog: true }).pipe(
24
+ // `provideMerge` keeps `HttpServer` for the test, which reads the port from it.
25
+ Layer.provideMerge(
26
+ NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 0, websocket: { maxPayload } }),
27
+ ),
28
+ Layer.provide(MessageStore.layerMemory),
29
+ );
30
+
31
+ // The page's client, on Node's global `WebSocket`. Each test closes it with `Effect.scoped`,
32
+ // before the server stops: a server that is stopping waits for its open sockets.
33
+ const chat = Effect.gen(function* () {
34
+ const server = yield* HttpServer.HttpServer;
35
+ const url = HttpServer.formatAddress(server.address).replace("http", "ws");
36
+
37
+ return yield* Effect.acquireRelease(
38
+ Effect.sync(() => connect(`${url}/rpc`)),
39
+ (page) => Effect.promise(() => page.close()),
40
+ );
41
+ });
42
+
43
+ describe("the chat, over a WebSocket", () => {
44
+ it.effect("answers the page's client on a listening port, each call as its token names", () =>
45
+ Effect.gen(function* () {
46
+ const page = yield* chat;
47
+
48
+ const posted = yield* Effect.promise(() =>
49
+ page.room("lobby", "ada").post("Deploying at noon."),
50
+ );
51
+
52
+ expect(posted).toEqual({ id: 1, author: "ada", text: "Deploying at noon." });
53
+ expect(yield* Effect.promise(() => page.room("lobby", "grace").read(0))).toEqual({
54
+ access: "member",
55
+ messages: [posted],
56
+ });
57
+ }).pipe(Effect.scoped, Effect.provide(listening)),
58
+ );
59
+
60
+ it.effect("closes the socket on a message over the bound, failing the call that sent it", () =>
61
+ Effect.gen(function* () {
62
+ const page = yield* chat;
63
+ const ada = page.room("lobby", "ada");
64
+
65
+ yield* Effect.promise(() => ada.post("Short."));
66
+
67
+ // 400 characters are a text the contract accepts and a message over 256 bytes. The server
68
+ // reads none of it: it closes the socket with 1009, "message too big", and the call fails
69
+ // with Effect's `RpcClientError`, its reason the socket's close, which no action declares.
70
+ yield* Effect.promise(() =>
71
+ expect(ada.post("x".repeat(400))).rejects.toMatchObject({ reason: { code: 1009 } }),
72
+ );
73
+ }).pipe(Effect.scoped, Effect.provide(listening)),
74
+ );
75
+ });
@@ -0,0 +1,136 @@
1
+ # mcp-tools
2
+
3
+ An MCP server whose tools wrap one upstream HTTP API: here, a 3D print farm's. It has no
4
+ authentication of its own. It signs in to the farm once, with its own account, and whoever can
5
+ reach it may call every tool, so it runs as a local subprocess of an agent or on a network only
6
+ trusted clients are on.
7
+
8
+ Start here if you are putting an existing API in front of a model. For tools that belong to the
9
+ signed-in user, start from [notes-service](../notes-service/).
10
+
11
+ ## Run it
12
+
13
+ From the repository root, after `vp install` (from an installed package, see
14
+ [Run one](../README.md#run-one)):
15
+
16
+ ```sh
17
+ export FARM_URL=https://farm.example.test FARM_USER=demo FARM_PASSWORD=demo
18
+
19
+ node --import tsx examples/mcp-tools/src/server.ts # MCP at 127.0.0.1:3000/mcp, PORT to change
20
+ curl http://127.0.0.1:3000/healthz # ok
21
+
22
+ # The subprocess, given one request on stdin. An agent's host starts it the same way.
23
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"shell","version":"0"}}}' |
24
+ node --import tsx examples/mcp-tools/src/stdio.ts
25
+
26
+ vp test examples/mcp-tools
27
+ ```
28
+
29
+ The farm is a fake, answered in memory, so any URL, user and password work: replace it with a
30
+ real HTTP client before deploying anything. `ALLOWED_ORIGINS` lists the browser origins the HTTP
31
+ endpoint accepts and answers with CORS headers, comma-separated; `HOST` is the interface it listens on.
32
+
33
+ ## Connect a client
34
+
35
+ Claude Code, to the running HTTP server or starting the subprocess itself; add one of the two:
36
+
37
+ ```sh
38
+ claude mcp add --transport http print-farm http://127.0.0.1:3000/mcp
39
+
40
+ claude mcp add print-farm \
41
+ -e FARM_URL=https://farm.example.test -e FARM_USER=demo -e FARM_PASSWORD=demo \
42
+ -- "$PWD/node_modules/.bin/tsx" "$PWD/examples/mcp-tools/src/stdio.ts"
43
+ ```
44
+
45
+ `node --import tsx` finds `tsx` from the directory the process starts in, which the host
46
+ chooses. So the line names both files by their full path, which `$PWD` gives it when run from
47
+ the repository root; `tsx` runs the `node` on the host's `PATH`. Claude Desktop takes the same
48
+ command as an entry of `mcpServers` in its `claude_desktop_config.json`, the paths written out:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "print-farm": {
54
+ "command": "/path/to/effect-actions/node_modules/.bin/tsx",
55
+ "args": ["/path/to/effect-actions/examples/mcp-tools/src/stdio.ts"],
56
+ "env": {
57
+ "FARM_URL": "https://farm.example.test",
58
+ "FARM_USER": "demo",
59
+ "FARM_PASSWORD": "demo"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ By hand, a tool is called with one request, which states its protocol revision, its method
67
+ and its tool's name twice: in the body and in the three `MCP-` headers.
68
+
69
+ ```sh
70
+ curl http://127.0.0.1:3000/mcp \
71
+ -H 'Content-Type: application/json' \
72
+ -H 'Accept: application/json, text/event-stream' \
73
+ -H 'MCP-Protocol-Version: 2026-07-28' \
74
+ -H 'MCP-Method: tools/call' \
75
+ -H 'MCP-Name: list_printers' \
76
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_printers","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
77
+ ```
78
+
79
+ It answers `{"jsonrpc":"2.0","id":1,"result":{…,"structuredContent":{"printers":[…]},"isError":false}}`.
80
+ Every header is needed: without `Accept` naming both types the answer is an empty 406, without
81
+ `Content-Type` a 415, and without an `MCP-` header, or with one that differs from the body, a
82
+ 400 saying which. `tools/list` takes the same request with that method, no `MCP-Name` header,
83
+ and no `name` or `arguments`.
84
+
85
+ ## Layout
86
+
87
+ ```text
88
+ src/
89
+ ├── printers/
90
+ │ ├── Printers.ts contract: the printer, the one declared error, the five tools
91
+ │ ├── farm.ts PrintFarm, the upstream API as a service, and its Layer
92
+ │ ├── fake.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
93
+ │ └── handlers.ts Action.implement: tool to service
94
+ ├── instructions.ts the server's name, version and its instructions for the model
95
+ ├── routes.ts the HTTP surface: /mcp and /healthz
96
+ ├── server.ts entrypoint: MCP over HTTP
97
+ └── stdio.ts entrypoint: MCP for a local agent
98
+ test/
99
+ ├── printers/ in process, with Action.client, over fake upstreams
100
+ └── routes.test.ts over MCP in memory, with Testing.layer
101
+ ```
102
+
103
+ - **The upstream is a service, built once.** `PrintFarm.layer` reads its URL and credentials
104
+ from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in while it is
105
+ built. The handlers get it already signed in and only delegate.
106
+ - **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
107
+ another shape and one that never comes each leave `farm.ts` as a `PrintFarmError` with a
108
+ message a model can act on. No handler catches anything.
109
+ - **What a model sends is checked by the contract.** A printer id becomes part of the farm's
110
+ URL, so its schema admits only what is safe there, and anything else is refused before a
111
+ handler runs.
112
+ - **One implementation, two transports.** `server.ts` and `stdio.ts` serve the same `printers`
113
+ with the same `serverInfo`; they differ in what they provide around it.
114
+ - **The contract is the tool.** A tool's name, description, input schema, hints and title all
115
+ come from `Printers.ts`.
116
+
117
+ ## Where to look
118
+
119
+ | To see | Read |
120
+ | --------------------------------------------------------------- | -------------------------------- |
121
+ | Public tools described for a model, with field descriptions | `src/printers/Printers.ts` |
122
+ | `readOnly`, `title`, `destructiveHint` and `idempotentHint` | `src/printers/Printers.ts` |
123
+ | One declared error shared by every tool | `src/printers/Printers.ts` |
124
+ | An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
125
+ | A tool answering an image beside other fields | `src/printers/Printers.ts` |
126
+ | An upstream client reading `Config`, signing in once | `src/printers/farm.ts` |
127
+ | An upstream's failures mapped to the declared error | `src/printers/farm.ts` |
128
+ | A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
129
+ | Server `instructions`, shared by both transports | `src/instructions.ts` |
130
+ | Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
131
+ | A request body limit, and where an unauthenticated server binds | `src/server.ts` |
132
+ | A stdio server whose logs go to stderr | `src/stdio.ts` |
133
+ | A fake upstream provided as a Layer, to a test or an entrypoint | `src/printers/fake.ts` |
134
+ | Testing tools and each upstream failure's message in process | `test/printers/printers.test.ts` |
135
+ | Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
136
+ | Testing CORS for one configured origin and for several | `test/routes.test.ts` |
@@ -0,0 +1,12 @@
1
+ // What the server tells a client of itself, on either transport: `server.ts` and `stdio.ts`
2
+ // both give it. `instructions` are for the model, as the tools' descriptions are: what the
3
+ // tools are for together, and what to be careful with. A client shows them to the model once,
4
+ // when it connects.
5
+ export const serverInfo = {
6
+ name: "print-farm",
7
+ version: "1.0.0",
8
+ instructions:
9
+ "Tools for one 3D print farm. Printer ids come from list_printers; call it first. When a " +
10
+ "print may have failed, look at it with get_camera_snapshot and pause it with set_paused " +
11
+ "while deciding. cancel_print cannot be undone: ask the user before cancelling.",
12
+ };