@gjermundgaraba/effect-actions 0.13.0 → 0.15.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 (58) hide show
  1. package/README.md +2 -2
  2. package/dist/Action.d.mts +1 -1
  3. package/dist/ActionCli.d.mts +4 -6
  4. package/dist/ActionCli.mjs +11 -9
  5. package/dist/ActionHttp.mjs +18 -30
  6. package/dist/ActionMcp.d.mts +16 -7
  7. package/dist/ActionMcp.mjs +76 -89
  8. package/dist/ActionRpc.mjs +17 -29
  9. package/dist/ActionToolkit.mjs +26 -11
  10. package/dist/Authentication.mjs +1 -2
  11. package/dist/Testing.d.mts +6 -73
  12. package/dist/Testing.mjs +3 -102
  13. package/dist/authentication/provider.mjs +1 -6
  14. package/dist/authentication/refusal.mjs +14 -4
  15. package/dist/cli/commands.mjs +8 -8
  16. package/dist/contract/call.d.mts +2 -1
  17. package/dist/contract/call.mjs +14 -1
  18. package/dist/contract/errors.d.mts +2 -3
  19. package/dist/contract/errors.mjs +6 -12
  20. package/dist/contract/implementation.mjs +33 -5
  21. package/dist/contract/rules.d.mts +7 -0
  22. package/dist/contract/rules.mjs +50 -3
  23. package/dist/http/client.mjs +4 -7
  24. package/dist/mcp/client.d.mts +87 -0
  25. package/dist/mcp/client.mjs +122 -0
  26. package/dist/mcp/protocol.mjs +25 -37
  27. package/docs/Action.md +6 -5
  28. package/docs/ActionCli.md +4 -3
  29. package/docs/ActionMcp-failures.md +6 -2
  30. package/docs/ActionMcp.md +30 -33
  31. package/docs/ActionToolkit-failures.md +2 -1
  32. package/docs/ActionToolkit.md +2 -2
  33. package/docs/Authentication-failures.md +2 -2
  34. package/docs/Authentication.md +10 -5
  35. package/docs/CONTEXT.md +1 -1
  36. package/docs/Testing-failures.md +3 -3
  37. package/docs/Testing.md +16 -24
  38. package/docs/guarantees.md +15 -14
  39. package/docs/setup-failures.md +2 -2
  40. package/docs/setup.md +5 -5
  41. package/examples/kitchen-sink/README.md +26 -26
  42. package/examples/kitchen-sink/test/attachments/attachments.test.ts +3 -2
  43. package/examples/kitchen-sink/test/identity/identity.test.ts +13 -6
  44. package/examples/kitchen-sink/test/routes.test.ts +4 -3
  45. package/examples/live-rpc/README.md +7 -2
  46. package/examples/mcp-tools/test/routes.test.ts +7 -6
  47. package/examples/notes-service/README.md +13 -3
  48. package/examples/notes-service/src/notes/Notes.ts +4 -2
  49. package/examples/notes-service/src/routes.ts +24 -14
  50. package/examples/notes-service/src/worker.ts +20 -0
  51. package/examples/notes-service/test/commands.test.ts +2 -1
  52. package/examples/notes-service/test/routes.test.ts +6 -3
  53. package/examples/notes-service/test/worker.test.ts +34 -0
  54. package/examples/quickstart/test/greeting/greeting.test.ts +2 -1
  55. package/package.json +5 -5
  56. package/dist/http/declared.mjs +0 -20
  57. package/dist/mcp/protocol.d.mts +0 -18
  58. package/dist/toolkit/tools.mjs +0 -29
@@ -0,0 +1,20 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter, HttpServer } from "effect/http";
3
+ import { NoteStore } from "./notes/store.js";
4
+ import { requestScopedRoutes } from "./routes.js";
5
+
6
+ // Entrypoint for a host that calls `fetch` per request and runs nothing between requests, such
7
+ // as Cloudflare Workers, which takes this default export. The handler is built once, when the
8
+ // module loads, as Effect's `HttpRouter.toWebHandler` asks on Workers: a build started inside
9
+ // `fetch` would belong to that request. Building these routes does no I/O, which Workers refuses
10
+ // outside a request. `toWebHandler` reads no `MaxBodySize`, so the host limits request bodies.
11
+ const app = HttpRouter.toWebHandler(
12
+ requestScopedRoutes.pipe(
13
+ // DEMO ONLY: each instance of the worker holds notes of its own, lost when it stops. A
14
+ // deployment provides a store every instance reaches.
15
+ Layer.provide(NoteStore.layerMemory),
16
+ Layer.provide(HttpServer.layerServices),
17
+ ),
18
+ );
19
+
20
+ export default { fetch: (request: Request) => app.handler(request) };
@@ -22,7 +22,8 @@ import { NoteStore } from "../src/notes/store.js";
22
22
  import { routes } from "../src/routes.js";
23
23
 
24
24
  // What the commands add to the routes: their syntax and what they print. They call the real
25
- // routes in memory, as a client with a token does, where cli.ts connects them to a server.
25
+ // routes in memory, as a client with a token does, where cli.ts connects them to a server. A
26
+ // service a builder yields is provided here too (routes.test.ts).
26
27
  const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
27
28
 
28
29
  // Effect's own runner, given arguments instead of the process's. The console of `it.effect`
@@ -3,6 +3,7 @@ import { Effect, Layer } from "effect";
3
3
  import { HttpClient, HttpClientRequest } from "effect/http";
4
4
  import * as Action from "@gjermundgaraba/effect-actions/Action";
5
5
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
6
7
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
8
  import { Http } from "../src/Api.js";
8
9
  import { Notes } from "../src/notes/Notes.js";
@@ -11,7 +12,9 @@ import { routes } from "../src/routes.js";
11
12
  import { GetStatus } from "../src/status/Status.js";
12
13
 
13
14
  // What the wire adds: the real routes, behind their real authentication, answering in memory.
14
- // No port is opened. The caller is the token each client sends, as in production.
15
+ // No port is opened. The caller is the token each client sends, as in production. A service a
16
+ // builder yields is provided here, as server.ts provides it: one left out is a `Scope` error at
17
+ // every `it.effect` (docs/Testing-failures.md).
15
18
  const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
16
19
 
17
20
  const as = (token: string) => ({
@@ -65,7 +68,7 @@ describe("the service, over HTTP and MCP", () => {
65
68
  it.effect("serves one store to both surfaces: a note written over HTTP is read as a tool", () =>
66
69
  Effect.gen(function* () {
67
70
  const http = yield* ActionHttp.client(Http, as("ada"));
68
- const mcp = yield* Testing.mcpClient([GetStatus, ...Notes], as("ada"));
71
+ const mcp = yield* ActionMcp.client([GetStatus, ...Notes], as("ada"));
69
72
  const note = yield* http.createNote({ title: "Plan", body: "Draft" });
70
73
 
71
74
  expect(yield* mcp.getNote({ id: note.id })).toEqual(note);
@@ -75,7 +78,7 @@ describe("the service, over HTTP and MCP", () => {
75
78
 
76
79
  it.effect("lists every tool to a signed-out MCP client, as one of them is public", () =>
77
80
  Effect.gen(function* () {
78
- const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
81
+ const response = yield* HttpClient.execute(ActionMcp.request("tools/list"));
79
82
 
80
83
  expect(response.status).toBe(200);
81
84
  expect(yield* response.text).toContain('"name":"createNote"');
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
6
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
+ import { CreateNote, GetNote } from "../src/notes/Notes.js";
8
+ import { GetStatus } from "../src/status/Status.js";
9
+ import worker from "../src/worker.js";
10
+
11
+ // What a host calling the worker's `fetch` gets: the module's own handler, answering in memory.
12
+ const served = Testing.layer(worker.fetch);
13
+
14
+ const as = (token: string) => ({
15
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
16
+ });
17
+
18
+ describe("the worker", () => {
19
+ it.effect("serves the tools, each request as the caller it authenticates", () =>
20
+ Effect.gen(function* () {
21
+ const anyone = yield* ActionMcp.client([GetStatus, GetNote]);
22
+ const ada = yield* ActionMcp.client([CreateNote, GetNote], as("ada"));
23
+
24
+ expect(yield* anyone.getStatus()).toMatchObject({ service: "notes-service" });
25
+
26
+ const note = yield* ada.createNote({ title: "Plan", body: "First draft" });
27
+
28
+ expect(yield* ada.getNote({ id: note.id })).toEqual(note);
29
+ expect(yield* Effect.flip(anyone.getNote({ id: note.id }))).toBeInstanceOf(
30
+ Action.Unauthenticated,
31
+ );
32
+ }).pipe(Effect.provide(served)),
33
+ );
34
+ });
@@ -1,6 +1,7 @@
1
1
  import { expect, it } from "@effect/vitest";
2
2
  import { Effect } from "effect";
3
3
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
4
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
6
  import { Http } from "../../src/Api.js";
6
7
  import { Greet } from "../../src/greeting/Greeting.js";
@@ -10,7 +11,7 @@ import { routes } from "../../src/routes.js";
10
11
  it.effect("greets over HTTP and as an MCP tool", () =>
11
12
  Effect.gen(function* () {
12
13
  const http = yield* ActionHttp.client(Http);
13
- const mcp = yield* Testing.mcpClient([Greet]);
14
+ const mcp = yield* ActionMcp.client([Greet]);
14
15
 
15
16
  expect(yield* http.greet({ name: "Ada" })).toBe("Hello, Ada!");
16
17
  expect(yield* mcp.greet({ name: "Ada" })).toBe("Hello, Ada!");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, Effect RPC, native Toolkits, and CLIs.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -49,11 +49,11 @@
49
49
  "access": "public"
50
50
  },
51
51
  "devDependencies": {
52
- "@effect/platform-node": "4.0.3",
53
- "@effect/vitest": "4.0.3",
52
+ "@effect/platform-node": "4.0.4",
53
+ "@effect/vitest": "4.0.4",
54
54
  "@modelcontextprotocol/client": "2.3.1",
55
55
  "@types/node": "26.6.4",
56
- "effect": "4.0.3",
56
+ "effect": "4.0.4",
57
57
  "tsx": "4.23.15",
58
58
  "typescript": "7.0.2",
59
59
  "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
@@ -61,7 +61,7 @@
61
61
  "vitest": "5.0.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "effect": "~4.0.3"
64
+ "effect": "~4.0.4"
65
65
  },
66
66
  "devEngines": {
67
67
  "packageManager": {
@@ -1,20 +0,0 @@
1
- import { unsuspended } from "../contract/rules.mjs";
2
- import { Schema, SchemaAST } from "effect";
3
- import { HttpServerResponse } from "effect/http";
4
- import { HttpApiSchema } from "effect/http-api";
5
- //#region src/http/declared.ts
6
- const httpApiStatusOf = SchemaAST.resolveAt("httpApiStatus");
7
- const statusThroughSuspensions = (ast) => httpApiStatusOf(ast) ?? (SchemaAST.isSuspend(ast) ? statusThroughSuspensions(ast.thunk()) : void 0);
8
- const declared = (error) => {
9
- const status = statusThroughSuspensions(error.ast);
10
- if (status !== void 0) return [httpApiStatusOf(error.ast) === void 0 ? HttpApiSchema.status(status)(error) : error];
11
- const resolved = unsuspended(error.ast);
12
- if (SchemaAST.isUnion(resolved) && resolved.checks === void 0 && resolved.encoding === void 0) return resolved.types.flatMap((member) => declared(Schema.make(member)));
13
- return [HttpApiSchema.status(422)(error)];
14
- };
15
- const declaredResponse = (errors, error) => {
16
- const schema = errors.flatMap(declared).find((each) => Schema.is(each)(error));
17
- return schema === void 0 ? void 0 : HttpServerResponse.jsonUnsafe(Schema.encodeUnknownSync(Schema.toCodecJson(schema))(error), { status: httpApiStatusOf(schema.ast) ?? 422 });
18
- };
19
- //#endregion
20
- export { declared, declaredResponse };
@@ -1,18 +0,0 @@
1
- import "../contract/implementation.mjs";
2
- import "../contract/media.mjs";
3
- import "../Action.mjs";
4
- import { Schema } from "effect";
5
- import { McpProtocol } from "effect/ai";
6
- //#region src/mcp/protocol.d.ts
7
- /**
8
- * An MCP request's parameters, as `Testing.mcpRequest` takes them: JSON, with any `_meta`, leaving
9
- * out an `undefined` one, as a caller may. A helper passing on whole params types them as this,
10
- * and a tool's `arguments` as `Schema.Json`, never `unknown`.
11
- */
12
- export interface Params {
13
- readonly _meta?: {
14
- readonly [key: string]: Schema.Json;
15
- };
16
- readonly [key: string]: Schema.Json | undefined;
17
- }
18
- //#endregion
@@ -1,29 +0,0 @@
1
- import { assertOnce, projectedErrors } from "../contract/rules.mjs";
2
- import { acquire } from "../contract/implementation.mjs";
3
- import { Effect, Schema } from "effect";
4
- import { Tool, Toolkit } from "effect/ai";
5
- //#region src/toolkit/tools.ts
6
- const annotateIfGiven = (tool, key, value) => value === void 0 ? tool : tool.annotate(key, value);
7
- const annotateFromContract = (tool, { readOnly, mcp }) => {
8
- const destructiveUnlessReadOnly = mcp.destructiveHint ?? (readOnly ? false : void 0);
9
- const meta = mcp._meta === void 0 ? void 0 : { ...mcp._meta };
10
- const hinted = annotateIfGiven(annotateIfGiven(annotateIfGiven(tool.annotate(Tool.Readonly, readOnly), Tool.Destructive, destructiveUnlessReadOnly), Tool.Idempotent, mcp.idempotentHint), Tool.OpenWorld, mcp.openWorldHint);
11
- return annotateIfGiven(annotateIfGiven(hinted, Tool.Title, mcp.title), Tool.Meta, meta);
12
- };
13
- const jsonToolOf = (action) => Tool.make(action.name, {
14
- description: action.description,
15
- parameters: Schema.toCodecJson(action.input),
16
- success: Schema.toCodecJson(action.success),
17
- failure: Schema.toCodecJson(Schema.Union(projectedErrors(action))),
18
- failureMode: "return"
19
- });
20
- const bindTools = (apps, projection) => {
21
- assertOnce(projection.label, apps.flatMap((app) => app.actions));
22
- const toolkit = Toolkit.make(...apps.flatMap((app) => app.actions.map((action) => annotateFromContract(projection.tool(jsonToolOf(action), action, app), action))));
23
- return {
24
- toolkit,
25
- layer: toolkit.toLayer(Effect.map(acquire(apps), (bound) => Object.fromEntries(bound.map(([action, run]) => [action.name, projection.handler?.(run, action) ?? run]))))
26
- };
27
- };
28
- //#endregion
29
- export { bindTools };