@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,189 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { ConfigProvider, Effect, Fiber, Layer } from "effect";
3
+ import {
4
+ HttpClient,
5
+ HttpClientError,
6
+ type HttpClientRequest,
7
+ HttpClientResponse,
8
+ } from "effect/http";
9
+ import { TestClock } from "effect/testing";
10
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
11
+ import { fakeFarm } from "../../src/printers/fake.js";
12
+ import { PrintFarm } from "../../src/printers/farm.js";
13
+ import { printers } from "../../src/printers/handlers.js";
14
+ import { PrintFarmError } from "../../src/printers/Printers.js";
15
+
16
+ // What the tools do, with no transport: `Action.client` runs the same decoding and the same
17
+ // handlers a server runs. A public action has no caller, so a call needs none provided.
18
+
19
+ // The real `PrintFarm` over an upstream of the test's: its requests, its sign-in and its error
20
+ // mapping all run, and only the network is replaced. Its settings come from a Layer too.
21
+ const farmOver = (upstream: Layer.Layer<HttpClient.HttpClient>) =>
22
+ PrintFarm.layer.pipe(
23
+ Layer.provide(upstream),
24
+ Layer.provide(
25
+ ConfigProvider.layer(
26
+ ConfigProvider.fromEnvRecord({
27
+ FARM_URL: "https://farm.example.test",
28
+ FARM_USER: "agent",
29
+ FARM_PASSWORD: "secret",
30
+ }),
31
+ ),
32
+ ),
33
+ );
34
+
35
+ // The fake farm, noting each request the service sends it. A request is noted as the service
36
+ // wrote it, before its client prepends FARM_URL, so the paths are relative.
37
+ const noting = (sent: Array<string>) =>
38
+ Layer.effect(
39
+ HttpClient.HttpClient,
40
+ Effect.map(
41
+ HttpClient.HttpClient,
42
+ HttpClient.tapRequest((request) =>
43
+ Effect.sync(() => sent.push(`${request.method} ${request.url}`)),
44
+ ),
45
+ ),
46
+ ).pipe(Layer.provide(fakeFarm));
47
+
48
+ // A farm that lets the service sign in, then answers every other request with `answer`.
49
+ const farmThat = (
50
+ answer: (
51
+ request: HttpClientRequest.HttpClientRequest,
52
+ ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
53
+ ) =>
54
+ Layer.succeed(
55
+ HttpClient.HttpClient,
56
+ HttpClient.make((request, url) =>
57
+ url.pathname === "/api/login"
58
+ ? Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ token: "session" })))
59
+ : answer(request),
60
+ ),
61
+ );
62
+
63
+ const door = { id: "mk4-1", name: "MK4 by the door" };
64
+
65
+ describe("printers, in process", () => {
66
+ it.effect("pauses, resumes and cancels a job, each call safe to repeat", () =>
67
+ Effect.gen(function* () {
68
+ const client = yield* Action.client(printers);
69
+ const pause = client.set_paused({ printer_id: "mk4-1", paused: true });
70
+ const cancel = client.cancel_print({ printer_id: "mk4-1" });
71
+ const job = { file: "bracket.gcode", progress: 62 };
72
+
73
+ expect(yield* pause).toEqual({ ...door, state: "paused", job });
74
+ expect(yield* pause).toEqual({ ...door, state: "paused", job });
75
+ expect(yield* client.set_paused({ printer_id: "mk4-1", paused: false })).toEqual({
76
+ ...door,
77
+ state: "printing",
78
+ job,
79
+ });
80
+ expect(yield* cancel).toEqual({ ...door, state: "idle", job: null });
81
+ expect(yield* cancel).toEqual({ ...door, state: "idle", job: null });
82
+ }).pipe(Effect.provide(farmOver(fakeFarm))),
83
+ );
84
+
85
+ it.effect("signs in once at startup, however many tools are called", () => {
86
+ const sent: Array<string> = [];
87
+
88
+ return Effect.gen(function* () {
89
+ const client = yield* Action.client(printers);
90
+
91
+ yield* client.list_printers();
92
+ yield* client.set_paused({ printer_id: "mk4-1", paused: true });
93
+
94
+ expect(sent).toEqual([
95
+ "POST /api/login",
96
+ "GET /api/printers",
97
+ "POST /api/printers/mk4-1/pause",
98
+ ]);
99
+ }).pipe(Effect.provide(farmOver(noting(sent))));
100
+ });
101
+
102
+ it.effect("refuses an id that could leave its place in the farm's URL, sending nothing", () => {
103
+ const sent: Array<string> = [];
104
+
105
+ return Effect.gen(function* () {
106
+ const client = yield* Action.client(printers);
107
+
108
+ for (const printer_id of ["..", "mk4-1/../..", "mk4-1?force=true", ""]) {
109
+ const refused = yield* Effect.flip(client.cancel_print({ printer_id }));
110
+
111
+ expect(refused).toBeInstanceOf(Action.InvalidInput);
112
+ }
113
+
114
+ expect(sent).toEqual(["POST /api/login"]);
115
+ }).pipe(Effect.provide(farmOver(noting(sent))));
116
+ });
117
+
118
+ // The message is all a model gets of a failure, so each test asserts it. `toEqual` with an
119
+ // expected error would not: an error's `message` is not among the properties it compares.
120
+ it.effect("fails with the declared error, carrying the farm's own reason", () =>
121
+ Effect.gen(function* () {
122
+ const client = yield* Action.client(printers);
123
+ const busy = yield* Effect.flip(client.start_print({ printer_id: "mk4-1", file: "a.gcode" }));
124
+ const unknown = yield* Effect.flip(client.get_camera_snapshot({ printer_id: "mk9" }));
125
+
126
+ expect(busy).toBeInstanceOf(PrintFarmError);
127
+ expect(busy.message).toBe(
128
+ "The print farm refused: MK4 by the door is busy with bracket.gcode.",
129
+ );
130
+ expect(unknown).toBeInstanceOf(PrintFarmError);
131
+ expect(unknown.message).toBe("The print farm refused: No printer has the id mk9.");
132
+ }).pipe(Effect.provide(farmOver(fakeFarm))),
133
+ );
134
+
135
+ it.effect("says so when the farm cannot be reached", () =>
136
+ Effect.gen(function* () {
137
+ const client = yield* Action.client(printers);
138
+ const error = yield* Effect.flip(client.list_printers());
139
+
140
+ expect(error).toBeInstanceOf(PrintFarmError);
141
+ expect(error.message).toBe("The print farm could not be reached.");
142
+ }).pipe(
143
+ Effect.provide(
144
+ farmOver(
145
+ farmThat((request) =>
146
+ Effect.fail(
147
+ new HttpClientError.HttpClientError({
148
+ reason: new HttpClientError.TransportError({ request }),
149
+ }),
150
+ ),
151
+ ),
152
+ ),
153
+ ),
154
+ ),
155
+ );
156
+
157
+ it.effect("says so when the farm answers in a shape the service does not know", () =>
158
+ Effect.gen(function* () {
159
+ const client = yield* Action.client(printers);
160
+ const error = yield* Effect.flip(client.list_printers());
161
+
162
+ expect(error).toBeInstanceOf(PrintFarmError);
163
+ expect(error.message).toBe("The print farm sent an answer this server could not read.");
164
+ }).pipe(
165
+ Effect.provide(
166
+ farmOver(
167
+ farmThat((request) =>
168
+ Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ rows: [] }))),
169
+ ),
170
+ ),
171
+ ),
172
+ ),
173
+ );
174
+
175
+ // `it.effect` runs on a test clock, so the thirty seconds pass when the test says so.
176
+ it.effect("gives up on a farm that does not answer", () =>
177
+ Effect.gen(function* () {
178
+ const client = yield* Action.client(printers);
179
+ const call = yield* Effect.forkChild(Effect.flip(client.list_printers()));
180
+
181
+ yield* TestClock.adjust("30 seconds");
182
+
183
+ const error = yield* Fiber.join(call);
184
+
185
+ expect(error).toBeInstanceOf(PrintFarmError);
186
+ expect(error.message).toBe("The print farm did not answer in time.");
187
+ }).pipe(Effect.provide(farmOver(farmThat(() => Effect.never)))),
188
+ );
189
+ });
@@ -0,0 +1,202 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { ConfigProvider, Effect, Layer, Schema } from "effect";
3
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { fakeFarm, picture } from "../src/printers/fake.js";
6
+ import { PrintFarm } from "../src/printers/farm.js";
7
+ import { PrintFarmError, Printers } from "../src/printers/Printers.js";
8
+ import { routes } from "../src/routes.js";
9
+
10
+ // What the MCP surface adds: the tools as listed, their results as sent, the origin check.
11
+ // The real routes answer in memory, and no port is opened.
12
+
13
+ // What the entrypoint reads from the environment, given to the test as a Layer.
14
+ const farm = { FARM_URL: "https://farm.example.test", FARM_USER: "agent", FARM_PASSWORD: "secret" };
15
+
16
+ // `Testing.layer` gives the test an `HttpClient` of its own, which reaches the routes. The
17
+ // fake farm is another `HttpClient`, provided to `PrintFarm.layer` alone.
18
+ const servedWith = (environment: Record<string, string>) =>
19
+ Testing.layer(routes).pipe(
20
+ Layer.provide(PrintFarm.layer.pipe(Layer.provide(fakeFarm))),
21
+ Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
22
+ );
23
+
24
+ const served = servedWith({
25
+ ...farm,
26
+ ALLOWED_ORIGINS: "https://console.example.test,https://wall.example.test",
27
+ });
28
+
29
+ // A browser's preflight: what it asks before a cross-origin `POST` with an MCP client's headers.
30
+ const preflight = (origin: string) =>
31
+ HttpClient.execute(
32
+ HttpClientRequest.make("OPTIONS")("/mcp", {
33
+ headers: {
34
+ origin,
35
+ "access-control-request-method": "POST",
36
+ "access-control-request-headers": "content-type, mcp-protocol-version",
37
+ },
38
+ }),
39
+ );
40
+
41
+ // One tool of a `tools/list` answer, as far as this test reads it.
42
+ const Listed = Schema.Struct({
43
+ result: Schema.Struct({
44
+ tools: Schema.Array(
45
+ Schema.Struct({
46
+ name: Schema.String,
47
+ annotations: Schema.Struct({
48
+ title: Schema.String,
49
+ readOnlyHint: Schema.Boolean,
50
+ destructiveHint: Schema.Boolean,
51
+ idempotentHint: Schema.Boolean,
52
+ }),
53
+ }),
54
+ ),
55
+ }),
56
+ });
57
+
58
+ describe("the tools, over MCP", () => {
59
+ it.effect("lists every tool with the hints its contract states", () =>
60
+ Effect.gen(function* () {
61
+ // A raw request, for what no typed client shows: here, the listing itself.
62
+ const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
63
+ const { result } = yield* HttpClientResponse.schemaBodyJson(Listed)(response);
64
+
65
+ const hints = (readOnlyHint: boolean, destructiveHint: boolean, idempotentHint: boolean) => ({
66
+ readOnlyHint,
67
+ destructiveHint,
68
+ idempotentHint,
69
+ });
70
+
71
+ expect(result.tools).toEqual([
72
+ {
73
+ name: "list_printers",
74
+ annotations: { title: "List printers", ...hints(true, false, false) },
75
+ },
76
+ {
77
+ name: "get_camera_snapshot",
78
+ annotations: { title: "Camera snapshot", ...hints(true, false, false) },
79
+ },
80
+ {
81
+ name: "start_print",
82
+ annotations: { title: "Start a print", ...hints(false, false, false) },
83
+ },
84
+ {
85
+ name: "set_paused",
86
+ annotations: { title: "Pause or resume a print", ...hints(false, false, true) },
87
+ },
88
+ {
89
+ name: "cancel_print",
90
+ annotations: { title: "Cancel a print", ...hints(false, true, true) },
91
+ },
92
+ ]);
93
+ }).pipe(Effect.provide(served)),
94
+ );
95
+
96
+ it.effect("answers a camera's picture as an image beside the printer", () =>
97
+ Effect.gen(function* () {
98
+ // A client method per tool: it sends `tools/call` and decodes the result, the image
99
+ // from its image block, as the handler returned it.
100
+ const mcp = yield* Testing.mcpClient(Printers);
101
+
102
+ expect(yield* mcp.get_camera_snapshot({ printer_id: "mk4-1" })).toEqual({
103
+ printer: {
104
+ id: "mk4-1",
105
+ name: "MK4 by the door",
106
+ state: "printing",
107
+ job: { file: "bracket.gcode", progress: 62 },
108
+ },
109
+ image: { data: picture, mimeType: "image/png" },
110
+ });
111
+ }).pipe(Effect.provide(served)),
112
+ );
113
+
114
+ it.effect("answers the farm's refusal as the tool's declared error, message and all", () =>
115
+ Effect.gen(function* () {
116
+ const mcp = yield* Testing.mcpClient(Printers);
117
+ const error = yield* Effect.flip(mcp.get_camera_snapshot({ printer_id: "mini-1" }));
118
+
119
+ expect(error).toBeInstanceOf(PrintFarmError);
120
+ expect(error.message).toBe("The print farm refused: Mini on the shelf is offline.");
121
+ }).pipe(Effect.provide(served)),
122
+ );
123
+
124
+ it.effect("accepts the browser origins the configuration lists, and no other", () =>
125
+ Effect.gen(function* () {
126
+ const from = (origin: string) =>
127
+ HttpClient.execute(Testing.mcpRequest("tools/list", {}, { headers: { origin } }));
128
+
129
+ expect((yield* from("https://console.example.test")).status).toBe(200);
130
+ expect((yield* from("https://wall.example.test")).status).toBe(200);
131
+ expect((yield* from("https://elsewhere.example.test")).status).toBe(403);
132
+ }).pipe(Effect.provide(served)),
133
+ );
134
+
135
+ it.effect("answers a listed origin's CORS preflight, and its headers on a tool's answer", () =>
136
+ Effect.gen(function* () {
137
+ const asked = yield* preflight("https://console.example.test");
138
+
139
+ expect(asked.status).toBe(204);
140
+ expect(asked.headers["access-control-allow-origin"]).toBe("https://console.example.test");
141
+ expect(asked.headers["access-control-allow-methods"]).toBe("POST, DELETE");
142
+ expect(asked.headers["access-control-allow-headers"]).toContain("MCP-Protocol-Version");
143
+
144
+ const listed = yield* HttpClient.execute(
145
+ Testing.mcpRequest(
146
+ "tools/list",
147
+ {},
148
+ { headers: { origin: "https://console.example.test" } },
149
+ ),
150
+ );
151
+
152
+ expect(listed.headers["access-control-allow-origin"]).toBe("https://console.example.test");
153
+ expect(listed.headers["access-control-expose-headers"]).toContain("Mcp-Session-Id");
154
+
155
+ // Two origins are configured, so each request's own is compared with the list.
156
+ expect((yield* preflight("https://elsewhere.example.test")).headers).not.toHaveProperty(
157
+ "access-control-allow-origin",
158
+ );
159
+ }).pipe(Effect.provide(served)),
160
+ );
161
+
162
+ it.effect("names its one configured origin to every preflight, and still refuses any other", () =>
163
+ Effect.gen(function* () {
164
+ // With exactly one origin, Effect's CORS compares nothing: the header is that origin
165
+ // whoever asks. A page elsewhere is still refused, twice: its browser reads an origin
166
+ // that is not its own, and the endpoint answers its request 403.
167
+ const asked = yield* preflight("https://elsewhere.example.test");
168
+
169
+ expect(asked.status).toBe(204);
170
+ expect(asked.headers["access-control-allow-origin"]).toBe("https://console.example.test");
171
+
172
+ const called = yield* HttpClient.execute(
173
+ Testing.mcpRequest(
174
+ "tools/list",
175
+ {},
176
+ { headers: { origin: "https://elsewhere.example.test" } },
177
+ ),
178
+ );
179
+
180
+ expect(called.status).toBe(403);
181
+ expect(called.headers["access-control-allow-origin"]).toBe("https://console.example.test");
182
+ }).pipe(
183
+ Effect.provide(servedWith({ ...farm, ALLOWED_ORIGINS: "https://console.example.test" })),
184
+ ),
185
+ );
186
+
187
+ it.effect("allows no origin where none is configured", () =>
188
+ Effect.gen(function* () {
189
+ const asked = yield* preflight("https://console.example.test");
190
+
191
+ expect(asked.headers).not.toHaveProperty("access-control-allow-origin");
192
+ }).pipe(Effect.provide(servedWith(farm))),
193
+ );
194
+
195
+ it.effect("serves the host's own route beside the tools", () =>
196
+ Effect.gen(function* () {
197
+ const health = yield* HttpClient.get("/healthz");
198
+
199
+ expect(yield* health.text).toBe("ok");
200
+ }).pipe(Effect.provide(served)),
201
+ );
202
+ });
@@ -0,0 +1,108 @@
1
+ # notes-service
2
+
3
+ A small service as most applications of this library are built: one set of contracts, one
4
+ implementation, served as a JSON API and as MCP tools, with a CLI and a browser client on the
5
+ same binding. Notes belong to the signed-in user; reading needs the `notes:read` scope and
6
+ writing `notes:write`.
7
+
8
+ Start here if you are building a service. The other examples follow the same layout. Then read
9
+ [Action.md](../../docs/Action.md) and the page of each surface you serve, which
10
+ [docs/README.md](../../docs/README.md) routes, one page per tool call. An error's message is
11
+ quoted in a `docs/*-failures.md`.
12
+
13
+ ## Run it
14
+
15
+ From the repository root, after `vp install` (from an installed package, see
16
+ [Run one](../README.md#run-one)):
17
+
18
+ ```sh
19
+ node --import tsx examples/notes-service/src/server.ts # 127.0.0.1:3000
20
+
21
+ export NOTES_TOKEN=ada # a demo token: ada, grace or reader
22
+ node --import tsx examples/notes-service/src/cli.ts --help
23
+ echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --title Plan
24
+ node --import tsx examples/notes-service/src/cli.ts list-notes
25
+ node --import tsx examples/notes-service/src/cli.ts export notes.md # a command written by hand
26
+
27
+ vp test examples/notes-service
28
+ ```
29
+
30
+ An agent host connects to the running server, and so to the notes the API holds:
31
+
32
+ ```sh
33
+ claude mcp add --transport http notes http://127.0.0.1:3000/mcp --header "Authorization: Bearer ada"
34
+ codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES_TOKEN
35
+ ```
36
+
37
+ `src/stdio.ts` is the other way in: a subprocess the host starts, with a store of its own.
38
+
39
+ The server reads `PORT` (default 3000) and `PUBLIC_URL`, the address it tells OAuth clients it
40
+ is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
41
+ Every command asks for `NOTES_TOKEN`, the public `get-status` included: without it a command
42
+ prints one message on stderr saying to set it, and exits 1. `--help` needs none.
43
+
44
+ The tokens are fixed names and the store is memory: replace both before deploying anything.
45
+
46
+ ## Layout
47
+
48
+ ```text
49
+ src/
50
+ ├── notes/
51
+ │ ├── Notes.ts contract: schemas, errors, actions
52
+ │ ├── store.ts NoteStore, the service the handlers call, and its Layer
53
+ │ └── handlers.ts Action.implement: contract to store
54
+ ├── identity/
55
+ │ ├── Identity.ts contract: CurrentUser, scopes, the Login descriptor, whoAmI
56
+ │ ├── authorize.ts the one authorization rule
57
+ │ ├── users.ts the demo users
58
+ │ ├── verifier.ts Authentication.layer: tokens to users, server only
59
+ │ └── handlers.ts
60
+ ├── status/
61
+ │ ├── Status.ts contract: a public action
62
+ │ └── handlers.ts
63
+ ├── Api.ts the HTTP binding of every domain's actions
64
+ ├── routes.ts every surface of the host: /api, /mcp, /healthz, the OpenAPI document
65
+ ├── server.ts entrypoint: the HTTP server
66
+ ├── stdio.ts entrypoint: MCP for a local agent
67
+ ├── commands.ts the commands calling the server, one written by hand, and their connection
68
+ ├── cli.ts entrypoint: the commands, given that connection
69
+ └── browser.ts client module: promises for a web page
70
+ test/
71
+ ├── notes/ in process, with Action.client
72
+ ├── routes.test.ts over HTTP and MCP in memory, with Testing.layer
73
+ └── commands.test.ts the commands and their connection, against the same routes in memory
74
+ ```
75
+
76
+ - **A folder per domain, not per mechanism.** Everything about notes is in `notes/`; there is no
77
+ `contracts/` or `handlers/` folder.
78
+ - **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
79
+ import no server code, so a browser or a CLI imports them. The lowercase files beside them run
80
+ on the server.
81
+ - **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
82
+ picks the store and the HTTP server, `stdio.ts` names the local user, a test provides its own
83
+ store. Nothing below an entrypoint knows which it got.
84
+ - **In a monorepo** the contract files and `Api.ts` are the package the server, the CLI and the
85
+ website all depend on; the lowercase files and `routes.ts` are the server.
86
+
87
+ ## Where to look
88
+
89
+ | To see | Read |
90
+ | ------------------------------------------------------------- | -------------------------------- |
91
+ | A contract with declared errors and their statuses | `src/notes/Notes.ts` |
92
+ | Input fields described, for `--help` and a tool's schema | `src/notes/Notes.ts` |
93
+ | Handlers getting a service once and the caller per call | `src/notes/handlers.ts` |
94
+ | A public action beside protected ones | `src/status/` |
95
+ | Authorization from `readOnly`, refusing with a scope | `src/identity/authorize.ts` |
96
+ | A token verifier and OAuth discovery read from config | `src/identity/verifier.ts` |
97
+ | One implementation served over HTTP and MCP | `src/routes.ts` |
98
+ | Config, a body limit and the host's own routes | `src/server.ts`, `src/routes.ts` |
99
+ | The same actions over stdio, the host naming the caller | `src/stdio.ts` |
100
+ | A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
101
+ | A command written by hand beside the derived ones | `src/commands.ts` |
102
+ | The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
103
+ | A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
104
+ | A promise client handling each declared error by its tag | `src/browser.ts` |
105
+ | Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
106
+ | Testing routes, tools and refusals in memory | `test/routes.test.ts` |
107
+ | Testing commands against the routes in memory | `test/commands.test.ts` |
108
+ | Testing `--help` and the connection with config as a Layer | `test/commands.test.ts` |
@@ -0,0 +1,13 @@
1
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
+ import { Login, WhoAmI } from "./identity/Identity.js";
3
+ import { Notes } from "./notes/Notes.js";
4
+ import { GetStatus } from "./status/Status.js";
5
+
6
+ // The HTTP binding: every action at `POST /api/<name>`, the protected ones requiring `Login`.
7
+ // It is plain data and imports no server code, so the server, the CLI and a browser page all
8
+ // import this one value, and each gets its routes or its typed methods from it. An action
9
+ // added here is also a `notes` command: commands.ts derives one per action of this binding.
10
+ export const Http = ActionHttp.make([GetStatus, ...Notes, WhoAmI], {
11
+ prefix: "/api",
12
+ authentication: Login,
13
+ });
@@ -0,0 +1,41 @@
1
+ import { Effect } from "effect";
2
+ import { HttpClient, HttpClientRequest } from "effect/http";
3
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import { Http } from "./Api.js";
5
+ import type { Note } from "./notes/Notes.js";
6
+
7
+ // Client module: what a web page imports. It holds the binding and nothing of the server, so a
8
+ // bundler gets the contracts and Effect's fetch client, and no handler.
9
+ //
10
+ // Without `baseUrl` the routes are called on the page's own origin. A page served elsewhere
11
+ // passes the API's URL, and the server must then allow its origin (CORS).
12
+ export const connect = (token: string, baseUrl?: string) => {
13
+ // Built once, outside any Effect. Every method is typed from its action.
14
+ const api = ActionHttp.fetchClient(Http, {
15
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
16
+ ...(baseUrl === undefined ? {} : { baseUrl }),
17
+ });
18
+
19
+ // Each method returns a promise. A failure rejects it with the decoded error, so in a `catch`
20
+ // `error instanceof Action.Unauthenticated` tells an expired sign-in from anything else.
21
+ return {
22
+ notes: () => Effect.runPromise(api.listNotes()),
23
+
24
+ // A declared error can also be handled by its tag before the promise: a missing note is
25
+ // `undefined` here, and every other failure still rejects.
26
+ note: (id: string): Promise<Note | undefined> =>
27
+ api.getNote({ id }).pipe(
28
+ Effect.catchTag("NoteNotFound", () => Effect.succeed(undefined)),
29
+ Effect.runPromise,
30
+ ),
31
+
32
+ // A stale revision is refused, never overwritten: the error carries the current one.
33
+ save: (note: Note, body: string) =>
34
+ api.updateNote({ id: note.id, body, revision: note.revision }).pipe(
35
+ Effect.catchTag("NoteChanged", ({ revision }) =>
36
+ Effect.fail(new Error(`Someone else saved revision ${revision}. Reload the note.`)),
37
+ ),
38
+ Effect.runPromise,
39
+ ),
40
+ };
41
+ };
@@ -0,0 +1,22 @@
1
+ import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
2
+ import { Effect } from "effect";
3
+ import { Command } from "effect/cli";
4
+ import { HttpClient } from "effect/http";
5
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
6
+ import { commands, connection } from "./commands.js";
7
+
8
+ // Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
9
+ // The commands of commands.ts, connected to a server.
10
+ const notes = commands.pipe(
11
+ // Provided on the command, never around `Command.run`: the URL and the token are then read
12
+ // when a command runs, so `--help` and a mistyped flag need neither.
13
+ Command.provideEffect(HttpClient.HttpClient, connection),
14
+ );
15
+
16
+ // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
17
+ // HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
18
+ Command.run(notes, { version: "1.0.0" }).pipe(
19
+ Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
20
+ ActionCli.logToStderr,
21
+ NodeRuntime.runMain,
22
+ );
@@ -0,0 +1,89 @@
1
+ import { Config, Console, Effect, FileSystem, flow } from "effect";
2
+ import { Argument, CliError, Command } from "effect/cli";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
5
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import { Http } from "./Api.js";
7
+
8
+ // A command of your own, for what no single action does: this one calls two and writes a file.
9
+ // It is a native Effect CLI command, so its syntax and its output are what you write: `notes
10
+ // export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
11
+ // calls the server through the binding's typed client, on the `HttpClient` the derived commands
12
+ // use, and writes with the `FileSystem` every Effect CLI command has.
13
+ const exportNotes = Command.make(
14
+ "export",
15
+ { file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
16
+ ({ file }) =>
17
+ Effect.gen(function* () {
18
+ const client = yield* ActionHttp.client(Http);
19
+ const files = yield* FileSystem.FileSystem;
20
+ const { notes } = yield* client.listNotes();
21
+ const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
22
+
23
+ yield* files.writeFileString(
24
+ file,
25
+ whole.map((note) => `# ${note.title}\n\n${note.body}`).join("\n\n"),
26
+ );
27
+
28
+ yield* Console.log(`Wrote ${whole.length} notes to ${file}`);
29
+ }).pipe(
30
+ // Context of your own for a failure goes to stderr before it, on a line of its own. A
31
+ // `UserError` wrapped around the failure instead would put its JSON mid-sentence. The
32
+ // line prints for every failure of the command, a file that cannot be written included,
33
+ // so it says only what is true of them all.
34
+ Effect.tapError(() => Console.error(`The export to ${file} did not finish.`)),
35
+ // Once, around the whole command: it then fails as a derived command does, an action's
36
+ // error printed as its JSON on stderr and any other, a file that cannot be written for
37
+ // one, named. A failure left as it is would be reported as a crash, with an `ERROR (#n)`
38
+ // line and a stack trace.
39
+ ActionCli.toUserError(Http),
40
+ ),
41
+ ).pipe(Command.withDescription("Write every note to a file, as Markdown."));
42
+
43
+ // A command per action of the binding, a flag per input field, each calling the server over
44
+ // HTTP. It imports the binding alone, never a handler. It is apart from cli.ts so a test runs
45
+ // it: test/commands.test.ts.
46
+ export const commands = ActionCli.remote(Http, {
47
+ name: "notes",
48
+ commands: {
49
+ // `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
50
+ createNote: { stdin: "body" },
51
+ getNote: { positional: ["id"], render: (note) => `# ${note.title}\n\n${note.body}` },
52
+ // `notes update-note <id> --revision 1 < draft.md`: an argument, a flag and stdin together.
53
+ updateNote: { positional: ["id"], stdin: "body" },
54
+ },
55
+ // `Command.withSubcommands` on the result would replace the derived commands, so your own
56
+ // go here.
57
+ subcommands: [exportNotes],
58
+ });
59
+
60
+ // Where the server is and who is calling, read from config: the `HttpClient` every command
61
+ // above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
62
+ // and it runs when one of them does.
63
+ export const connection = Effect.gen(function* () {
64
+ const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
65
+
66
+ // Required, since every command but `get-status` is refused without one. A failure of what a
67
+ // command is provided is no action's error: left as it is, a missing token is reported as a
68
+ // crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
69
+ // ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
70
+ // the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
71
+ // printed, never `cause`: what the user needs from the cause goes into the message. A CLI
72
+ // whose public commands must answer signed out reads the token with `Config.option` instead
73
+ // and adds the header when it is there.
74
+ const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
75
+ Effect.mapError(
76
+ (cause) =>
77
+ new CliError.UserError({
78
+ cause,
79
+ userMessage:
80
+ "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
81
+ }),
82
+ ),
83
+ );
84
+
85
+ return HttpClient.mapRequest(
86
+ yield* HttpClient.HttpClient,
87
+ flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
88
+ );
89
+ });