@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.
- package/README.md +2 -2
- package/dist/Action.d.mts +1 -1
- package/dist/ActionCli.d.mts +4 -6
- package/dist/ActionCli.mjs +11 -9
- package/dist/ActionHttp.mjs +18 -30
- package/dist/ActionMcp.d.mts +16 -7
- package/dist/ActionMcp.mjs +76 -89
- package/dist/ActionRpc.mjs +17 -29
- package/dist/ActionToolkit.mjs +26 -11
- package/dist/Authentication.mjs +1 -2
- package/dist/Testing.d.mts +6 -73
- package/dist/Testing.mjs +3 -102
- package/dist/authentication/provider.mjs +1 -6
- package/dist/authentication/refusal.mjs +14 -4
- package/dist/cli/commands.mjs +8 -8
- package/dist/contract/call.d.mts +2 -1
- package/dist/contract/call.mjs +14 -1
- package/dist/contract/errors.d.mts +2 -3
- package/dist/contract/errors.mjs +6 -12
- package/dist/contract/implementation.mjs +33 -5
- package/dist/contract/rules.d.mts +7 -0
- package/dist/contract/rules.mjs +50 -3
- package/dist/http/client.mjs +4 -7
- package/dist/mcp/client.d.mts +87 -0
- package/dist/mcp/client.mjs +122 -0
- package/dist/mcp/protocol.mjs +25 -37
- package/docs/Action.md +6 -5
- package/docs/ActionCli.md +4 -3
- package/docs/ActionMcp-failures.md +6 -2
- package/docs/ActionMcp.md +30 -33
- package/docs/ActionToolkit-failures.md +2 -1
- package/docs/ActionToolkit.md +2 -2
- package/docs/Authentication-failures.md +2 -2
- package/docs/Authentication.md +10 -5
- package/docs/CONTEXT.md +1 -1
- package/docs/Testing-failures.md +3 -3
- package/docs/Testing.md +16 -24
- package/docs/guarantees.md +15 -14
- package/docs/setup-failures.md +2 -2
- package/docs/setup.md +5 -5
- package/examples/kitchen-sink/README.md +26 -26
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +3 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +13 -6
- package/examples/kitchen-sink/test/routes.test.ts +4 -3
- package/examples/live-rpc/README.md +7 -2
- package/examples/mcp-tools/test/routes.test.ts +7 -6
- package/examples/notes-service/README.md +13 -3
- package/examples/notes-service/src/notes/Notes.ts +4 -2
- package/examples/notes-service/src/routes.ts +24 -14
- package/examples/notes-service/src/worker.ts +20 -0
- package/examples/notes-service/test/commands.test.ts +2 -1
- package/examples/notes-service/test/routes.test.ts +6 -3
- package/examples/notes-service/test/worker.test.ts +34 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +2 -1
- package/package.json +5 -5
- package/dist/http/declared.mjs +0 -20
- package/dist/mcp/protocol.d.mts +0 -18
- package/dist/toolkit/tools.mjs +0 -29
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { BuiltIn } from "../contract/errors.mjs";
|
|
2
|
+
import { Call } from "../contract/call.mjs";
|
|
3
|
+
import { Action_d_exports, Any } from "../Action.mjs";
|
|
4
|
+
import { Any as Any$1, VerifierError } from "../authentication/provider.mjs";
|
|
5
|
+
import { Effect, Schema } from "effect";
|
|
6
|
+
import { Headers, HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
|
|
7
|
+
//#region src/mcp/client.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* An MCP request's parameters, as `request` takes them: JSON, with any `_meta`, leaving
|
|
10
|
+
* out an `undefined` one, as a caller may. A helper passing on whole params types them as this,
|
|
11
|
+
* and a tool's `arguments` as `Schema.Json`, never `unknown`.
|
|
12
|
+
*/
|
|
13
|
+
export interface Params {
|
|
14
|
+
readonly _meta?: {
|
|
15
|
+
readonly [key: string]: Schema.Json;
|
|
16
|
+
};
|
|
17
|
+
readonly [key: string]: Schema.Json | undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Where `client` sends, through what client, and what the endpoint's authentication declares. */
|
|
20
|
+
export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined> {
|
|
21
|
+
/**
|
|
22
|
+
* The endpoint, resolved by the `HttpClient`: relative under `Testing.layer`. Defaults to `/mcp`,
|
|
23
|
+
* the default `layerHttp` path.
|
|
24
|
+
*/
|
|
25
|
+
readonly url?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token is
|
|
28
|
+
* `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
|
|
29
|
+
*/
|
|
30
|
+
readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
|
|
31
|
+
/**
|
|
32
|
+
* The endpoint's descriptor, as `layerHttp` takes it: an error its verifier declares, the
|
|
33
|
+
* endpoint's HTTP answer for a protected tool, is then that error's decoded value, as over
|
|
34
|
+
* `ActionHttp.client`.
|
|
35
|
+
*/
|
|
36
|
+
readonly authentication?: D;
|
|
37
|
+
}
|
|
38
|
+
/** Where a `request` goes, and with what headers. */
|
|
39
|
+
export interface RequestOptions {
|
|
40
|
+
/**
|
|
41
|
+
* The endpoint, resolved by the `HttpClient` that sends the request: relative under `Testing.layer`.
|
|
42
|
+
* Defaults to `/mcp`, as `client`'s does; converting to a web `Request` needs an absolute
|
|
43
|
+
* one.
|
|
44
|
+
*/
|
|
45
|
+
readonly url?: string;
|
|
46
|
+
readonly headers?: Headers.Input;
|
|
47
|
+
}
|
|
48
|
+
declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<"McpCallError", {
|
|
49
|
+
readonly message: Schema.String;
|
|
50
|
+
}>, import("effect/Cause").YieldableError>;
|
|
51
|
+
/**
|
|
52
|
+
* An answer a client method cannot read as the action's success or a declared error, such as
|
|
53
|
+
* the native server's message for invalid arguments or an unknown tool, or a result without
|
|
54
|
+
* its structured content. Its message holds the answer. Structured content that does not
|
|
55
|
+
* decode as the success is a `SchemaError` instead, and a request that fails an
|
|
56
|
+
* `HttpClientError`.
|
|
57
|
+
*/
|
|
58
|
+
export declare class McpCallError extends McpCallError_base {}
|
|
59
|
+
type CallError<A extends Any, D> = A["error"][number]["Type"] | VerifierError<A, D>["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
|
|
60
|
+
/** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
|
|
61
|
+
export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D>>>; };
|
|
62
|
+
/**
|
|
63
|
+
* A client of an MCP endpoint served by `layerHttp`, one method per action calling
|
|
64
|
+
* its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
|
|
65
|
+
* encoded with the action's schema, and the success decoded from its structured content, each
|
|
66
|
+
* media field from the blocks whose `_meta` names it, and a success that is media, or whose
|
|
67
|
+
* every field is, from its blocks alone. A declared error the tool returns, the action's own
|
|
68
|
+
* or a refusal, is its decoded value, and so is a refusal the endpoint's authentication
|
|
69
|
+
* answers with, or an error its verifier declares, given the `authentication`: a test telling a 401 or 403 from a tool result asserts the status of a request
|
|
70
|
+
* `request` builds. The argument may be omitted when `{}` is a valid input. Requires the
|
|
71
|
+
* native `HttpClient`, such as the one `Testing.layer` provides.
|
|
72
|
+
*/
|
|
73
|
+
export declare function client<const Actions extends ReadonlyArray<Any>, D extends Any$1 | undefined = undefined>(actions: Actions, options?: ClientOptions<D>): Effect.Effect<Client<Actions, D>, never, HttpClient.HttpClient>;
|
|
74
|
+
/**
|
|
75
|
+
* One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
|
|
76
|
+
* as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
|
|
77
|
+
* `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
|
|
78
|
+
* `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
|
|
79
|
+
* `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
|
|
80
|
+
* version is always the request's own. The test sends it: `HttpClient.execute` answers the
|
|
81
|
+
* response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
|
|
82
|
+
* gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
|
|
83
|
+
* asserting on what `client` decodes away, such as `tools/list`, a refusal's challenge, or
|
|
84
|
+
* a call its types would not send.
|
|
85
|
+
*/
|
|
86
|
+
export declare const request: (method: string, params?: Params, { headers, url }?: RequestOptions) => HttpClientRequest.HttpClientRequest;
|
|
87
|
+
//#endregion
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Refusal } from "../contract/errors.mjs";
|
|
2
|
+
import { Anyone, assertOnce, projectedErrors } from "../contract/rules.mjs";
|
|
3
|
+
import { checked } from "../contract/call.mjs";
|
|
4
|
+
import { defaultPath, httpProtocol, liftOf, unliftedFrom } from "./protocol.mjs";
|
|
5
|
+
import { Effect, Option, Predicate, Schema, identity } from "effect";
|
|
6
|
+
import { McpSchema } from "effect/ai";
|
|
7
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
8
|
+
import { Sse } from "effect/encoding";
|
|
9
|
+
//#region src/mcp/client.ts
|
|
10
|
+
/**
|
|
11
|
+
* One stateless MCP request of `method` with `params`, as `layerHttp` serves it: the headers
|
|
12
|
+
* routing it, which repeat what its body says, and its JSON-RPC body. The client metadata goes in
|
|
13
|
+
* `_meta`, under any given, such as a `progressToken`; the protocol version is always the
|
|
14
|
+
* request's own.
|
|
15
|
+
*/
|
|
16
|
+
const statelessRequest = (method, params) => {
|
|
17
|
+
const { protocolVersion } = httpProtocol;
|
|
18
|
+
const name = method === "resources/read" ? params.uri : params.name;
|
|
19
|
+
return {
|
|
20
|
+
headers: {
|
|
21
|
+
accept: "application/json, text/event-stream",
|
|
22
|
+
"mcp-protocol-version": protocolVersion,
|
|
23
|
+
"mcp-method": method,
|
|
24
|
+
...Predicate.isString(name) ? { "mcp-name": name } : {}
|
|
25
|
+
},
|
|
26
|
+
body: {
|
|
27
|
+
jsonrpc: "2.0",
|
|
28
|
+
id: 1,
|
|
29
|
+
method,
|
|
30
|
+
params: {
|
|
31
|
+
...params,
|
|
32
|
+
_meta: {
|
|
33
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
34
|
+
"io.modelcontextprotocol/clientInfo": {
|
|
35
|
+
name: "effect-actions",
|
|
36
|
+
version: "0"
|
|
37
|
+
},
|
|
38
|
+
...params._meta,
|
|
39
|
+
"io.modelcontextprotocol/protocolVersion": protocolVersion
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* An answer a client method cannot read as the action's success or a declared error, such as
|
|
47
|
+
* the native server's message for invalid arguments or an unknown tool, or a result without
|
|
48
|
+
* its structured content. Its message holds the answer. Structured content that does not
|
|
49
|
+
* decode as the success is a `SchemaError` instead, and a request that fails an
|
|
50
|
+
* `HttpClientError`.
|
|
51
|
+
*/
|
|
52
|
+
var McpCallError = class extends Schema.TaggedError()("McpCallError", { message: Schema.String }) {};
|
|
53
|
+
const textOf = (block) => block?.type === "text" ? block.text : void 0;
|
|
54
|
+
const ToolCallReply = Schema.Union([Schema.Struct({ result: McpSchema.CallToolResult }), Schema.Struct({ error: Schema.Struct({
|
|
55
|
+
code: Schema.Finite,
|
|
56
|
+
message: Schema.String
|
|
57
|
+
}) })]);
|
|
58
|
+
const decodeReply = Schema.decodeUnknownOption(Schema.fromJsonString(ToolCallReply));
|
|
59
|
+
const replyOf = (response, text) => {
|
|
60
|
+
if (!(response.headers["content-type"] ?? "").startsWith("text/event-stream")) return decodeReply(text);
|
|
61
|
+
const data = [];
|
|
62
|
+
Sse.makeParser((event) => {
|
|
63
|
+
if (Predicate.isTagged(event, "Event")) data.push(event.data);
|
|
64
|
+
}).feed(text);
|
|
65
|
+
return Option.fromNullishOr(data.flatMap((message) => Option.toArray(decodeReply(message))).at(-1));
|
|
66
|
+
};
|
|
67
|
+
const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
|
|
68
|
+
const callTool = (client, url, action, lift, refusedWith, input) => {
|
|
69
|
+
const { name } = action;
|
|
70
|
+
const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
|
|
71
|
+
return Effect.gen(function* () {
|
|
72
|
+
const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
|
|
73
|
+
const response = yield* client.execute(request("tools/call", {
|
|
74
|
+
name,
|
|
75
|
+
arguments: encoded
|
|
76
|
+
}, { url }));
|
|
77
|
+
const text = yield* response.text;
|
|
78
|
+
if (response.status !== 200) return yield* failWith(refusedWith, text, other(`answered ${response.status}: ${text}`));
|
|
79
|
+
const reply = replyOf(response, text);
|
|
80
|
+
if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
|
|
81
|
+
if ("error" in reply.value) {
|
|
82
|
+
const { code, message } = reply.value.error;
|
|
83
|
+
return yield* Effect.fail(other(`failed with ${code}: ${message}`));
|
|
84
|
+
}
|
|
85
|
+
const { result } = reply.value;
|
|
86
|
+
if (result.isError === true) {
|
|
87
|
+
const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
|
|
88
|
+
return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
|
|
89
|
+
}
|
|
90
|
+
if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
|
|
91
|
+
return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(unliftedFrom(lift, result));
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
function client(actions, { url = defaultPath, transformClient = identity, authentication } = {}) {
|
|
95
|
+
assertOnce("action", actions);
|
|
96
|
+
return Effect.map(HttpClient.HttpClient, (native) => {
|
|
97
|
+
const http = transformClient(native);
|
|
98
|
+
return Object.fromEntries(actions.map((action) => {
|
|
99
|
+
const lift = liftOf(action.success);
|
|
100
|
+
const refusedWith = [...Refusal.members, ...action.caller === Anyone ? [] : authentication?.error ?? []];
|
|
101
|
+
return [action.name, checked(action, (input) => callTool(http, url, action, lift, refusedWith, input))];
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
|
|
107
|
+
* as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
|
|
108
|
+
* `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
|
|
109
|
+
* `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
|
|
110
|
+
* `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
|
|
111
|
+
* version is always the request's own. The test sends it: `HttpClient.execute` answers the
|
|
112
|
+
* response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
|
|
113
|
+
* gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
|
|
114
|
+
* asserting on what `client` decodes away, such as `tools/list`, a refusal's challenge, or
|
|
115
|
+
* a call its types would not send.
|
|
116
|
+
*/
|
|
117
|
+
const request = (method, params = {}, { headers = {}, url = defaultPath } = {}) => {
|
|
118
|
+
const { headers: routingHeaders, body } = statelessRequest(method, params);
|
|
119
|
+
return HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.setHeaders(routingHeaders), HttpClientRequest.bodyJsonUnsafe(body));
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
export { McpCallError, client, request, statelessRequest };
|
package/dist/mcp/protocol.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { unsuspended } from "../contract/rules.mjs";
|
|
|
2
2
|
import { Media, isMarked, kindOf } from "../contract/media.mjs";
|
|
3
3
|
import { Predicate, Schema, SchemaAST } from "effect";
|
|
4
4
|
import { McpProtocol } from "effect/ai";
|
|
5
|
+
import { Base64 } from "effect/encoding";
|
|
5
6
|
//#region src/mcp/protocol.ts
|
|
6
7
|
const httpProtocol = McpProtocol.v2026_07_28;
|
|
7
8
|
const protocols = [
|
|
@@ -10,42 +11,6 @@ const protocols = [
|
|
|
10
11
|
McpProtocol.v2025_06_18
|
|
11
12
|
];
|
|
12
13
|
const defaultPath = "/mcp";
|
|
13
|
-
const mcpNameHeaderOf = (method, params) => method === "resources/read" ? params.uri : params.name;
|
|
14
|
-
/**
|
|
15
|
-
* One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it:
|
|
16
|
-
* the headers routing it, which repeat what its body says, and its JSON-RPC body. The client
|
|
17
|
-
* metadata goes in `_meta`, under any given, such as a `progressToken`; the protocol version
|
|
18
|
-
* is always the request's own.
|
|
19
|
-
*/
|
|
20
|
-
const statelessRequest = (method, params) => {
|
|
21
|
-
const { protocolVersion } = httpProtocol;
|
|
22
|
-
const name = mcpNameHeaderOf(method, params);
|
|
23
|
-
return {
|
|
24
|
-
headers: {
|
|
25
|
-
accept: "application/json, text/event-stream",
|
|
26
|
-
"mcp-protocol-version": protocolVersion,
|
|
27
|
-
"mcp-method": method,
|
|
28
|
-
...Predicate.isString(name) ? { "mcp-name": name } : {}
|
|
29
|
-
},
|
|
30
|
-
body: {
|
|
31
|
-
jsonrpc: "2.0",
|
|
32
|
-
id: 1,
|
|
33
|
-
method,
|
|
34
|
-
params: {
|
|
35
|
-
...params,
|
|
36
|
-
_meta: {
|
|
37
|
-
"io.modelcontextprotocol/clientCapabilities": {},
|
|
38
|
-
"io.modelcontextprotocol/clientInfo": {
|
|
39
|
-
name: "effect-actions",
|
|
40
|
-
version: "0"
|
|
41
|
-
},
|
|
42
|
-
...params._meta,
|
|
43
|
-
"io.modelcontextprotocol/protocolVersion": protocolVersion
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
};
|
|
49
14
|
const fieldKey = "effect-actions/field";
|
|
50
15
|
const isMedia = Schema.is(Media);
|
|
51
16
|
const isMediaArray = Schema.is(Schema.Array(Media));
|
|
@@ -145,5 +110,28 @@ const liftedFrom = (lift, success) => lift.fields.flatMap(({ name, kind, many })
|
|
|
145
110
|
value: media
|
|
146
111
|
}));
|
|
147
112
|
});
|
|
113
|
+
const mediaOf = ({ name, kind, many }, result) => {
|
|
114
|
+
const values = result.content.flatMap((block) => block.type === kind && block._meta?.["effect-actions/field"] === name ? [{
|
|
115
|
+
data: Base64.encode(block.data),
|
|
116
|
+
mimeType: block.mimeType
|
|
117
|
+
}] : []);
|
|
118
|
+
return many || values.length > 1 ? values : values[0];
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* The encoded success a tool result carries, `liftedFrom` undone: its structured content, each
|
|
122
|
+
* lifted field from the blocks whose `_meta` names it, and a success that is media from its
|
|
123
|
+
* blocks alone.
|
|
124
|
+
*/
|
|
125
|
+
const unliftedFrom = (lift, result) => {
|
|
126
|
+
const [whole] = lift.fields;
|
|
127
|
+
if (whole !== void 0 && whole.name === void 0) return mediaOf(whole, result);
|
|
128
|
+
const { structuredContent = {} } = result;
|
|
129
|
+
if (!Predicate.isObject(structuredContent)) return structuredContent;
|
|
130
|
+
const lifted = new Set(lift.fields.map(({ name }) => name));
|
|
131
|
+
return Object.fromEntries([...Object.entries(structuredContent).filter(([key]) => !lifted.has(key)), ...lift.fields.flatMap((field) => {
|
|
132
|
+
const value = mediaOf(field, result);
|
|
133
|
+
return field.name === void 0 || value === void 0 ? [] : [[field.name, value]];
|
|
134
|
+
})]);
|
|
135
|
+
};
|
|
148
136
|
//#endregion
|
|
149
|
-
export { defaultPath, fieldKey, httpProtocol, isMisplaced, liftOf, liftedFrom, protocols,
|
|
137
|
+
export { defaultPath, fieldKey, httpProtocol, isMisplaced, liftOf, liftedFrom, protocols, unliftedFrom };
|
package/docs/Action.md
CHANGED
|
@@ -29,7 +29,7 @@ Import `@gjermundgaraba/effect-actions/Action`.
|
|
|
29
29
|
| `Action`, `Any`, `Implementation` | Concrete and erased contracts, and bound implementations. |
|
|
30
30
|
| `AnyImplementation`, `AnyImplementation<A>` | Any implementation, or any of actions `A`, erased. |
|
|
31
31
|
| `BuildServices`, `BuildError` | What builders read and fail with at startup. |
|
|
32
|
-
| `implementation.actions` | Its exact contracts, as `
|
|
32
|
+
| `implementation.actions` | Its exact contracts, as `ActionMcp.client` takes them. |
|
|
33
33
|
| `Handler`, `Handlers`, `Authorize` | A typed handler, a list's record of them, and an authorizer. |
|
|
34
34
|
| `Options`, `Mcp` | What `make` takes, and its `mcp` options. |
|
|
35
35
|
|
|
@@ -197,8 +197,8 @@ Derive the rule from `action.readOnly`, never from an `mcp` hint: changing an ac
|
|
|
197
197
|
- **Name `scopes` in a `Forbidden` only where re-authorizing can grant them.** An OAuth
|
|
198
198
|
challenge answers them, which a client follows only where the server publishes discovery
|
|
199
199
|
([Authentication.md](Authentication.md#publish-oauth-discovery)); with fixed tokens, such as
|
|
200
|
-
API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error).
|
|
201
|
-
|
|
200
|
+
API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error). What a
|
|
201
|
+
refusal answers on each surface is in [guarantees.md](guarantees.md#wire-behavior).
|
|
202
202
|
- **What a plain authorizer yields is a request-time requirement of every surface.** To read a
|
|
203
203
|
store, make `authorize` an Effect that yields the store once and returns the function, as a
|
|
204
204
|
builder does
|
|
@@ -225,8 +225,9 @@ failure compiles. What each surface answers is in [guarantees.md](guarantees.md#
|
|
|
225
225
|
- **Fail with `Action.InvalidInput` for input that decodes but cannot be served**, naming
|
|
226
226
|
`issues` of your own, instead of declaring a second error for bad input
|
|
227
227
|
([kitchen-sink/src/attachments/handlers.ts](../examples/kitchen-sink/src/attachments/handlers.ts)).
|
|
228
|
-
- **For input you decode yourself
|
|
229
|
-
surfaces do:
|
|
228
|
+
- **For input you decode yourself** on the server, such as a header or a route parameter,
|
|
229
|
+
answer as the surfaces do, with every issue:
|
|
230
|
+
`Schema.decodeUnknownEffect(schema)(value, { errors: "all" }).pipe(Effect.mapError(Action.InvalidInput.fromSchemaError))`.
|
|
230
231
|
- **Map another service's refusals before failing with them.** A client that decodes the
|
|
231
232
|
built-in errors, such as `ActionHttp.client`, hands a handler that service's `Unauthenticated`
|
|
232
233
|
or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
|
package/docs/ActionCli.md
CHANGED
|
@@ -148,9 +148,10 @@ In an aggregate, a command's options go under `commands`, keyed by the action's
|
|
|
148
148
|
### Add a command of your own
|
|
149
149
|
|
|
150
150
|
Work every surface should offer, a bulk import for one, is an action first: a command of your
|
|
151
|
-
own then only reads the file and calls it
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
own then only reads the file and calls it. Its client checks the input as it encodes it, naming
|
|
152
|
+
a bad entry by its path, so a decode of your own only repeats that check. `notes import <file>`
|
|
153
|
+
reads `Argument.FileText` and calls `importNotes`. For a syntax the options cannot express, such
|
|
154
|
+
as a flag named apart from its field, build the command with native `Command.make`, `Flag` and `Argument`, and pass it
|
|
154
155
|
in `subcommands`: `notes export <file>` takes `Argument.String("file")`
|
|
155
156
|
([notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)):
|
|
156
157
|
|
|
@@ -18,9 +18,10 @@ Search for a fragment of the message. The tasks are in [ActionMcp.md](ActionMcp.
|
|
|
18
18
|
- A protected tool's call answers 401 to a signed-in client after a while: its token expired. The client refreshes it, or signs in again, on the challenge's `invalid_token`.
|
|
19
19
|
- An MCP client gets an `isError` refusal, not the 401 or 403 it re-authorizes on: the handler sent a notification first, which starts the response, so its refusal is a tool result. Refuse in `authorize`.
|
|
20
20
|
- A client that opens with `initialize` gets 401 where it lists the tools or calls a public one, on an endpoint that also serves protected tools: a session's every request authenticates. Sign the client in, or serve the public tools on an endpoint of their own.
|
|
21
|
+
- A client that opens with `initialize`, such as Codex, gets 400 with JSON-RPC error `-32020`, `MCP-Protocol-Version header is required`, or 401 signed out where the endpoint serves a protected tool, and a `DELETE` gets 405: the endpoint is `requestScoped`, which speaks MCP 2026-07-28 alone and keeps no session. Connect that client to an endpoint without it, on a host that keeps a process running.
|
|
21
22
|
- A request over HTTP answers 400, `MCP-Session-Id header is required; send initialize to start a session`: its `MCP-Protocol-Version` header is absent or names a 2025 revision, so it is a session's request, and it follows no `initialize`. One answering 404 names a session that ended, or one of a process since restarted: the client opens another.
|
|
22
|
-
- A hand-written HTTP request answers 400 with JSON-RPC error `-32602`, `Required request metadata is missing` or `io.modelcontextprotocol/clientCapabilities request metadata is required`: it names 2026-07-28 in `MCP-Protocol-Version` and its `params._meta` lacks `io.modelcontextprotocol/protocolVersion`, for the first message, or `io.modelcontextprotocol/clientCapabilities`, for the second. Build it with `
|
|
23
|
-
- A hand-written HTTP request answers 400 with JSON-RPC error `-32020`, `MCP-Protocol-Version header is required`, `Mcp-Method header does not match request method` or `Mcp-Name header does not match request parameters`: those headers repeat a 2026-07-28 request's revision, method and `tools/call` name, and one is absent or differs from its body. Build it with `
|
|
23
|
+
- A hand-written HTTP request answers 400 with JSON-RPC error `-32602`, `Required request metadata is missing` or `io.modelcontextprotocol/clientCapabilities request metadata is required`: it names 2026-07-28 in `MCP-Protocol-Version` and its `params._meta` lacks `io.modelcontextprotocol/protocolVersion`, for the first message, or `io.modelcontextprotocol/clientCapabilities`, for the second. Build it with `ActionMcp.request`, or copy the `curl` of [mcp-tools](../examples/mcp-tools/README.md#connect-a-client).
|
|
24
|
+
- A hand-written HTTP request answers 400 with JSON-RPC error `-32020`, `MCP-Protocol-Version header is required`, `Mcp-Method header does not match request method` or `Mcp-Name header does not match request parameters`: those headers repeat a 2026-07-28 request's revision, method and `tools/call` name, and one is absent or differs from its body. Build it with `ActionMcp.request`.
|
|
24
25
|
- A hand-written HTTP request answers an empty 406: its `Accept` header does not list both `application/json` and `text/event-stream`, as `*/*` does not. Send both.
|
|
25
26
|
- A client asking for 2025-03-26 or 2024-11-05 is offered 2025-11-25, and disconnects if it cannot speak it: HTTP and stdio serve 2026-07-28, 2025-11-25 and 2025-06-18. Update the client.
|
|
26
27
|
- A request written by hand to a stdio server answers JSON-RPC error `-32602`, `Invalid request metadata`: it is neither preceded by `initialize` nor carries the 2026-07-28 metadata. Open with `initialize` and `notifications/initialized`, or give the request's `_meta` its `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities`.
|
|
@@ -29,3 +30,6 @@ Search for a fragment of the message. The tasks are in [ActionMcp.md](ActionMcp.
|
|
|
29
30
|
- A disallowed `Origin` gets 401, not 403: the endpoint's authentication, which runs before its Origin check, refused the request.
|
|
30
31
|
- Browser calls fail despite an allowed origin, the preflight answering 405, or 401 on an endpoint with `authentication`, which every method but `POST` meets first: `allowedOrigins` is no CORS. Add `HttpRouter.cors` ([browser clients](ActionMcp.md#allow-browser-clients)).
|
|
31
32
|
- `Tool <name> has an output schema but did not return structured content` raised by a Python MCP client for an action that returns nothing: its success is sent as `null`, which a Python SDK release before the fix of python-sdk#3345 reads as missing. Update the SDK.
|
|
33
|
+
- `MCP tools/call "<name>" answered 404`: the endpoint is not at `/mcp`, the default. Pass `ActionMcp.client` its `url`.
|
|
34
|
+
- `MCP tools/call "<name>" returned an error: Invalid parameters for tool ...`: the endpoint serves another contract under that name. Pass `ActionMcp.client` the served contract value.
|
|
35
|
+
- `UrlError` from `HttpClientRequest.toWebResult` or `toWeb` of an `ActionMcp.request`, or an `HttpClientError` whose reason is `InvalidUrlError` from a client method or a sent `ActionMcp.request`: the `url` is relative, such as the default `/mcp`, which only the `HttpClient` of `Testing.layer` resolves. Pass an absolute one.
|
package/docs/ActionMcp.md
CHANGED
|
@@ -18,7 +18,10 @@ Import `@gjermundgaraba/effect-actions/ActionMcp`.
|
|
|
18
18
|
| `layerHttp(implementations, options)` | A Layer serving the actions as tools at one HTTP endpoint. |
|
|
19
19
|
| `runStdio(implementations, options)` | An Effect serving them on standard I/O until stdin closes. |
|
|
20
20
|
| `Options` | Type of `runStdio`'s options. |
|
|
21
|
-
| `LayerHttpOptions` | Type of `layerHttp`'s options: `Options` and
|
|
21
|
+
| `LayerHttpOptions` | Type of `layerHttp`'s options: `Options` and HTTP's own. |
|
|
22
|
+
| `client(actions, options?)` | An Effect of a client calling each action's tool. |
|
|
23
|
+
| `request(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` to send. |
|
|
24
|
+
| `McpCallError` | A client method's failure for an answer it cannot decode. |
|
|
22
25
|
|
|
23
26
|
`implementations` is one implementation or a list.
|
|
24
27
|
|
|
@@ -33,10 +36,15 @@ Import `@gjermundgaraba/effect-actions/ActionMcp`.
|
|
|
33
36
|
| `path` | HTTP only: the endpoint's path; defaults to `/mcp`. |
|
|
34
37
|
| `allowedOrigins` | HTTP only: the exact origins allowed; none by default. |
|
|
35
38
|
| `allowSubscriptions` | HTTP only: `false` turns off 2026-07-28 subscriptions. |
|
|
39
|
+
| `requestScoped` | HTTP only: `true` runs a server per request. |
|
|
36
40
|
| `authentication` | HTTP only: the protected tools' authentication descriptor. |
|
|
37
41
|
|
|
38
|
-
All but `actions`, `features` and `
|
|
39
|
-
through. Its `protocols` and `allowSessionTermination` are not options.
|
|
42
|
+
All but `actions`, `features`, `authentication` and `requestScoped` are Effect's own `McpServer`
|
|
43
|
+
options, passed through. Its `protocols` and `allowSessionTermination` are not options.
|
|
44
|
+
|
|
45
|
+
`client` takes `url`, default `/mcp`, `transformClient`, and the endpoint's `authentication`, so an
|
|
46
|
+
error its verifier declares decodes; `request` takes `url` and `headers`.
|
|
47
|
+
Types: `Client<Actions, D>`, `ClientOptions`, `RequestOptions`, `Params`.
|
|
40
48
|
|
|
41
49
|
## Serve tools over HTTP
|
|
42
50
|
|
|
@@ -66,6 +74,9 @@ see [Action-in-process.md](Action-in-process.md#share-one-build-across-servers).
|
|
|
66
74
|
Effect expires none, so limit request bodies, which bounds what one holds. Where an endpoint
|
|
67
75
|
serves a protected tool, only a signed-in caller opens one, and that client signs in for every
|
|
68
76
|
request, a public tool's call included. An endpoint of public tools alone lets anyone open them.
|
|
77
|
+
- **On a serverless host**, such as Cloudflare Workers, pass `requestScoped: true`: a server per
|
|
78
|
+
request, speaking 2026-07-28 alone with no session. Connect a client that opens with
|
|
79
|
+
`initialize` to a long-lived endpoint: [notes-service/src/worker.ts](../examples/notes-service/src/worker.ts).
|
|
69
80
|
- An endpoint without `authentication` answers whoever reaches its port. Bind it to loopback or to
|
|
70
81
|
a network only trusted clients are on, and limit request bodies
|
|
71
82
|
([ActionHttp.md](ActionHttp.md#serve-the-routes)):
|
|
@@ -90,16 +101,9 @@ ActionMcp.runStdio([status, notes, identity], { name: "notes-service", version:
|
|
|
90
101
|
See [notes-service/src/stdio.ts](../examples/notes-service/src/stdio.ts), and
|
|
91
102
|
[mcp-tools/src/stdio.ts](../examples/mcp-tools/src/stdio.ts) for public tools.
|
|
92
103
|
|
|
93
|
-
To try a server by hand, send
|
|
94
|
-
`
|
|
95
|
-
|
|
96
|
-
```sh
|
|
97
|
-
BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getStatus","arguments":{},"_meta":{"io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
|
|
98
|
-
echo "$BODY" | node --import tsx examples/notes-service/src/stdio.ts
|
|
99
|
-
curl http://127.0.0.1:3000/mcp -d "$BODY" -H 'Content-Type: application/json' \
|
|
100
|
-
-H 'Accept: application/json, text/event-stream' -H 'MCP-Protocol-Version: 2026-07-28' \
|
|
101
|
-
-H 'MCP-Method: tools/call' -H 'MCP-Name: getStatus'
|
|
102
|
-
```
|
|
104
|
+
To try a server by hand, send one request as `request` builds it, its 2026-07-28 metadata in
|
|
105
|
+
`_meta`: [mcp-tools](../examples/mcp-tools/README.md#connect-a-client) has the `curl`, with
|
|
106
|
+
HTTP's five headers, and [notes-service](../examples/notes-service/README.md#run-it) the stdio command.
|
|
103
107
|
|
|
104
108
|
- **stdout carries the protocol alone.** `runStdio` sends its own program's Effect logs and
|
|
105
109
|
`Console` output to stderr. For the layers provided around it, apply `ActionCli.logToStderr`
|
|
@@ -112,10 +116,7 @@ curl http://127.0.0.1:3000/mcp -d "$BODY" -H 'Content-Type: application/json' \
|
|
|
112
116
|
protected tools' identity around `runStdio`, whose one caller is whoever started it. Each
|
|
113
117
|
implementation's `authorize` still runs, so give that caller what it checks. Tools served
|
|
114
118
|
behind a token over HTTP keep their one protected contract here: never a public copy.
|
|
115
|
-
- **Test the tools without a process**:
|
|
116
|
-
([mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts)),
|
|
117
|
-
an endpoint in memory
|
|
118
|
-
([mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts)).
|
|
119
|
+
- **Test the tools without a process**: [Testing.md](Testing.md#test-mcp-tools).
|
|
119
120
|
|
|
120
121
|
## Describe tools to a model
|
|
121
122
|
|
|
@@ -156,8 +157,8 @@ success: { printer: Printer, image: Action.Image },
|
|
|
156
157
|
|
|
157
158
|
The tool sends the field as an image block, after the JSON of the rest. Every other surface sends
|
|
158
159
|
it as JSON, the bytes in base64. See
|
|
159
|
-
[mcp-tools/src/printers/Printers.ts](../examples/mcp-tools/src/printers/Printers.ts)
|
|
160
|
-
|
|
160
|
+
[mcp-tools/src/printers/Printers.ts](../examples/mcp-tools/src/printers/Printers.ts); `client`
|
|
161
|
+
decodes it back.
|
|
161
162
|
|
|
162
163
|
- An image is a top-level field of a struct success, bare, optional or a required array, or the
|
|
163
164
|
whole success or an array of it. Anywhere else in a served action, as in a `Schema.NullOr`, a
|
|
@@ -196,24 +197,22 @@ sign in. What a signed-in caller may do is each implementation's
|
|
|
196
197
|
to signed-out callers, or public ones open to a session's client, serve the public tools on an
|
|
197
198
|
endpoint of their own: a second `layerHttp` on another `path`.
|
|
198
199
|
- No listing is filtered by caller: `authorize` refuses the call, not the sight of the tool.
|
|
199
|
-
- On a Bearer endpoint, a protected tool's
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
started, and its refusal is a tool result: refuse in `authorize`.
|
|
200
|
+
- On a Bearer endpoint, a protected tool's step-up refusal answers the request, not a tool
|
|
201
|
+
result ([Authentication.md](Authentication.md#step-up-for-a-scope),
|
|
202
|
+
[guarantees.md](guarantees.md#wire-behavior)). Once a handler has sent a notification the
|
|
203
|
+
response has started, and its refusal is a tool result: refuse in `authorize`.
|
|
204
204
|
- Test as a signed-in caller ([Testing.md](Testing.md#test-as-a-signed-in-caller)):
|
|
205
205
|
[notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts).
|
|
206
|
-
`Testing.mcpClient` decodes a refusal alike from a 401, a 403 or a tool result: to assert the
|
|
207
|
-
status, send `Testing.mcpRequest`.
|
|
208
206
|
|
|
209
207
|
## Allow browser clients
|
|
210
208
|
|
|
211
209
|
A browser MCP client sends its page's `Origin`, and needs two things.
|
|
212
210
|
|
|
213
211
|
- **`allowedOrigins`.** A request carrying an `Origin` is refused with 403 unless that exact
|
|
214
|
-
origin is listed, so a page a user
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
origin is listed, so a page a user visits cannot call the tools; one without, as other
|
|
213
|
+
clients send, passes. Authentication runs first, so the verifier sees a token from any origin:
|
|
214
|
+
a request that must authenticate, or sends a token, gets 401 without a valid one. Read the
|
|
215
|
+
list from config: [mcp-tools/src/routes.ts](../examples/mcp-tools/src/routes.ts).
|
|
217
216
|
- **CORS.** `allowedOrigins` only refuses: it answers no preflight and adds no header. Add
|
|
218
217
|
`HttpRouter.cors`, global to the router, only where an origin is configured: given an empty
|
|
219
218
|
list, it answers `Access-Control-Allow-Origin: *`, and given one origin it names that origin
|
|
@@ -224,9 +223,8 @@ A browser MCP client sends its page's `Origin`, and needs two things.
|
|
|
224
223
|
[kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts) with
|
|
225
224
|
authentication.
|
|
226
225
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
that must run first is a request policy:
|
|
226
|
+
A `Host` allowlist, for a request that sends no `Origin`, or an Origin check that must run first
|
|
227
|
+
is a request policy:
|
|
230
228
|
[Authentication-advanced.md](Authentication-advanced.md#run-a-request-policy-before-authentication).
|
|
231
229
|
|
|
232
230
|
## Add native prompts and resources
|
|
@@ -239,7 +237,6 @@ one and pass it as `features`:
|
|
|
239
237
|
around the endpoint, never to the `features` layer itself: there it is built again for the
|
|
240
238
|
features alone, so a resource reads another store than the tools write.
|
|
241
239
|
|
|
242
|
-
- Merged beside the endpoint, not given as `features`, they build and are never served.
|
|
243
240
|
- No `authorize` runs for them, and they run with the services they were built with, never a
|
|
244
241
|
request's, so they cannot read the caller. Content that depends on the caller is an action.
|
|
245
242
|
- On an endpoint naming `authentication`, every prompt and resource request needs a credential,
|
|
@@ -6,7 +6,8 @@ The tasks are in [ActionToolkit.md](ActionToolkit.md).
|
|
|
6
6
|
- An approved call never runs, and nothing is raised: a `tool-approval-response` finds its request by `approvalId` in the prompt history, and was sent without it. Use a `Chat`, or resend the assistant message holding the call and its request before the response.
|
|
7
7
|
- `Type 'X' is not assignable to type 'never'` where a turn or a call by hand runs, or `Service not found` in plain JavaScript: a request service is provided around the turn, or around the whole call effect, not to the stream alone, and a protected tool owes its identity whatever its handler reads. A startup service's Layer is provided to `layer`.
|
|
8
8
|
- A returned failure whose `result` is an `AiError` with reason `ToolParameterValidationError` (`failureOrigin: "parameters"`), not a declared error: the arguments did not decode. Neither `authorize` nor the handler ran.
|
|
9
|
-
- A
|
|
9
|
+
- A call dies with the handler's failure and a `SchemaError` beside it: the handler failed with an error its action does not declare, which a cast or plain JavaScript lets through, or with one breaking a check of its schema, such as `Infinity` in a struct error's `Schema.Finite` field. Declare the error on the action, or fix the value.
|
|
10
|
+
- A turn fails with an `AiError`, `Failed to encode result for tool '<name>'`: the handler's success does not encode, such as `Infinity` for a `Schema.Finite`.
|
|
10
11
|
- A model reads base64 text where an image was meant: a tool's success holds an `Action.Image`, which a Toolkit gives the model as JSON text. Leave the action out of `actions`, or serve it over MCP, which sends an image block ([ActionMcp.md](ActionMcp.md#return-images-and-other-media)).
|
|
11
12
|
- `Listed in actions, but no implementation holds it: <names>` thrown at `make`: those actions are not the implementations', by identity. `(another contract)` marks one whose name an implementation holds, as a second copy of the contracts module makes. Pass the implementation holding the action, or list the contract it implements.
|
|
12
13
|
- `Duplicate tool: <name>` thrown at `make`: two implementations serve actions of one name. Rename one action; of two implementations of one contract, pass one.
|
package/docs/ActionToolkit.md
CHANGED
|
@@ -50,11 +50,11 @@ conversations.pipe(
|
|
|
50
50
|
|
|
51
51
|
- **One `generateText` is one request to the model.** Its tool calls run, and their results come back in the response, unread by the model. For the model to read them, ask the `Chat` again with `prompt: []` while a response has `toolCalls` and no `tool-approval-request`.
|
|
52
52
|
- Without `actions`, every action of the implementations is a tool, one added later included. Where a model must not get them all, list the tools ([Action.md](Action.md#define-a-contract)): [kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts).
|
|
53
|
-
- A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads.
|
|
53
|
+
- A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads. So are arguments that do not decode, an `AiError` (`ToolParameterValidationError`), not `InvalidInput`: neither `authorize` nor the handler ran.
|
|
54
54
|
- A turn's tool calls, the approved calls of an answering turn included, run concurrently unless `generateText` is given a `concurrency`. A service they share must tolerate it: [assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
|
|
55
55
|
- Arguments the input does not declare are dropped, where HTTP and a CLI refuse them ([guarantees.md](guarantees.md#wire-behavior)): a misspelled optional argument is ignored silently.
|
|
56
56
|
- Beside other tools, merge natively and provide every handler layer: `Toolkit.merge(toolkit, others)` with `Layer.mergeAll(layer, othersHandlers)`.
|
|
57
|
-
- **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), which the entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts), and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts).
|
|
57
|
+
- **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), which the entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts), and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts). Name the files as it does, the route's contract `Assistant.ts` and the loop `handlers.ts`, from the first one written: a lowercase `assistant.ts` beside the contract fails with TS1149 ([setup-failures.md](setup-failures.md)).
|
|
58
58
|
- **The real model comes later?** Provide a scripted one from the entrypoint until then, as the kitchen-sink does. Reading the model with `Effect.serviceOption` hides the requirement: the server builds without one and every request fails.
|
|
59
59
|
|
|
60
60
|
### Call a tool by hand
|
|
@@ -64,9 +64,9 @@ Tasks: [Authentication.md](Authentication.md),
|
|
|
64
64
|
too ([ActionMcp.md](ActionMcp.md#protect-tools)).
|
|
65
65
|
- A verifier's declared error arrives as 422: the error states no `httpApiStatus`. Give it the
|
|
66
66
|
status it means, such as `{ httpApiStatus: 503 }`.
|
|
67
|
-
- `
|
|
67
|
+
- `ActionMcp.client` fails with an `McpCallError`, not a typed error, when the verifier fails
|
|
68
68
|
with an error its descriptor declares: over MCP that failure is the endpoint's HTTP answer,
|
|
69
|
-
before any tool runs.
|
|
69
|
+
before any tool runs. Pass the endpoint's descriptor as the client's `authentication`.
|
|
70
70
|
- A route's `Cache-Control` is replaced by `no-store`: only a route's own answer keeps its
|
|
71
71
|
caching, and this response is a failure that enclosing middleware serialized.
|
|
72
72
|
|
package/docs/Authentication.md
CHANGED
|
@@ -70,6 +70,12 @@ does an RPC binding ([live-rpc/src/Rpc.ts](../examples/live-rpc/src/Rpc.ts)).
|
|
|
70
70
|
`ActionRpc.layer`, `ActionMcp.layerHttp` and `Authentication.protect` refuse, when they build,
|
|
71
71
|
a descriptor that is another object than the one the provider was given, equal name and
|
|
72
72
|
options or not.
|
|
73
|
+
- Protecting an action that tests already call breaks them. An `Action.client` call then owes
|
|
74
|
+
`Type 'CurrentUser' is missing … from type 'Scope'`: provide the caller around each call
|
|
75
|
+
([Testing-failures.md](Testing-failures.md)). A test that builds `ActionMcp.layerHttp` itself
|
|
76
|
+
gets `Property 'authentication' is missing`: serve `routes` with `Testing.layer`
|
|
77
|
+
([ActionMcp-failures.md](ActionMcp-failures.md)). A call over HTTP without a credential gets
|
|
78
|
+
`Unauthenticated` at run time.
|
|
73
79
|
- A local surface has no remote caller and takes no descriptor: the host provides the identity
|
|
74
80
|
itself, for a CLI ([ActionCli.md](ActionCli.md#name-the-caller)), a Toolkit
|
|
75
81
|
([ActionToolkit.md](ActionToolkit.md#name-the-caller)) and an MCP server over stdio
|
|
@@ -142,15 +148,14 @@ added. The authorizer, a handler or the verifier may fail with one:
|
|
|
142
148
|
|
|
143
149
|
- Step-up is a Bearer descriptor's, for protected actions and routes under `protect`: the
|
|
144
150
|
refusal answers the request with 401 or 403 and its challenge, over MCP in place of the tool
|
|
145
|
-
result.
|
|
151
|
+
result unless the response has started ([guarantees.md](guarantees.md#wire-behavior)).
|
|
146
152
|
- On a public action or under another scheme, an authorizer's or a handler's refusal is
|
|
147
153
|
answered like a declared error, and RPC never steps up: a refusal is the call's typed failure
|
|
148
154
|
([guarantees.md](guarantees.md#wire-behavior)).
|
|
149
155
|
- Name scopes only when re-authorizing can grant them: a server with fixed tokens, such as API
|
|
150
|
-
keys, leaves `scopes` out. A `Forbidden` without them prompts no login
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
([kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts)).
|
|
156
|
+
keys, leaves `scopes` out. A `Forbidden` without them prompts no login
|
|
157
|
+
([kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts));
|
|
158
|
+
what it answers on each surface is in [guarantees.md](guarantees.md#wire-behavior).
|
|
154
159
|
- Map another service's refusals before failing with them
|
|
155
160
|
([Action.md](Action.md#fail-with-declared-and-built-in-errors)): passed on, a `Forbidden`
|
|
156
161
|
naming that service's scopes sends this server's caller to re-authorize for them.
|
package/docs/CONTEXT.md
CHANGED
|
@@ -23,7 +23,7 @@ page.
|
|
|
23
23
|
- **Host**: the application that serves the surfaces: it builds their layers, provides their services and runs the server, subprocess or CLI.
|
|
24
24
|
- **Surface**: where callers reach actions. A **remote surface** authenticates its callers (`ActionHttp.layer`, `ActionMcp.layerHttp`, `ActionRpc.layer`). A **local surface** has no one to authenticate (`ActionToolkit`, a local `ActionCli` command, `ActionMcp.runStdio`, `Action.client`): the host provides the identity.
|
|
25
25
|
- **Binding**: what `ActionHttp.make` or `ActionRpc.make` returns: the actions its surface serves, as browser-safe data the server and every client share.
|
|
26
|
-
- **Client**: one method per action, `client.<action>(input)`: `ActionHttp.client`, `ActionRpc.client`, `
|
|
26
|
+
- **Client**: one method per action, `client.<action>(input)`: `ActionHttp.client`, `ActionRpc.client`, `ActionMcp.client` and, in process, `Action.client`.
|
|
27
27
|
- **Authorizer**: `authorize`, a function of the called action that an implementation of protected actions states. It succeeds or fails with a refusal ([Action.md](Action.md#authorize-calls)).
|
|
28
28
|
- **Authentication descriptor**: what `Authentication.make` returns: a named, browser-safe declaration of how a remote caller proves an identity. A binding or an MCP endpoint serving protected actions names it.
|
|
29
29
|
- **Authentication provider**: what `Authentication.layer` returns for a descriptor, holding its **verifier**: the server-only function from a credential to the identity.
|
package/docs/Testing-failures.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Testing failure modes
|
|
2
2
|
|
|
3
3
|
What you see when something is wrong, and the fix. Search for a fragment of the message.
|
|
4
|
+
A missing service's error can start
|
|
5
|
+
`… with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined'`: search a line
|
|
6
|
+
beneath it, `is missing the following properties from type 'Scope'`, instead.
|
|
4
7
|
The tasks are in [Testing.md](Testing.md).
|
|
5
8
|
|
|
6
9
|
- A route answers an empty 500, which a client method reports as `HttpClientError: Decode error (500 …)`: a defect, or a result that does not encode. `Testing.layer` logs the cause as an error on the process console, under `it.effect` too, and nothing for a request that succeeds or is refused: read the test's output, or collect it with a `Logger` or an `ErrorReporter` provided around the layer.
|
|
@@ -14,8 +17,5 @@ The tasks are in [Testing.md](Testing.md).
|
|
|
14
17
|
- `Property '<field>' does not exist on type 'MethodError<Action<…>>'`, or on a built-in error, after `expect(error).toBeInstanceOf(…)`: `toBeInstanceOf` does not narrow the union of the action's errors. Compare with `toEqual(new X({ … }))`, or narrow with `instanceof` ([Assert on errors](Testing.md#assert-on-errors)).
|
|
15
18
|
- A test passes whatever message an error carries: `toEqual` never compares a tagged error's `message`. Assert it separately ([Assert on errors](Testing.md#assert-on-errors)).
|
|
16
19
|
- The test reads a service the handlers never changed: it was provided inside the routes and again to the test, two instances. Provide it once, around `Testing.layer`, with `Layer.provideMerge`.
|
|
17
|
-
- `MCP tools/call "<name>" answered 404`: the endpoint is not at `/mcp`, the default. Pass `mcpClient` its `url`.
|
|
18
|
-
- `MCP tools/call "<name>" returned an error: Invalid parameters for tool ...`: the endpoint serves another contract under that name. Pass `mcpClient` the served contract value.
|
|
19
20
|
- A builder or handler fails with `NotFound: FileSystem.<method> (<path>)` for a file that exists, or a file route answers 500: with no `FileSystem` provided around `Testing.layer`, the routes read a no-op one. Provide `NodeFileSystem.layer` or `NodeServices.layer`. In a command test, the nearer of two provided is read: provide a fake `FileSystem` on the command, inside `NodeServices.layer`.
|
|
20
21
|
- `Argument of type 'Layer<…, FileSystem | Generator | HttpPlatform | HttpRouter | Path>' is not assignable` at `HttpRouter.toWebHandler(routes)`: a web handler's routes need the platform services `Testing.layer(routes)` supplies itself. Provide `HttpServer.layerServices` to them.
|
|
21
|
-
- `UrlError` from `HttpClientRequest.toWebResult` or `toWeb` of an `mcpRequest`, or an `HttpClientError` whose reason is `InvalidUrlError` from a client method or a sent `mcpRequest`: the `url` is relative, such as the default `/mcp`, which only the `HttpClient` of `Testing.layer` resolves. Pass an absolute one.
|