@gjermundgaraba/effect-actions 0.9.0 → 0.11.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 +56 -50
- package/dist/Action.d.mts +286 -79
- package/dist/Action.mjs +125 -23
- package/dist/ActionCli.d.mts +102 -31
- package/dist/ActionCli.mjs +114 -16
- package/dist/ActionHttp.d.mts +170 -51
- package/dist/ActionHttp.mjs +130 -79
- package/dist/ActionMcp.d.mts +71 -21
- package/dist/ActionMcp.mjs +231 -80
- package/dist/ActionToolkit.d.mts +56 -27
- package/dist/ActionToolkit.mjs +16 -5
- package/dist/Authentication.d.mts +129 -33
- package/dist/Authentication.mjs +231 -38
- package/dist/Testing.d.mts +84 -57
- package/dist/Testing.mjs +121 -79
- package/dist/internal/actions.d.mts +5 -25
- package/dist/internal/actions.mjs +75 -27
- package/dist/internal/authentication.d.mts +102 -0
- package/dist/internal/authentication.mjs +11 -0
- package/dist/internal/call.d.mts +12 -0
- package/dist/internal/call.mjs +12 -0
- package/dist/internal/cli.d.mts +36 -22
- package/dist/internal/cli.mjs +311 -33
- package/dist/internal/client.d.mts +43 -0
- package/dist/internal/client.mjs +25 -0
- package/dist/internal/console.mjs +77 -0
- package/dist/internal/declared.d.mts +13 -0
- package/dist/internal/declared.mjs +34 -0
- package/dist/internal/errors.d.mts +77 -0
- package/dist/internal/errors.mjs +79 -0
- package/dist/internal/implementation.d.mts +171 -41
- package/dist/internal/implementation.mjs +147 -29
- package/dist/internal/mcp.d.mts +14 -0
- package/dist/internal/mcp.mjs +182 -0
- package/dist/internal/media.d.mts +8 -0
- package/dist/internal/media.mjs +26 -0
- package/dist/internal/memory.d.mts +6 -0
- package/dist/internal/memory.mjs +38 -0
- package/dist/internal/refusal.mjs +67 -0
- package/dist/internal/schema-errors.mjs +20 -0
- package/dist/internal/tools.mjs +27 -57
- package/docs/Action.md +403 -73
- package/docs/ActionCli.md +281 -51
- package/docs/ActionHttp.md +267 -82
- package/docs/ActionMcp.md +232 -102
- package/docs/ActionToolkit.md +99 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +24 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +82 -43
- package/docs/setup.md +95 -27
- package/package.json +12 -20
- package/dist/ActionCatalog.d.mts +0 -30
- package/dist/ActionCatalog.mjs +0 -39
- package/dist/ActionCliClient.d.mts +0 -48
- package/dist/ActionCliClient.mjs +0 -43
- package/dist/ActionGroup.d.mts +0 -44
- package/dist/ActionGroup.mjs +0 -35
- package/dist/ActionHttpClient.d.mts +0 -33
- package/dist/ActionHttpClient.mjs +0 -16
- package/dist/TestingClient.d.mts +0 -17
- package/dist/TestingClient.mjs +0 -22
- package/dist/internal/fetchClient.d.mts +0 -6
- package/dist/internal/fetchClient.mjs +0 -8
- package/dist/internal/tools.d.mts +0 -22
- package/docs/ActionCatalog.md +0 -54
- package/docs/ActionCliClient.md +0 -63
- package/docs/ActionGroup.md +0 -128
- package/docs/ActionHttpClient.md +0 -80
package/dist/Testing.mjs
CHANGED
|
@@ -1,91 +1,133 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Refusal } from "./internal/errors.mjs";
|
|
2
|
+
import { assertOnce, projectedErrors } from "./internal/actions.mjs";
|
|
3
|
+
import { inputOf } from "./internal/call.mjs";
|
|
4
|
+
import { defaultPath, liftOf, statelessRequest } from "./internal/mcp.mjs";
|
|
5
|
+
import { clientOf } from "./internal/memory.mjs";
|
|
6
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Predicate, Schema, identity } from "effect";
|
|
7
|
+
import { Etag, HttpClient, HttpClientRequest, HttpEffect, HttpPlatform, HttpRouter } from "effect/http";
|
|
8
|
+
import { McpSchema } from "effect/ai";
|
|
9
|
+
import { Base64, Sse } from "effect/encoding";
|
|
3
10
|
//#region src/Testing.ts
|
|
11
|
+
function layer(routes) {
|
|
12
|
+
if (!Layer.isLayer(routes)) return clientOf(routes);
|
|
13
|
+
return Layer.unwrap(Effect.gen(function* () {
|
|
14
|
+
const fileSystem = Option.getOrElse(yield* Effect.serviceOption(FileSystem.FileSystem), () => FileSystem.makeNoop({}));
|
|
15
|
+
const platform = yield* Layer.build(Layer.fresh(Layer.mergeAll(HttpPlatform.layer, Path.layer, Etag.layerWeak).pipe(Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, fileSystem)))));
|
|
16
|
+
const context = Context.merge(platform, yield* Effect.context());
|
|
17
|
+
const app = yield* HttpRouter.toHttpEffect(routes).pipe(Effect.provideContext(context));
|
|
18
|
+
return clientOf(HttpEffect.toWebHandlerWith(context)(app));
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
4
21
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
22
|
+
* An answer a client method cannot decode as the action's success or a declared error,
|
|
23
|
+
* such as the native server's message for invalid arguments or an unknown tool. Its message
|
|
24
|
+
* holds the answer.
|
|
7
25
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const mcpRequest = ({ url, method, params = {}, headers: init }) => {
|
|
14
|
-
const headers = new Headers(init);
|
|
15
|
-
headers.set("content-type", "application/json");
|
|
16
|
-
headers.set("accept", "application/json, text/event-stream");
|
|
17
|
-
headers.set("mcp-protocol-version", "2026-07-28");
|
|
18
|
-
headers.set("mcp-method", method);
|
|
19
|
-
if (Predicate.isString(params.name)) headers.set("mcp-name", params.name);
|
|
20
|
-
return new Request(url, {
|
|
21
|
-
method: "POST",
|
|
22
|
-
headers,
|
|
23
|
-
body: JSON.stringify({
|
|
24
|
-
jsonrpc: "2.0",
|
|
25
|
-
id: 1,
|
|
26
|
-
method,
|
|
27
|
-
params: {
|
|
28
|
-
...params,
|
|
29
|
-
_meta: Object.assign({
|
|
30
|
-
"io.modelcontextprotocol/clientCapabilities": {},
|
|
31
|
-
"io.modelcontextprotocol/clientInfo": {
|
|
32
|
-
name: "test",
|
|
33
|
-
version: "0"
|
|
34
|
-
}
|
|
35
|
-
}, params._meta, { "io.modelcontextprotocol/protocolVersion": "2026-07-28" })
|
|
36
|
-
}
|
|
37
|
-
})
|
|
38
|
-
});
|
|
39
|
-
};
|
|
40
|
-
/** The JSON-RPC response to a `tools/call`: a tool result or a protocol error. */
|
|
41
|
-
const ToolReply = Schema.Union([Schema.Struct({ result: Schema.Struct({
|
|
42
|
-
isError: Schema.optionalKey(Schema.Boolean),
|
|
43
|
-
structuredContent: Schema.optionalKey(Schema.Json),
|
|
44
|
-
content: Schema.Array(Schema.Struct({
|
|
45
|
-
type: Schema.String,
|
|
46
|
-
text: Schema.optionalKey(Schema.String)
|
|
47
|
-
}))
|
|
48
|
-
}) }), Schema.Struct({ error: Schema.Struct({
|
|
26
|
+
var McpCallError = class extends Schema.TaggedError()("McpCallError", { message: Schema.String }) {};
|
|
27
|
+
/** The text of a block, none of a block of another kind. */
|
|
28
|
+
const textOf = (block) => block?.type === "text" ? block.text : void 0;
|
|
29
|
+
/** The JSON-RPC response to a `tools/call`: the native tool result, or a protocol error. */
|
|
30
|
+
const ToolReply = Schema.Union([Schema.Struct({ result: McpSchema.CallToolResult }), Schema.Struct({ error: Schema.Struct({
|
|
49
31
|
code: Schema.Finite,
|
|
50
32
|
message: Schema.String
|
|
51
33
|
}) })]);
|
|
52
34
|
const decodeReply = Schema.decodeUnknownOption(Schema.fromJsonString(ToolReply));
|
|
53
|
-
const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
|
|
54
35
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
36
|
+
* The reply in a response: its body when it is one JSON message, or the last reply among an
|
|
37
|
+
* event stream's events, which notifications may precede.
|
|
38
|
+
*/
|
|
39
|
+
const replyOf = (response, text) => {
|
|
40
|
+
if (!(response.headers["content-type"] ?? "").startsWith("text/event-stream")) return decodeReply(text);
|
|
41
|
+
const data = [];
|
|
42
|
+
Sse.makeParser((event) => {
|
|
43
|
+
if (Predicate.isTagged(event, "Event")) data.push(event.data);
|
|
44
|
+
}).feed(text);
|
|
45
|
+
return Option.fromNullishOr(data.flatMap((message) => Option.toArray(decodeReply(message))).at(-1));
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The JSON of the media `field` a tool result's blocks hold: those of its kind naming it, or
|
|
49
|
+
* none, for the whole success, in order; an array, or else the one, if any. Surplus blocks for
|
|
50
|
+
* one value stay an array, which its decoding refuses.
|
|
51
|
+
*/
|
|
52
|
+
const mediaOf = ({ name, kind, many }, result) => {
|
|
53
|
+
const values = result.content.flatMap((block) => block.type === kind && block._meta?.["effect-actions/field"] === name ? [{
|
|
54
|
+
data: Base64.encode(block.data),
|
|
55
|
+
mimeType: block.mimeType
|
|
56
|
+
}] : []);
|
|
57
|
+
return many || values.length > 1 ? values : values[0];
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The encoded success of a tool result lifting the media `lift` finds: the whole success's
|
|
61
|
+
* blocks, or its structured content with each media field taken from its blocks alone, an
|
|
62
|
+
* empty one when nothing else was left.
|
|
58
63
|
*/
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
const reassembled = (lift, result) => {
|
|
65
|
+
const [whole] = lift.fields;
|
|
66
|
+
if (whole !== void 0 && whole.name === void 0) return mediaOf(whole, result);
|
|
67
|
+
const { structuredContent = {} } = result;
|
|
68
|
+
if (!Predicate.isObject(structuredContent)) return structuredContent;
|
|
69
|
+
const lifted = new Set(lift.fields.map(({ name }) => name));
|
|
70
|
+
return Object.fromEntries([...Object.entries(structuredContent).filter(([key]) => !lifted.has(key)), ...lift.fields.flatMap((field) => {
|
|
71
|
+
const value = mediaOf(field, result);
|
|
72
|
+
return field.name === void 0 || value === void 0 ? [] : [[field.name, value]];
|
|
73
|
+
})]);
|
|
74
|
+
};
|
|
75
|
+
/** Fail with the value of one of `errors` that `text` holds, or else with `otherwise`. */
|
|
76
|
+
const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
|
|
77
|
+
/**
|
|
78
|
+
* The tool call of `action`, whose success its tool lifts as `lift` says, with `input` on
|
|
79
|
+
* `client`, sending to `url`.
|
|
80
|
+
*/
|
|
81
|
+
const callTool = (client, url, action, lift, input) => {
|
|
82
|
+
const { name } = action;
|
|
83
|
+
const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
|
|
84
|
+
return Effect.gen(function* () {
|
|
85
|
+
const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
|
|
86
|
+
const response = yield* client.execute(mcpRequest("tools/call", {
|
|
64
87
|
name,
|
|
65
|
-
arguments:
|
|
66
|
-
},
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
arguments: encoded
|
|
89
|
+
}, { url }));
|
|
90
|
+
const text = yield* response.text;
|
|
91
|
+
if (response.status !== 200) return yield* failWith(Refusal.members, text, other(`answered ${response.status}: ${text}`));
|
|
92
|
+
const reply = replyOf(response, text);
|
|
93
|
+
if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
|
|
94
|
+
if ("error" in reply.value) {
|
|
95
|
+
const { code, message } = reply.value.error;
|
|
96
|
+
return yield* Effect.fail(other(`failed with ${code}: ${message}`));
|
|
97
|
+
}
|
|
98
|
+
const { result } = reply.value;
|
|
99
|
+
if (result.isError === true) {
|
|
100
|
+
const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
|
|
101
|
+
return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
|
|
102
|
+
}
|
|
103
|
+
if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
|
|
104
|
+
return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(reassembled(lift, result));
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
function mcpClient(actions, { url = defaultPath, transformClient = identity } = {}) {
|
|
108
|
+
assertOnce("action", actions);
|
|
109
|
+
return Effect.map(HttpClient.HttpClient, (native) => {
|
|
110
|
+
const client = transformClient(native);
|
|
111
|
+
return Object.fromEntries(actions.map((action) => {
|
|
112
|
+
const lift = liftOf(action.success);
|
|
113
|
+
return [action.name, (...args) => Effect.flatMap(inputOf(action, args), (input) => callTool(client, url, action, lift, input))];
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it,
|
|
119
|
+
* as the native request value: the JSON-RPC envelope, the 2026-07-28 headers, `mcp-name` from
|
|
120
|
+
* `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
|
|
121
|
+
* `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
|
|
122
|
+
* version is always the request's own. The test sends it: `HttpClient.execute` answers the
|
|
123
|
+
* response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
|
|
124
|
+
* gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
|
|
125
|
+
* asserting on what `mcpClient` decodes away, such as `tools/list`, a refusal's challenge, or
|
|
126
|
+
* a call its types would not send.
|
|
127
|
+
*/
|
|
128
|
+
const mcpRequest = (method, params = {}, { headers = {}, url = defaultPath } = {}) => {
|
|
129
|
+
const { headers: routing, body } = statelessRequest(method, params);
|
|
130
|
+
return HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.setHeaders(routing), HttpClientRequest.bodyJsonUnsafe(body));
|
|
89
131
|
};
|
|
90
132
|
//#endregion
|
|
91
|
-
export {
|
|
133
|
+
export { McpCallError, layer, mcpClient, mcpRequest };
|
|
@@ -1,29 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import "../Action.mjs";
|
|
2
|
+
import { SchemaAST } from "effect";
|
|
3
3
|
//#region src/internal/actions.d.ts
|
|
4
|
-
/** One side of a schema-error policy: the error it answers with, and how to make it. */
|
|
5
|
-
export interface SchemaErrorAnswer<E extends Codec> {
|
|
6
|
-
/** A declared error schema; its `httpApiStatus` is the HTTP status of the answer. */
|
|
7
|
-
readonly schema: E;
|
|
8
|
-
readonly make: (failure: HttpApiError.HttpApiSchemaError) => NoInfer<E["Type"]>;
|
|
9
|
-
}
|
|
10
4
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* The caller of a public action: anyone, signed in or not. Registered globally, so two copies
|
|
6
|
+
* of the package agree on it, as they agree on a name.
|
|
13
7
|
*/
|
|
14
|
-
export
|
|
15
|
-
/** The request did not decode: its payload, or its params, headers or query. */
|
|
16
|
-
readonly invalid: SchemaErrorAnswer<Invalid>;
|
|
17
|
-
/** The handler's result did not encode: its body or its response headers. */
|
|
18
|
-
readonly internal: SchemaErrorAnswer<Internal>;
|
|
19
|
-
}
|
|
20
|
-
/** The contract half of a group: what adapters and clients need, without `implement`. */
|
|
21
|
-
export interface Actions<Name extends string = string, A extends ReadonlyArray<Any> = ReadonlyArray<Any>, PolicyError extends Codec = Codec> {
|
|
22
|
-
readonly name: Name;
|
|
23
|
-
readonly actions: A;
|
|
24
|
-
/** How HTTP answers failed decoding or encoding; native behavior without one. MCP is unaffected. */
|
|
25
|
-
readonly schemaError: SchemaErrorPolicy<PolicyError, PolicyError> | undefined;
|
|
26
|
-
}
|
|
27
|
-
/** The errors a group's policy may answer with, by lookup rather than a conditional type. */
|
|
28
|
-
export type PolicyError<G extends Actions> = NonNullable<G["schemaError"]>[keyof SchemaErrorPolicy]["schema"];
|
|
8
|
+
export declare const Anyone: unique symbol;
|
|
29
9
|
//#endregion
|
|
@@ -1,41 +1,89 @@
|
|
|
1
|
+
import { builtIns, statuses } from "./errors.mjs";
|
|
2
|
+
import { Predicate, Schema, SchemaAST } from "effect";
|
|
1
3
|
//#region src/internal/actions.ts
|
|
2
|
-
/** Each distinct error a policy may answer with, invalid first. */
|
|
3
|
-
const policyErrors = (policy) => [.../* @__PURE__ */ new Set([policy.invalid.schema, policy.internal.schema])];
|
|
4
4
|
/**
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
* decoding the request before it.
|
|
5
|
+
* The list an `error` option stands for: one schema, a list as given, or none, as
|
|
6
|
+
* `HttpApiEndpoint` takes it.
|
|
8
7
|
*/
|
|
9
|
-
const
|
|
10
|
-
/** The policy's answer to one native failure: `internal` for the response side, else `invalid`. */
|
|
11
|
-
const answerSchemaError = (policy, failure) => responseKinds.has(failure.kind) ? policy.internal.make(failure) : policy.invalid.make(failure);
|
|
8
|
+
const errorList = (error) => error === void 0 ? [] : Schema.isSchema(error) ? [error] : error;
|
|
12
9
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* What a projection of an action declares: the built-in errors, the action's own, and any
|
|
11
|
+
* its surface adds, such as a binding's. The built-ins come first, since a failure encodes
|
|
12
|
+
* and decodes with the first schema that accepts it, and they are classes, which accept
|
|
13
|
+
* only their own instances: a loose schema of the action's never captures one. A schema
|
|
14
|
+
* listed twice is not repeated.
|
|
16
15
|
*/
|
|
17
|
-
const projectedErrors = (action, surface) => [.../* @__PURE__ */ new Set([
|
|
18
|
-
|
|
16
|
+
const projectedErrors = (action, surface = []) => [.../* @__PURE__ */ new Set([
|
|
17
|
+
...builtIns,
|
|
18
|
+
...action.error,
|
|
19
|
+
...surface
|
|
20
|
+
])];
|
|
21
|
+
/**
|
|
22
|
+
* The caller of a public action: anyone, signed in or not. Registered globally, so two copies
|
|
23
|
+
* of the package agree on it, as they agree on a name.
|
|
24
|
+
*/
|
|
25
|
+
const Anyone = Symbol.for("@gjermundgaraba/effect-actions/Anyone");
|
|
26
|
+
const validName = /^[A-Za-z0-9_-]{1,128}$/;
|
|
19
27
|
/** Names become path segments, OpenAPI identifiers and client method keys. */
|
|
20
28
|
const assertName = (what, name) => {
|
|
21
29
|
if (!validName.test(name) || name === "then") throw new Error(`Invalid ${what}: ${name}`);
|
|
22
30
|
};
|
|
23
31
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
32
|
+
* Names are checked by whoever owns them: an implementation, a binding, a CLI, or the MCP
|
|
33
|
+
* tools. `claimantOf` says who claims a name, so a clash between two claimants names both.
|
|
26
34
|
*/
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
seen.
|
|
35
|
+
const assertDistinct = (what, items, nameOf, claimantOf = nameOf) => {
|
|
36
|
+
const seen = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const name = nameOf(item);
|
|
39
|
+
const claimant = claimantOf(item);
|
|
40
|
+
const other = seen.get(name);
|
|
41
|
+
if (other !== void 0) {
|
|
42
|
+
const by = other === claimant ? "" : `, claimed by ${other} and ${claimant}`;
|
|
43
|
+
throw new Error(`Duplicate ${what}: ${name}${by}`);
|
|
44
|
+
}
|
|
45
|
+
seen.set(name, claimant);
|
|
38
46
|
}
|
|
39
47
|
};
|
|
48
|
+
/** Refuse an action name `actions` hold twice. */
|
|
49
|
+
const assertOnce = (what, actions) => assertDistinct(what, actions, (action) => action.name);
|
|
50
|
+
/**
|
|
51
|
+
* Refuse a key of options keyed by action name, such as handlers, tools or commands, that no
|
|
52
|
+
* action of `names` has, so a stale option cannot outlive its action.
|
|
53
|
+
*/
|
|
54
|
+
const assertKnown = (what, keys, names) => {
|
|
55
|
+
const unknown = keys.filter((key) => !names.includes(key));
|
|
56
|
+
if (unknown.length > 0) throw new Error(`Unknown ${what}: ${unknown.join(", ")}`);
|
|
57
|
+
};
|
|
58
|
+
/** `ast` with any suspension at its top resolved, as a recursive schema's is. */
|
|
59
|
+
const unsuspended = (ast) => SchemaAST.isSuspend(ast) ? unsuspended(ast.thunk()) : ast;
|
|
60
|
+
/** The members of a union, nested and suspended ones included, or the one type otherwise. */
|
|
61
|
+
const members = (ast) => {
|
|
62
|
+
const resolved = unsuspended(ast);
|
|
63
|
+
return SchemaAST.isUnion(resolved) ? resolved.types.flatMap(members) : [resolved];
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The values a literal or an enum accepts, or `undefined` for any other type, which has no
|
|
67
|
+
* fixed set.
|
|
68
|
+
*/
|
|
69
|
+
const literalValues = (ast) => SchemaAST.isLiteral(ast) ? [ast.literal] : SchemaAST.isEnum(ast) ? ast.enums.map(([, value]) => value) : [void 0];
|
|
70
|
+
/**
|
|
71
|
+
* The `_tag`s a schema's encoding carries: each member's of a union, every value of a union of
|
|
72
|
+
* literals or of an enum, and a suspended schema's, as a recursive error is written.
|
|
73
|
+
*/
|
|
74
|
+
const tagsOf = (ast) => members(SchemaAST.toEncoded(ast)).flatMap((member) => {
|
|
75
|
+
const tag = SchemaAST.isObjects(member) ? member.propertySignatures.find((property) => property.name === "_tag")?.type : void 0;
|
|
76
|
+
return tag === void 0 ? [] : members(tag).flatMap(literalValues).filter(Predicate.isString);
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Refuse an error that encodes with a built-in error's `_tag`, the built-in itself included:
|
|
80
|
+
* every endpoint and tool declares the built-in errors already, a client decoding the answer
|
|
81
|
+
* could not tell a look-alike from them, and a look-alike of a refusal would step up. Checked
|
|
82
|
+
* where the action or the binding is made, so every surface and client of it may rely on it.
|
|
83
|
+
*/
|
|
84
|
+
const assertOwnTags = (what, errors) => {
|
|
85
|
+
const tag = errors.flatMap((error) => tagsOf(error.ast)).find((tag) => Object.hasOwn(statuses, tag));
|
|
86
|
+
if (tag !== void 0) throw new Error(`${what}: error _tag "${tag}" is built in, and declared on every surface`);
|
|
87
|
+
};
|
|
40
88
|
//#endregion
|
|
41
|
-
export {
|
|
89
|
+
export { Anyone, assertDistinct, assertKnown, assertName, assertOnce, assertOwnTags, errorList, literalValues, members, projectedErrors, unsuspended };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Anyone } from "./actions.mjs";
|
|
2
|
+
import { Refusal } from "./errors.mjs";
|
|
3
|
+
import { Protected, ServedRequest, Serving } from "./implementation.mjs";
|
|
4
|
+
import { Action_d_exports, Any as Any$1, Errors } from "../Action.mjs";
|
|
5
|
+
import { Context, Effect } from "effect";
|
|
6
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
7
|
+
import { HttpApiMiddleware, HttpApiSecurity } from "effect/http-api";
|
|
8
|
+
//#region src/internal/authentication.d.ts
|
|
9
|
+
/** One native security scheme: Bearer unless a descriptor names another. */
|
|
10
|
+
export type Security = HttpApiSecurity.HttpApiSecurity;
|
|
11
|
+
/** What a native scheme decodes: a `Redacted` token or key, or Basic credentials. */
|
|
12
|
+
export type Credential = HttpApiSecurity.HttpApiSecurity.Type<Security>;
|
|
13
|
+
/** The provider of a descriptor's verifier: a type of its own, apart from the actor. */
|
|
14
|
+
export interface Provider<I, Name extends string = string> {
|
|
15
|
+
readonly "~effect-actions/Authentication": I;
|
|
16
|
+
readonly "~effect-actions/Authentication/Name": Name;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The native security middleware a descriptor's protected endpoints carry, which provides
|
|
20
|
+
* the identity: an identifier of its own, which no `Authentication.layer` provides, so only
|
|
21
|
+
* `ActionHttp.layer` satisfies it, from the provider.
|
|
22
|
+
*/
|
|
23
|
+
export interface SecurityMiddleware<I, Name extends string = string> {
|
|
24
|
+
readonly "~effect-actions/Authentication/Security": Name;
|
|
25
|
+
readonly "~effect/http-api/HttpApiMiddleware": {
|
|
26
|
+
readonly provides: I;
|
|
27
|
+
readonly requires: never;
|
|
28
|
+
readonly error: never;
|
|
29
|
+
readonly clientError: never;
|
|
30
|
+
readonly requiredForClient: false;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
type Response = Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, unknown>;
|
|
34
|
+
interface Runtime$1 {
|
|
35
|
+
/** The descriptor the provider was built from, the one a binding naming it must hold. */
|
|
36
|
+
readonly descriptor: unknown;
|
|
37
|
+
/** The native security middleware's implementation, keyed by its scheme. */
|
|
38
|
+
readonly middleware: Readonly<Record<string, (route: Response, options: {
|
|
39
|
+
readonly credential: Credential;
|
|
40
|
+
}) => Response>>;
|
|
41
|
+
/**
|
|
42
|
+
* A protected HTTP route as it leaves the layer's middleware, a step-up refusal it fails with
|
|
43
|
+
* answered with its challenge under Bearer; as it is under another scheme.
|
|
44
|
+
*/
|
|
45
|
+
readonly stepUp: HttpApiMiddleware.HttpApiMiddleware<never, never, never>;
|
|
46
|
+
readonly http: <E, R>(route: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, optional: boolean) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R | HttpRouter.Provided>;
|
|
47
|
+
/**
|
|
48
|
+
* A route of the host's own: the request is decoded here, the route gets the identity
|
|
49
|
+
* itself, and a refusal it fails with is answered as an action route's.
|
|
50
|
+
*/
|
|
51
|
+
readonly route: <E, R>(route: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse, Exclude<E, Refusal>, HttpRouter.Provided>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What `Authentication.make` declares: its name, the identity it authenticates, its scheme,
|
|
55
|
+
* and what its verifier may fail with besides a refusal. The `~` keys are the library's own
|
|
56
|
+
* wiring, which no host composes.
|
|
57
|
+
*/
|
|
58
|
+
export interface Descriptor<I, A, S extends Security, Name extends string = string, E extends Errors = readonly []> {
|
|
59
|
+
readonly name: Name;
|
|
60
|
+
readonly identity: Context.Key<I, A>;
|
|
61
|
+
readonly security: S;
|
|
62
|
+
/**
|
|
63
|
+
* What its verifier may fail with besides a refusal, as a list: every protected endpoint
|
|
64
|
+
* declares it, and every remote surface sends it with its status.
|
|
65
|
+
*/
|
|
66
|
+
readonly error: E;
|
|
67
|
+
readonly "~provider": Context.Key<Provider<I, Name>, Runtime$1>;
|
|
68
|
+
readonly "~middleware": Context.Key<SecurityMiddleware<I, Name>, unknown>;
|
|
69
|
+
/** An MCP request's verified identity, which a protected tool's call promotes. */
|
|
70
|
+
readonly "~verified": Context.Key<never, unknown>;
|
|
71
|
+
}
|
|
72
|
+
export type Any = Descriptor<unknown, unknown, Security, string, Errors>;
|
|
73
|
+
export type Identity<A extends Any$1> = [A] extends [never] ? never : A["caller"] extends Context.Key<infer I, unknown> ? I : never;
|
|
74
|
+
/**
|
|
75
|
+
* The identity a descriptor for `A` must authenticate: `unknown` for an erased action, whose
|
|
76
|
+
* key is known only at run time, where `assertAuthentication` checks it.
|
|
77
|
+
*/
|
|
78
|
+
type Covered<A extends Any$1> = Exclude<A["caller"], typeof Anyone> extends Context.Key<infer I, unknown> ? I : never;
|
|
79
|
+
export type Required<A extends Any$1> = [Protected<A>] extends [never] ? {
|
|
80
|
+
readonly authentication?: Any;
|
|
81
|
+
} : {
|
|
82
|
+
readonly authentication: Descriptor<Covered<Protected<A>>, unknown, Security, string, Errors>;
|
|
83
|
+
};
|
|
84
|
+
export type ProviderOf<D> = D extends Descriptor<infer I, unknown, Security, infer Name, Errors> ? Provider<I, Name> : never;
|
|
85
|
+
/** All protected actions on a surface use the descriptor's single identity key. */
|
|
86
|
+
export type Matching<A extends Any$1, D> = unknown extends Covered<Protected<A>> ? unknown : [Exclude<Covered<Protected<A>>, D extends Descriptor<infer I, unknown, Security, string, Errors> ? I : never>] extends [never] ? unknown : {
|
|
87
|
+
readonly "Authentication descriptor does not cover every protected action": never;
|
|
88
|
+
};
|
|
89
|
+
/** Keep public requirements separate, without rescanning all handlers for every action. */
|
|
90
|
+
export type RemoteRequest<App, A extends Any$1> = ServedRequest<App, Extract<A, {
|
|
91
|
+
readonly caller: typeof Anyone;
|
|
92
|
+
}>> | Exclude<ServedRequest<App, Protected<A>>, Identity<Protected<A>>>;
|
|
93
|
+
/**
|
|
94
|
+
* What a call of `A` under the descriptor `D` may fail with from its verifier: the errors `D`
|
|
95
|
+
* declares, for a protected action; none for a public one, which nothing verifies.
|
|
96
|
+
*/
|
|
97
|
+
export type VerifierError<A extends Any$1, D> = A["caller"] extends typeof Anyone ? never : D extends {
|
|
98
|
+
readonly error: infer E extends Errors;
|
|
99
|
+
} ? E[number] : never;
|
|
100
|
+
export type ServedProvider<App, A extends Any$1, D> = [Protected<Serving<App, A>>] extends [never] ? never : ProviderOf<D>;
|
|
101
|
+
//#endregion
|
|
102
|
+
export { Runtime$1 as Runtime };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Unauthenticated } from "./errors.mjs";
|
|
2
|
+
import { Anyone } from "./actions.mjs";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
//#region src/internal/authentication.ts
|
|
5
|
+
const assertAuthentication = (actions, auth) => {
|
|
6
|
+
for (const action of actions) if (action.caller !== Anyone && (auth === void 0 || action.caller.key !== auth.identity.key)) throw new Error(`Protected action '${action.name}' requires its matching authentication descriptor`);
|
|
7
|
+
};
|
|
8
|
+
/** Remote promotion happens at tool entry, before shared dispatch, never in local calls. */
|
|
9
|
+
const promote = (auth, run) => Effect.flatMap(Effect.serviceOption(auth["~verified"]), (actor) => Option.isSome(actor) ? Effect.provideService(run, auth.identity, actor.value) : Effect.fail(new Unauthenticated()));
|
|
10
|
+
//#endregion
|
|
11
|
+
export { assertAuthentication, promote };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "./implementation.mjs";
|
|
2
|
+
import { Action_d_exports, Any } from "../Action.mjs";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
//#region src/internal/call.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A call of `A` answering `R`. It may leave its input out when `{}` is a valid encoded input,
|
|
7
|
+
* such as for an action declared without `input` or one whose every field has a decoding
|
|
8
|
+
* default, and then sends the input `{}` decodes to. Every client shares this rule:
|
|
9
|
+
* `ActionHttp.client`, `Testing.mcpClient` and `Action.client`.
|
|
10
|
+
*/
|
|
11
|
+
export type Call<A extends Any, R> = {} extends A["input"]["Encoded"] ? (...input: [] | [input: A["input"]["Type"]]) => R : (input: A["input"]["Type"]) => R;
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
//#region src/internal/call.ts
|
|
3
|
+
/**
|
|
4
|
+
* The input a method of `action` sends for the arguments it was called with: a given
|
|
5
|
+
* argument as given, and none as the input `{}` decodes to, as a server decodes the `{}` it
|
|
6
|
+
* receives. `Call` allows none only when `{}` is a valid encoded input, which is what this
|
|
7
|
+
* decodes; decoding makes it one of the input's own values, such as an instance of an input
|
|
8
|
+
* class, or fills its defaults.
|
|
9
|
+
*/
|
|
10
|
+
const inputOf = (action, args) => args.length === 0 ? Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))({}) : Effect.succeed(args[0]);
|
|
11
|
+
//#endregion
|
|
12
|
+
export { inputOf };
|
package/dist/internal/cli.d.mts
CHANGED
|
@@ -1,29 +1,43 @@
|
|
|
1
|
-
import "
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import "./errors.mjs";
|
|
2
|
+
import { Action_d_exports, Any } from "../Action.mjs";
|
|
3
|
+
import { Effect, Runtime, Types } from "effect";
|
|
4
|
+
import { CliError, Command } from "effect/cli";
|
|
4
5
|
//#region src/internal/cli.d.ts
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The fields of `A`'s input that may be positional: the named top-level fields of a struct
|
|
8
|
+
* or class input, as encoded. None for any other input, a record or a union included.
|
|
9
|
+
*/
|
|
10
|
+
export type Field<A extends Any> = A["input"]["Encoded"] extends (infer E) ? true extends Types.IsUnion<E> ? never : E extends ReadonlyArray<unknown> ? never : E extends object ? string extends keyof E ? never : Extract<keyof E, string> : never : never;
|
|
11
|
+
/** How one command is named, takes its input and prints its result. */
|
|
12
|
+
export interface Options<A extends Any> {
|
|
13
|
+
/** Override the command name. The action name, in kebab case, is used by default. */
|
|
8
14
|
readonly name?: string;
|
|
9
15
|
/** Human output. Adds a `--json` flag to the command that selects JSON instead. */
|
|
10
|
-
readonly render?: (output:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
readonly
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
readonly
|
|
21
|
-
/** Maps native parsed parameters to the action's canonical JSON input. */
|
|
22
|
-
readonly input: (parsed: Command.Command.Config.InferValue<Parameters>) => Schema.Json;
|
|
16
|
+
readonly render?: (output: A["success"]["Type"]) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Input fields taken as positional arguments instead of flags, in this order. An
|
|
19
|
+
* optional field is an optional argument, so it follows every required one.
|
|
20
|
+
*/
|
|
21
|
+
readonly positional?: ReadonlyArray<Field<A>>;
|
|
22
|
+
/**
|
|
23
|
+
* A short name per input field's flag, such as `{ limit: "n" }` for `-n`, given as
|
|
24
|
+
* `Param.withAlias` takes it.
|
|
25
|
+
*/
|
|
26
|
+
readonly aliases?: { readonly [K in Field<A>]?: string; };
|
|
23
27
|
}
|
|
24
28
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
29
|
+
* What a command fails with when its action fails: Effect CLI's own error for a handler's
|
|
30
|
+
* failure, which `Command.run` prints on stderr and marks reported, so `runMain` does not
|
|
31
|
+
* print it again. Its `cause` is the action's failure. The process exits with the cause's
|
|
32
|
+
* `Runtime.errorExitCode`, 1 by default.
|
|
27
33
|
*/
|
|
28
|
-
export
|
|
34
|
+
export declare class UserError<out E = unknown> extends CliError.UserError {
|
|
35
|
+
readonly cause: E;
|
|
36
|
+
/**
|
|
37
|
+
* The action's failure, as Effect names an error's nested one, so the native
|
|
38
|
+
* `Effect.catchReason("UserError", "UserNotFound", f)` and `catchReasons` match it by tag.
|
|
39
|
+
*/
|
|
40
|
+
get reason(): E;
|
|
41
|
+
get [Runtime.errorExitCode](): number;
|
|
42
|
+
}
|
|
29
43
|
//#endregion
|