@gjermundgaraba/effect-actions 0.11.0 → 0.13.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 (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -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 +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  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 +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  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 +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  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 +318 -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 +126 -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 +111 -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 +42 -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 +77 -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 +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -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 +31 -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 +111 -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 +137 -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 +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -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 +288 -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 +27 -9
  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 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. 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, true, false) },
83
+ },
84
+ {
85
+ name: "set_paused",
86
+ annotations: { title: "Pause or resume a print", ...hints(false, true, 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,111 @@
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
+ node --import tsx examples/notes-service/src/cli.ts import notes.md # one importNotes call
27
+
28
+ vp test examples/notes-service
29
+ ```
30
+
31
+ An agent host connects to the running server, and so to the notes the API holds:
32
+
33
+ ```sh
34
+ claude mcp add --transport http notes http://127.0.0.1:3000/mcp --header "Authorization: Bearer ada"
35
+ codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES_TOKEN
36
+ ```
37
+
38
+ `src/stdio.ts` is the other way in: a subprocess the host starts, with a store of its own.
39
+
40
+ The server reads `HOST` (default `127.0.0.1`), `PORT` (default 3000) and `PUBLIC_URL`, the
41
+ address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
42
+ Every command asks for `NOTES_TOKEN`, the public `get-status` included: without it a command
43
+ prints one message on stderr saying to set it, and exits 1. `--help` needs none.
44
+
45
+ The tokens are fixed names and the store is memory: replace both before deploying anything.
46
+
47
+ ## Layout
48
+
49
+ ```text
50
+ src/
51
+ ├── notes/
52
+ │ ├── Notes.ts contract: schemas, errors, actions
53
+ │ ├── store.ts NoteStore, the service the handlers call, and its Layer
54
+ │ └── handlers.ts Action.implement: contract to store
55
+ ├── identity/
56
+ │ ├── Identity.ts contract: CurrentUser, scopes, the Login descriptor, whoAmI
57
+ │ ├── authorize.ts the one authorization rule
58
+ │ ├── users.ts the demo users
59
+ │ ├── verifier.ts Authentication.layer: tokens to users, server only
60
+ │ └── handlers.ts
61
+ ├── status/
62
+ │ ├── Status.ts contract: a public action
63
+ │ └── handlers.ts
64
+ ├── Api.ts the HTTP binding of every domain's actions
65
+ ├── routes.ts every surface of the host: /api, /mcp, /healthz, the OpenAPI document
66
+ ├── server.ts entrypoint: the HTTP server
67
+ ├── stdio.ts entrypoint: MCP for a local agent
68
+ ├── commands.ts the commands calling the server, one written by hand, and their connection
69
+ ├── cli.ts entrypoint: the commands, given that connection
70
+ └── browser.ts client module: promises for a web page
71
+ test/
72
+ ├── notes/ in process, with Action.client
73
+ ├── routes.test.ts over HTTP and MCP in memory, with Testing.layer
74
+ └── commands.test.ts the commands and their connection, against the same routes in memory
75
+ ```
76
+
77
+ - **A folder per domain, not per mechanism.** Everything about notes is in `notes/`; there is no
78
+ `contracts/` or `handlers/` folder.
79
+ - **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
80
+ import no server code, so a browser or a CLI imports them. The lowercase files beside them run
81
+ on the server, each named for its role, never the domain: `notes.ts` beside `Notes.ts` is one
82
+ file on a disk that ignores case (TS1149).
83
+ - **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
84
+ picks the store and the HTTP server, `stdio.ts` names the local user, a test provides its own
85
+ store. Nothing below an entrypoint knows which it got.
86
+ - **In a monorepo** the contract files and `Api.ts` are the package the server, the CLI and the
87
+ website all depend on; the lowercase files and `routes.ts` are the server.
88
+
89
+ ## Where to look
90
+
91
+ | To see | Read |
92
+ | ------------------------------------------------------------- | -------------------------------- |
93
+ | A contract with declared errors and their statuses | `src/notes/Notes.ts` |
94
+ | Input fields described, for `--help` and a tool's schema | `src/notes/Notes.ts` |
95
+ | Handlers getting a service once and the caller per call | `src/notes/handlers.ts` |
96
+ | A public action beside protected ones | `src/status/` |
97
+ | Authorization from `readOnly`, refusing with a scope | `src/identity/authorize.ts` |
98
+ | A token verifier and OAuth discovery read from config | `src/identity/verifier.ts` |
99
+ | One implementation served over HTTP and MCP | `src/routes.ts` |
100
+ | Config, a body limit and the host's own routes | `src/server.ts`, `src/routes.ts` |
101
+ | The same actions over stdio, the host naming the caller | `src/stdio.ts` |
102
+ | A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
103
+ | A command written by hand beside the derived ones | `src/commands.ts` |
104
+ | A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
105
+ | The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
106
+ | A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
107
+ | A promise client handling each declared error by its tag | `src/browser.ts` |
108
+ | Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
109
+ | Testing routes, tools and refusals in memory | `test/routes.test.ts` |
110
+ | Testing commands against the routes in memory | `test/commands.test.ts` |
111
+ | 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
+ );