@gjermundgaraba/effect-actions 0.12.0 → 0.14.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 +4 -4
- package/dist/Action.d.mts +3 -1
- package/dist/ActionToolkit.d.mts +23 -1
- package/dist/ActionToolkit.mjs +28 -1
- package/dist/Testing.d.mts +3 -1
- package/dist/Testing.mjs +2 -2
- package/dist/mcp/protocol.d.mts +2 -1
- package/docs/Action.md +11 -6
- package/docs/ActionCli-failures.md +2 -0
- package/docs/ActionCli.md +7 -5
- package/docs/ActionHttp-failures.md +2 -2
- package/docs/ActionHttp.md +6 -3
- package/docs/ActionMcp.md +20 -20
- package/docs/ActionRpc.md +3 -2
- package/docs/ActionToolkit.md +15 -12
- package/docs/Authentication.md +3 -2
- package/docs/CONTEXT.md +3 -2
- package/docs/README.md +9 -5
- package/docs/Testing-failures.md +2 -1
- package/docs/Testing.md +10 -3
- package/docs/setup-failures.md +5 -2
- package/docs/setup.md +9 -7
- package/examples/kitchen-sink/README.md +5 -4
- package/examples/kitchen-sink/src/assistant/handlers.ts +18 -38
- package/examples/kitchen-sink/src/issues/Issues.ts +4 -2
- package/examples/kitchen-sink/src/issues/write-limit.ts +2 -1
- package/examples/kitchen-sink/src/server.ts +3 -3
- package/examples/live-rpc/README.md +1 -1
- package/examples/live-rpc/src/chat/Chat.ts +3 -1
- package/examples/live-rpc/src/chat/handlers.ts +2 -2
- package/examples/live-rpc/src/chat/store.ts +1 -1
- package/examples/live-rpc/src/server.ts +3 -3
- package/examples/live-rpc/test/chat/chat.test.ts +1 -1
- package/examples/mcp-tools/README.md +9 -4
- package/examples/mcp-tools/src/printers/Printers.ts +6 -4
- package/examples/mcp-tools/src/server.ts +3 -2
- package/examples/mcp-tools/test/routes.test.ts +2 -2
- package/examples/notes-service/README.md +6 -3
- package/examples/notes-service/src/commands.ts +50 -2
- package/examples/notes-service/src/notes/Notes.ts +32 -4
- package/examples/notes-service/src/notes/handlers.ts +8 -2
- package/examples/notes-service/src/notes/store.ts +11 -0
- package/examples/notes-service/src/server.ts +3 -3
- package/examples/notes-service/test/commands.test.ts +156 -2
- package/package.json +5 -5
package/docs/setup.md
CHANGED
|
@@ -3,26 +3,28 @@
|
|
|
3
3
|
Install the package, import its modules, write the smallest program, lay out an application and
|
|
4
4
|
bundle a browser client.
|
|
5
5
|
|
|
6
|
-
Read this page alone, in one tool call
|
|
7
|
-
search [setup-failures.md](setup-failures.md). Reading order:
|
|
6
|
+
Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
|
|
7
|
+
When something fails, search [setup-failures.md](setup-failures.md). Reading order:
|
|
8
|
+
[README.md](README.md).
|
|
8
9
|
|
|
9
10
|
## Install
|
|
10
11
|
|
|
11
12
|
```sh
|
|
12
|
-
pnpm add @gjermundgaraba/effect-actions effect@~4.0.
|
|
13
|
+
pnpm add @gjermundgaraba/effect-actions effect@~4.0.4
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
| Need | Add |
|
|
16
17
|
| ----------------------------------- | ---------------------------------------------------------------- |
|
|
17
|
-
| Node HTTP server, stdio host or CLI | `@effect/platform-node@~4.0.
|
|
18
|
-
| Tests | `vitest@^5.0.3` and `@effect/vitest@~4.0.
|
|
18
|
+
| Node HTTP server, stdio host or CLI | `@effect/platform-node@~4.0.4` |
|
|
19
|
+
| Tests | `vitest@^5.0.3` and `@effect/vitest@~4.0.4`, as dev dependencies |
|
|
19
20
|
| Type checking | `typescript@^7.0.2`, as a dev dependency |
|
|
21
|
+
| Running a TypeScript entrypoint | `tsx`, as a dev dependency: `node --import tsx <entrypoint>.ts` |
|
|
20
22
|
|
|
21
23
|
The `effect` peer admits only the patches of the Effect release the package is tested against:
|
|
22
24
|
a minor release may change the unstable Effect modules the surfaces build on.
|
|
23
25
|
|
|
24
|
-
Keep the tilde in `package.json`: `^4.0.
|
|
25
|
-
release outside the peer range. `npm install effect@~4.0.
|
|
26
|
+
Keep the tilde in `package.json`: `^4.0.4`, which npm writes for `effect@4.0.4`, admits a minor
|
|
27
|
+
release outside the peer range. `npm install effect@~4.0.4` keeps it.
|
|
26
28
|
|
|
27
29
|
Node `^22.12.0 || ^24.0.0 || >=26.0.0`: the package's `engines`, where its `devEngines` is
|
|
28
30
|
what developing the library itself takes. TypeScript 7 or newer. ESM only. No example ships a
|
|
@@ -36,10 +36,11 @@ node --import tsx examples/kitchen-sink/src/agent.ts # the assis
|
|
|
36
36
|
vp test examples/kitchen-sink
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
`PORT`, `ADMIN_PORT`, `PUBLIC_URL` and `MCP_ORIGIN` configure the server; `TRACKER_URL`
|
|
40
|
-
CLI and the seed at it. `stdio.ts` is an MCP server for a local agent to start as a
|
|
41
|
-
`sdk.ts` and `browser.ts` are imported by client code, not run. The OpenAPI
|
|
42
|
-
`/api/v1/openapi.json`, Swagger UI at `/docs`, the MCP endpoint at `/mcp`, the
|
|
39
|
+
`HOST`, `PORT`, `ADMIN_PORT`, `PUBLIC_URL` and `MCP_ORIGIN` configure the server; `TRACKER_URL`
|
|
40
|
+
points the CLI and the seed at it. `stdio.ts` is an MCP server for a local agent to start as a
|
|
41
|
+
subprocess. `sdk.ts` and `browser.ts` are imported by client code, not run. The OpenAPI
|
|
42
|
+
document is at `/api/v1/openapi.json`, Swagger UI at `/docs`, the MCP endpoint at `/mcp`, the
|
|
43
|
+
socket at `/rpc`.
|
|
43
44
|
|
|
44
45
|
Everything is a demo: stores are memory, and the credentials are fixed strings. Replace both
|
|
45
46
|
before deploying anything.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Effect, Ref, Schema } from "effect";
|
|
2
2
|
import { type Chat, LanguageModel, type Prompt } from "effect/ai";
|
|
3
3
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
4
5
|
import { CurrentMember } from "../identity/Identity.js";
|
|
5
6
|
import { toolkit } from "../issues/assistant.js";
|
|
6
7
|
import { AssistantFailed, Assistant } from "./Assistant.js";
|
|
@@ -15,7 +16,8 @@ const approvalsOf = (response: Response) =>
|
|
|
15
16
|
response.content.filter((part) => part.type === "tool-approval-request");
|
|
16
17
|
|
|
17
18
|
// What Effect gives the model as the result of a call that did not run: one its member denied,
|
|
18
|
-
// or one in a reply the provider cut short.
|
|
19
|
+
// or one in a reply the provider cut short. Left out of `ran` below, which lists the calls that
|
|
20
|
+
// ran.
|
|
19
21
|
const didNotRun = Schema.is(
|
|
20
22
|
Schema.Struct({ type: Schema.Literals(["execution-denied", "execution-interrupted"]) }),
|
|
21
23
|
);
|
|
@@ -23,45 +25,12 @@ const didNotRun = Schema.is(
|
|
|
23
25
|
const callsToRead = (response: Response) =>
|
|
24
26
|
response.toolCalls.length > 0 && approvalsOf(response).length === 0;
|
|
25
27
|
|
|
26
|
-
// Every request of a conversation that an answer would still act on, with the call it would
|
|
27
|
-
// run: read from the `Chat`'s history, by the rule Effect applies when an answer arrives. A
|
|
28
|
-
// request is open until its call has a result, a denial's included, and its call is the last
|
|
29
|
-
// in the history with that id. So a request an earlier answer left open is listed in every
|
|
30
|
-
// turn until it is answered, and what is listed is what an approval runs.
|
|
31
|
-
const openRequestsOf = ({ content: messages }: Prompt.Prompt) => {
|
|
32
|
-
const resolved = new Set(
|
|
33
|
-
messages.flatMap((message) =>
|
|
34
|
-
message.role === "tool"
|
|
35
|
-
? message.content.flatMap((part) => (part.type === "tool-result" ? [part.id] : []))
|
|
36
|
-
: [],
|
|
37
|
-
),
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
const said = messages.flatMap((message) => (message.role === "assistant" ? message.content : []));
|
|
41
|
-
const calls = said.filter((part) => part.type === "tool-call");
|
|
42
|
-
|
|
43
|
-
return said.flatMap((request) => {
|
|
44
|
-
if (request.type !== "tool-approval-request" || resolved.has(request.toolCallId)) return [];
|
|
45
|
-
|
|
46
|
-
const call = calls.findLast(({ id }) => id === request.toolCallId);
|
|
47
|
-
|
|
48
|
-
return call === undefined
|
|
49
|
-
? []
|
|
50
|
-
: [
|
|
51
|
-
{
|
|
52
|
-
approvalId: request.approvalId,
|
|
53
|
-
tool: call.name,
|
|
54
|
-
arguments: JSON.stringify(call.params),
|
|
55
|
-
},
|
|
56
|
-
];
|
|
57
|
-
});
|
|
58
|
-
};
|
|
59
|
-
|
|
60
28
|
// The model loop behind a route. The builder runs once, at startup, and yields what a turn is
|
|
61
29
|
// built from: the tools with their handlers, the model, and where conversations are kept. A
|
|
62
30
|
// handler yielding them would owe them per request, which no route provides. `CurrentMember`
|
|
63
31
|
// is the one per-request service: each tool call of a turn runs as the request's caller,
|
|
64
|
-
// through that tool's own `authorize`.
|
|
32
|
+
// through that tool's own `authorize`. A conversation keeps its owner's id, never the member
|
|
33
|
+
// itself, whose scopes would go stale between requests.
|
|
65
34
|
export const assistant = Action.implement(
|
|
66
35
|
Assistant,
|
|
67
36
|
Effect.gen(function* () {
|
|
@@ -92,7 +61,17 @@ export const assistant = Action.implement(
|
|
|
92
61
|
ran: results
|
|
93
62
|
.filter(({ result }) => !didNotRun(result))
|
|
94
63
|
.map(({ name, isFailure }) => ({ tool: name, failed: isFailure })),
|
|
95
|
-
waiting
|
|
64
|
+
// Every request of the conversation still waiting for an answer, with the call it
|
|
65
|
+
// would run, read from the `Chat`'s history by the rule Effect applies when an answer
|
|
66
|
+
// arrives: one an earlier answer left open is listed in every turn until answered.
|
|
67
|
+
// The arguments are shown as JSON, as the member approves them.
|
|
68
|
+
waiting: ActionToolkit.openApprovals(yield* Ref.get(chat.history)).map(
|
|
69
|
+
({ approvalId, name, params }) => ({
|
|
70
|
+
approvalId,
|
|
71
|
+
tool: name,
|
|
72
|
+
arguments: JSON.stringify(params),
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
96
75
|
};
|
|
97
76
|
}).pipe(
|
|
98
77
|
// The model is a startup service, given to each turn here.
|
|
@@ -126,7 +105,8 @@ export const assistant = Action.implement(
|
|
|
126
105
|
: turn(conversation, prompt),
|
|
127
106
|
// The answers start the next turn, in which an approved call runs, as this request's
|
|
128
107
|
// caller, before the model is asked again. Only the member who was asked reaches the
|
|
129
|
-
// conversation, and
|
|
108
|
+
// conversation, and a turn that fails ends it, so no answer is left without its result:
|
|
109
|
+
// an `approvalId` that `openApprovals` would not list runs nothing.
|
|
130
110
|
answerAssistant: ({ conversation, answers }) =>
|
|
131
111
|
turn(conversation, [
|
|
132
112
|
{
|
|
@@ -43,8 +43,9 @@ const change = {
|
|
|
43
43
|
caller: CurrentMember,
|
|
44
44
|
error: IssueWriteErrors,
|
|
45
45
|
// MCP hints, in MCP's names. They tell a model what a tool does and enforce nothing; the
|
|
46
|
-
// read-only hint is `readOnly` itself.
|
|
47
|
-
|
|
46
|
+
// read-only hint is `readOnly` itself. A write is destructive unless it only adds, as a
|
|
47
|
+
// comment does: closing is not stated `false`.
|
|
48
|
+
mcp: { openWorldHint: false },
|
|
48
49
|
} as const;
|
|
49
50
|
|
|
50
51
|
const Text = Schema.String.check(Schema.isMinLength(1));
|
|
@@ -95,6 +96,7 @@ export const CommentOnIssue = Action.make("commentOnIssue", {
|
|
|
95
96
|
input: { id: Schema.String, text: Text },
|
|
96
97
|
success: Issue,
|
|
97
98
|
readOnly: false,
|
|
99
|
+
mcp: { ...change.mcp, destructiveHint: false },
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
export const CloseIssue = Action.make("closeIssue", {
|
|
@@ -10,7 +10,8 @@ import { RateLimited } from "./Issues.js";
|
|
|
10
10
|
// as any declared error. The limiter is a service, so its counts are shared wherever it is
|
|
11
11
|
// provided: memory.ts provides Effect's own, `RateLimiter.layer` over a store. `consume` counts
|
|
12
12
|
// in a fixed window unless given `algorithm: "token-bucket"`: ten writes in each minute, so
|
|
13
|
-
// twenty may pass across the boundary between two.
|
|
13
|
+
// twenty may pass across the boundary between two. A strict limit, ten in any sixty seconds,
|
|
14
|
+
// is a service of your own of the same shape, yielded here, never part of the issue store.
|
|
14
15
|
export const writeLimit = Effect.gen(function* () {
|
|
15
16
|
const limiter = yield* RateLimiter.RateLimiter;
|
|
16
17
|
|
|
@@ -19,6 +19,8 @@ const socket = rpc.pipe(
|
|
|
19
19
|
|
|
20
20
|
const server = Layer.unwrap(
|
|
21
21
|
Effect.gen(function* () {
|
|
22
|
+
// The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
|
|
23
|
+
const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
|
|
22
24
|
const publicPort = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
|
|
23
25
|
const adminPort = yield* Config.Port("ADMIN_PORT").pipe(Config.withDefault(3001));
|
|
24
26
|
|
|
@@ -26,9 +28,7 @@ const server = Layer.unwrap(
|
|
|
26
28
|
|
|
27
29
|
const listen = (port: number) =>
|
|
28
30
|
NodeHttpServer.layer(createServer, {
|
|
29
|
-
|
|
30
|
-
// `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
31
|
-
host: "127.0.0.1",
|
|
31
|
+
host,
|
|
32
32
|
port,
|
|
33
33
|
// A message on the WebSocket of `/rpc` is no request body, so the limit below does not
|
|
34
34
|
// bound it: Node's WebSocket server reads one of up to 100 MiB whole unless told
|
|
@@ -15,7 +15,7 @@ From the repository root, after `vp install` (from an installed package, see
|
|
|
15
15
|
[Run one](../README.md#run-one)):
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
node --import tsx examples/live-rpc/src/server.ts # 127.0.0.1:3000, PORT to change
|
|
18
|
+
node --import tsx examples/live-rpc/src/server.ts # 127.0.0.1:3000, HOST and PORT to change
|
|
19
19
|
node --import tsx examples/live-rpc/src/client.ts # CHAT_URL to change, ws://127.0.0.1:3000/rpc
|
|
20
20
|
|
|
21
21
|
vp test examples/live-rpc
|
|
@@ -21,9 +21,11 @@ export class MessageNotFound extends Schema.TaggedError<MessageNotFound>()(
|
|
|
21
21
|
{ httpApiStatus: 404 },
|
|
22
22
|
) {}
|
|
23
23
|
|
|
24
|
+
// A refusal carries what the caller sent and no more: who wrote the message is for the actions
|
|
25
|
+
// that read it, under their own rule.
|
|
24
26
|
export class NotAuthor extends Schema.TaggedError<NotAuthor>()(
|
|
25
27
|
"NotAuthor",
|
|
26
|
-
{ id: Schema.Int
|
|
28
|
+
{ id: Schema.Int },
|
|
27
29
|
{ httpApiStatus: 403 },
|
|
28
30
|
) {}
|
|
29
31
|
|
|
@@ -16,8 +16,8 @@ export const rooms = Action.implement(
|
|
|
16
16
|
}),
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
-
// The builder runs once, at startup: it is where a handler gets its services. The
|
|
20
|
-
// the room are read per call, inside each handler, because every message has its own.
|
|
19
|
+
// The builder runs once, at startup: it is where a handler gets its startup services. The
|
|
20
|
+
// caller and the room are read per call, inside each handler, because every message has its own.
|
|
21
21
|
export const messages = Action.implement(
|
|
22
22
|
Messages,
|
|
23
23
|
Effect.gen(function* () {
|
|
@@ -45,7 +45,7 @@ export class MessageStore extends Context.Service<
|
|
|
45
45
|
|
|
46
46
|
if (message === undefined) return yield* new MessageNotFound({ id });
|
|
47
47
|
|
|
48
|
-
if (message.author !== author) return yield* new NotAuthor({ id
|
|
48
|
+
if (message.author !== author) return yield* new NotAuthor({ id });
|
|
49
49
|
|
|
50
50
|
rooms.set(name, { last, messages: messages.filter((kept) => kept !== message) });
|
|
51
51
|
}),
|
|
@@ -20,14 +20,14 @@ const socket = rpc.pipe(
|
|
|
20
20
|
|
|
21
21
|
const server = Layer.unwrap(
|
|
22
22
|
Effect.gen(function* () {
|
|
23
|
+
// The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
|
|
24
|
+
const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
|
|
23
25
|
const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
|
|
24
26
|
|
|
25
27
|
return HttpRouter.serve(socket).pipe(
|
|
26
28
|
Layer.provide(
|
|
27
29
|
NodeHttpServer.layer(createServer, {
|
|
28
|
-
|
|
29
|
-
// `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
30
|
-
host: "127.0.0.1",
|
|
30
|
+
host,
|
|
31
31
|
port,
|
|
32
32
|
// A message on a WebSocket is no request body, so `HttpServerRequest.MaxBodySize`
|
|
33
33
|
// does not bound it: Node's WebSocket server reads one of up to 100 MiB whole unless
|
|
@@ -42,7 +42,7 @@ describe("messages, in process", () => {
|
|
|
42
42
|
const { id } = yield* client.postMessage({ text: "Oops." }).pipe(as("ada", "lobby"));
|
|
43
43
|
|
|
44
44
|
expect(yield* Effect.flip(client.deleteMessage({ id }).pipe(as("grace", "lobby")))).toEqual(
|
|
45
|
-
new NotAuthor({ id
|
|
45
|
+
new NotAuthor({ id }),
|
|
46
46
|
);
|
|
47
47
|
|
|
48
48
|
yield* client.deleteMessage({ id }).pipe(as("ada", "lobby"));
|
|
@@ -5,8 +5,10 @@ authentication of its own. It signs in to the farm once, with its own account, a
|
|
|
5
5
|
reach it may call every tool, so it runs as a local subprocess of an agent or on a network only
|
|
6
6
|
trusted clients are on.
|
|
7
7
|
|
|
8
|
-
Start here if you are putting an existing API in front of a model
|
|
9
|
-
|
|
8
|
+
Start here if you are putting an existing API in front of a model, then read
|
|
9
|
+
[ActionMcp.md](../../docs/ActionMcp.md). For tools that need a token, its "Protect tools" shows
|
|
10
|
+
the verifier, and [notes-service](../notes-service/) is a server whose tools belong to the
|
|
11
|
+
signed-in user.
|
|
10
12
|
|
|
11
13
|
## Run it
|
|
12
14
|
|
|
@@ -28,7 +30,8 @@ vp test examples/mcp-tools
|
|
|
28
30
|
|
|
29
31
|
The farm is a fake, answered in memory, so any URL, user and password work: replace it with a
|
|
30
32
|
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
|
|
33
|
+
endpoint accepts and answers with CORS headers, comma-separated; `HOST` is the interface it
|
|
34
|
+
listens on.
|
|
32
35
|
|
|
33
36
|
## Connect a client
|
|
34
37
|
|
|
@@ -102,7 +105,9 @@ test/
|
|
|
102
105
|
|
|
103
106
|
- **The upstream is a service, built once.** `PrintFarm.layer` reads its URL and credentials
|
|
104
107
|
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.
|
|
108
|
+
built. The handlers get it already signed in and only delegate: `handlers.ts` maps each tool
|
|
109
|
+
onto a method of `farm.ts`, which knows nothing of tools, so a second caller of the farm, a
|
|
110
|
+
job or another surface, takes the service alone.
|
|
106
111
|
- **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
|
|
107
112
|
another shape and one that never comes each leave `farm.ts` as a `PrintFarmError` with a
|
|
108
113
|
message a model can act on. No handler catches anything.
|
|
@@ -67,8 +67,8 @@ export const GetCameraSnapshot = Action.make("get_camera_snapshot", {
|
|
|
67
67
|
mcp: { title: "Camera snapshot" },
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
70
|
+
// Destructive, MCP's default for a write: a print sets a machine to work and spends material,
|
|
71
|
+
// which is more than adding a record. A second call is refused, so it is not idempotent.
|
|
72
72
|
export const StartPrint = Action.make("start_print", {
|
|
73
73
|
...tool,
|
|
74
74
|
description:
|
|
@@ -80,7 +80,7 @@ export const StartPrint = Action.make("start_print", {
|
|
|
80
80
|
},
|
|
81
81
|
success: Printer,
|
|
82
82
|
readOnly: false,
|
|
83
|
-
mcp: { title: "Start a print"
|
|
83
|
+
mcp: { title: "Start a print" },
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
// Idempotent: the same call again leaves the printer as the first left it.
|
|
@@ -95,7 +95,9 @@ export const SetPaused = Action.make("set_paused", {
|
|
|
95
95
|
},
|
|
96
96
|
success: Printer,
|
|
97
97
|
readOnly: false,
|
|
98
|
-
|
|
98
|
+
// Destructive, MCP's default for a write: a pause interrupts a running print. Only a write
|
|
99
|
+
// that only adds, such as creating a record, says `destructiveHint: false`.
|
|
100
|
+
mcp: { title: "Pause or resume a print", idempotentHint: true },
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
// Destructive, which is MCP's default for a write, so no `destructiveHint` is stated.
|
|
@@ -8,8 +8,9 @@ import { routes } from "./routes.js";
|
|
|
8
8
|
|
|
9
9
|
// Entrypoint: `node --import tsx examples/mcp-tools/src/server.ts`, with FARM_URL, FARM_USER
|
|
10
10
|
// and FARM_PASSWORD set. MCP over HTTP, for clients that connect to a URL.
|
|
11
|
-
//
|
|
12
|
-
// interface by default
|
|
11
|
+
// These tools are public: no route asks for a token, so where the server listens is its access
|
|
12
|
+
// control, the loopback interface by default or a network only trusted clients are on. Tools
|
|
13
|
+
// that need a caller take a verifier, as docs/ActionMcp.md's "Protect tools" shows.
|
|
13
14
|
const server = Layer.unwrap(
|
|
14
15
|
Effect.gen(function* () {
|
|
15
16
|
const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
|
|
@@ -79,11 +79,11 @@ describe("the tools, over MCP", () => {
|
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
name: "start_print",
|
|
82
|
-
annotations: { title: "Start a print", ...hints(false,
|
|
82
|
+
annotations: { title: "Start a print", ...hints(false, true, false) },
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
name: "set_paused",
|
|
86
|
-
annotations: { title: "Pause or resume a print", ...hints(false,
|
|
86
|
+
annotations: { title: "Pause or resume a print", ...hints(false, true, true) },
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
name: "cancel_print",
|
|
@@ -23,6 +23,7 @@ node --import tsx examples/notes-service/src/cli.ts --help
|
|
|
23
23
|
echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --title Plan
|
|
24
24
|
node --import tsx examples/notes-service/src/cli.ts list-notes
|
|
25
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
|
|
26
27
|
|
|
27
28
|
vp test examples/notes-service
|
|
28
29
|
```
|
|
@@ -36,8 +37,8 @@ codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES
|
|
|
36
37
|
|
|
37
38
|
`src/stdio.ts` is the other way in: a subprocess the host starts, with a store of its own.
|
|
38
39
|
|
|
39
|
-
The server reads `PORT` (default 3000) and `PUBLIC_URL`, the
|
|
40
|
-
is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
|
|
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.
|
|
41
42
|
Every command asks for `NOTES_TOKEN`, the public `get-status` included: without it a command
|
|
42
43
|
prints one message on stderr saying to set it, and exits 1. `--help` needs none.
|
|
43
44
|
|
|
@@ -77,7 +78,8 @@ test/
|
|
|
77
78
|
`contracts/` or `handlers/` folder.
|
|
78
79
|
- **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
|
|
79
80
|
import no server code, so a browser or a CLI imports them. The lowercase files beside them run
|
|
80
|
-
on the server.
|
|
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).
|
|
81
83
|
- **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
|
|
82
84
|
picks the store and the HTTP server, `stdio.ts` names the local user, a test provides its own
|
|
83
85
|
store. Nothing below an entrypoint knows which it got.
|
|
@@ -99,6 +101,7 @@ test/
|
|
|
99
101
|
| The same actions over stdio, the host naming the caller | `src/stdio.ts` |
|
|
100
102
|
| A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
|
|
101
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` |
|
|
102
105
|
| The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
|
|
103
106
|
| A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
|
|
104
107
|
| A promise client handling each declared error by its tag | `src/browser.ts` |
|
|
@@ -5,6 +5,13 @@ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
|
5
5
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
6
|
import { Http } from "./Api.js";
|
|
7
7
|
|
|
8
|
+
// The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
|
|
9
|
+
// a blank line. A body line that starts as a title would is written with a backslash before it,
|
|
10
|
+
// as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
|
|
11
|
+
const escape = (body: string) => body.replaceAll(/^(?=\\*# )/gmu, "\\");
|
|
12
|
+
|
|
13
|
+
const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
|
|
14
|
+
|
|
8
15
|
// A command of your own, for what no single action does: this one calls two and writes a file.
|
|
9
16
|
// It is a native Effect CLI command, so its syntax and its output are what you write: `notes
|
|
10
17
|
// export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
|
|
@@ -22,7 +29,7 @@ const exportNotes = Command.make(
|
|
|
22
29
|
|
|
23
30
|
yield* files.writeFileString(
|
|
24
31
|
file,
|
|
25
|
-
whole.map((note) => `# ${note.title}\n\n${note.body}`).join("\n\n"),
|
|
32
|
+
whole.map((note) => `# ${note.title}\n\n${escape(note.body)}`).join("\n\n"),
|
|
26
33
|
);
|
|
27
34
|
|
|
28
35
|
yield* Console.log(`Wrote ${whole.length} notes to ${file}`);
|
|
@@ -40,6 +47,47 @@ const exportNotes = Command.make(
|
|
|
40
47
|
),
|
|
41
48
|
).pipe(Command.withDescription("Write every note to a file, as Markdown."));
|
|
42
49
|
|
|
50
|
+
// The other half of `export`: it reads such a file and creates its notes with one call of the
|
|
51
|
+
// bulk action, so the server checks every note before it writes any, and a file of more than
|
|
52
|
+
// the action's 100 is refused whole. The command reads the file and nothing more.
|
|
53
|
+
// `Argument.FileText` reads it, and a missing file is a usage error naming it, where
|
|
54
|
+
// `Argument.String` and `FileSystem` would print the platform's error. Text before the first
|
|
55
|
+
// title is refused, not dropped: the file is not one `export` wrote. The derived `import-notes`
|
|
56
|
+
// stays beside it, taking the notes as a JSON array in `--notes`.
|
|
57
|
+
const importNotes = Command.make(
|
|
58
|
+
"import",
|
|
59
|
+
{
|
|
60
|
+
text: Argument.FileText("file").pipe(
|
|
61
|
+
Argument.withDescription("A Markdown file as `notes export` writes it."),
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
({ text }) =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const client = yield* ActionHttp.client(Http);
|
|
67
|
+
|
|
68
|
+
if (text === "") return yield* Console.log("Created 0 notes");
|
|
69
|
+
|
|
70
|
+
if (!text.startsWith("# ")) {
|
|
71
|
+
return yield* new CliError.UserError({
|
|
72
|
+
cause: text.split("\n", 1)[0],
|
|
73
|
+
userMessage: "The file does not start with a `# ` title, as `notes export` writes one.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const notes = text.split(/\n\n(?=# )/u).map((note) => {
|
|
78
|
+
const [title = "", ...body] = note.slice(2).split("\n\n");
|
|
79
|
+
|
|
80
|
+
return { title, body: unescape(body.join("\n\n")) };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { notes: created } = yield* client.importNotes({ notes });
|
|
84
|
+
|
|
85
|
+
yield* Console.log(`Created ${created.length} notes`);
|
|
86
|
+
}).pipe(ActionCli.toUserError(Http)),
|
|
87
|
+
).pipe(
|
|
88
|
+
Command.withDescription("Create the notes of a file `export` wrote, all or none, at most 100."),
|
|
89
|
+
);
|
|
90
|
+
|
|
43
91
|
// A command per action of the binding, a flag per input field, each calling the server over
|
|
44
92
|
// HTTP. It imports the binding alone, never a handler. It is apart from cli.ts so a test runs
|
|
45
93
|
// it: test/commands.test.ts.
|
|
@@ -54,7 +102,7 @@ export const commands = ActionCli.remote(Http, {
|
|
|
54
102
|
},
|
|
55
103
|
// `Command.withSubcommands` on the result would replace the derived commands, so your own
|
|
56
104
|
// go here.
|
|
57
|
-
subcommands: [exportNotes],
|
|
105
|
+
subcommands: [exportNotes, importNotes],
|
|
58
106
|
});
|
|
59
107
|
|
|
60
108
|
// Where the server is and who is calling, read from config: the `HttpClient` every command
|
|
@@ -32,8 +32,13 @@ const Id = Schema.String.annotate({
|
|
|
32
32
|
description: "The note's id, as the list of your notes shows it.",
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// A check's `message` is what a caller reads when the value fails it, in place of the
|
|
36
|
+
// generic "Expected a value with a length of at least 1". A model and a CLI user read it too.
|
|
37
|
+
const Title = Schema.String.check(
|
|
38
|
+
Schema.isMinLength(1, { message: "A note needs a title." }),
|
|
39
|
+
Schema.isPattern(/^[^\r\n\u2028\u2029]*$/u, { message: "A title is one line." }),
|
|
40
|
+
).annotate({
|
|
41
|
+
description: "A short name for the note: one line, at least one character.",
|
|
37
42
|
});
|
|
38
43
|
|
|
39
44
|
const Body = Schema.String.annotate({ description: "The note's whole text." });
|
|
@@ -77,7 +82,8 @@ export const UpdateNote = Action.make("updateNote", {
|
|
|
77
82
|
error: [NoteNotFound, NoteChanged],
|
|
78
83
|
readOnly: false,
|
|
79
84
|
caller: CurrentUser,
|
|
80
|
-
|
|
85
|
+
// No `destructiveHint`: an update replaces the body, so it is destructive, MCP's default for
|
|
86
|
+
// a write. `createNote` and `importNotes`, which only add, say `false`.
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
export const DeleteNote = Action.make("deleteNote", {
|
|
@@ -88,4 +94,26 @@ export const DeleteNote = Action.make("deleteNote", {
|
|
|
88
94
|
caller: CurrentUser,
|
|
89
95
|
});
|
|
90
96
|
|
|
91
|
-
|
|
97
|
+
// Work every surface should offer is an action, a bulk import included: the HTTP API, the MCP
|
|
98
|
+
// tools and the CLI then all have it, and the whole list is decoded, so checked, before the
|
|
99
|
+
// handler writes any of it. A command of your own only reads the file and calls this once
|
|
100
|
+
// (commands.ts). `importNotes` adds and removes nothing, so `destructiveHint` is `false`.
|
|
101
|
+
export const ImportNotes = Action.make("importNotes", {
|
|
102
|
+
description:
|
|
103
|
+
"Create several notes at once, such as those `notes export` wrote. Every note is checked " +
|
|
104
|
+
"before any is written: an import holding one that is not valid writes none.",
|
|
105
|
+
input: {
|
|
106
|
+
notes: Schema.Array(Schema.Struct({ title: Title, body: Body }))
|
|
107
|
+
.check(
|
|
108
|
+
Schema.isMinLength(1, { message: "An import holds at least one note." }),
|
|
109
|
+
Schema.isMaxLength(100, { message: "An import holds at most 100 notes." }),
|
|
110
|
+
)
|
|
111
|
+
.annotate({ description: "The notes to create, in order: from 1 to 100." }),
|
|
112
|
+
},
|
|
113
|
+
success: { notes: Schema.Array(Note) },
|
|
114
|
+
readOnly: false,
|
|
115
|
+
caller: CurrentUser,
|
|
116
|
+
mcp: { destructiveHint: false },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const Notes = [ListNotes, GetNote, CreateNote, UpdateNote, DeleteNote, ImportNotes] as const;
|
|
@@ -6,8 +6,9 @@ import { Notes } from "./Notes.js";
|
|
|
6
6
|
import { NoteStore } from "./store.js";
|
|
7
7
|
|
|
8
8
|
// The builder runs once, at startup, however many surfaces serve these actions: it is where a
|
|
9
|
-
// handler gets
|
|
10
|
-
//
|
|
9
|
+
// handler gets the services that last the process, never the caller. `CurrentUser` is read per
|
|
10
|
+
// call, inside each handler. Each handler is a thin step from the contract to the store, and
|
|
11
|
+
// none checks a scope: `authorize` has run before it.
|
|
11
12
|
export const notes = Action.implement(
|
|
12
13
|
Notes,
|
|
13
14
|
Effect.gen(function* () {
|
|
@@ -27,6 +28,11 @@ export const notes = Action.implement(
|
|
|
27
28
|
updateNote: ({ id, body, revision }) =>
|
|
28
29
|
Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
|
|
29
30
|
deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),
|
|
31
|
+
importNotes: ({ notes }) =>
|
|
32
|
+
Effect.map(
|
|
33
|
+
Effect.flatMap(CurrentUser, (user) => store.createAll(user.id, notes)),
|
|
34
|
+
(created) => ({ notes: created }),
|
|
35
|
+
),
|
|
30
36
|
};
|
|
31
37
|
}),
|
|
32
38
|
{ authorize },
|
|
@@ -11,6 +11,11 @@ export class NoteStore extends Context.Service<
|
|
|
11
11
|
readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
|
|
12
12
|
readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
|
|
13
13
|
readonly create: (owner: string, title: string, body: string) => Effect.Effect<Note>;
|
|
14
|
+
/** Every note or none: a database does this in one transaction. */
|
|
15
|
+
readonly createAll: (
|
|
16
|
+
owner: string,
|
|
17
|
+
notes: ReadonlyArray<{ readonly title: string; readonly body: string }>,
|
|
18
|
+
) => Effect.Effect<ReadonlyArray<Note>>;
|
|
14
19
|
readonly update: (
|
|
15
20
|
owner: string,
|
|
16
21
|
id: string,
|
|
@@ -48,6 +53,12 @@ export class NoteStore extends Context.Service<
|
|
|
48
53
|
get,
|
|
49
54
|
create: (owner, title, body) =>
|
|
50
55
|
Effect.sync(() => save(owner, { id: crypto.randomUUID(), title, body, revision: 1 })),
|
|
56
|
+
createAll: (owner, all) =>
|
|
57
|
+
Effect.sync(() =>
|
|
58
|
+
all.map(({ title, body }) =>
|
|
59
|
+
save(owner, { id: crypto.randomUUID(), title, body, revision: 1 }),
|
|
60
|
+
),
|
|
61
|
+
),
|
|
51
62
|
// No `yield*` between the read and the write: a fiber may be set aside at one, and two
|
|
52
63
|
// updates that each read revision 1 before either wrote would both succeed. A database
|
|
53
64
|
// does this with a conditional update or a transaction.
|
|
@@ -10,12 +10,12 @@ import { routes } from "./routes.js";
|
|
|
10
10
|
// and starts Node's HTTP server; nothing below it knows which of those it got.
|
|
11
11
|
const server = Layer.unwrap(
|
|
12
12
|
Effect.gen(function* () {
|
|
13
|
+
// The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
|
|
14
|
+
const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
|
|
13
15
|
const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
|
|
14
16
|
|
|
15
17
|
return HttpRouter.serve(routes).pipe(
|
|
16
|
-
|
|
17
|
-
// config: `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
18
|
-
Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port })),
|
|
18
|
+
Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
|
|
19
19
|
// Request bodies have no size limit unless the host sets one. This covers every route.
|
|
20
20
|
Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))),
|
|
21
21
|
Layer.provide(NoteStore.layerMemory),
|