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