@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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Media, isMarked, kindOf } from "./media.mjs";
|
|
2
|
+
import { Predicate, Schema, SchemaAST } from "effect";
|
|
3
|
+
import { McpProtocol } from "effect/ai";
|
|
4
|
+
//#region src/internal/mcp.ts
|
|
5
|
+
/**
|
|
6
|
+
* The one protocol revision served over HTTP. 2026-07-28 is stateless: every request
|
|
7
|
+
* stands alone. The stateful revisions keep a session per `initialize`, which the native
|
|
8
|
+
* HTTP runtime never expires and which no identity owns. A mixed endpoint's gate relies on it
|
|
9
|
+
* too: it decides from the routing headers, which the native runtime checks against the body
|
|
10
|
+
* of a stateless request alone, so serving a stateful revision over HTTP would open the gate.
|
|
11
|
+
*/
|
|
12
|
+
const httpProtocol = McpProtocol.v2026_07_28;
|
|
13
|
+
/** Where `ActionMcp.layerHttp` serves, and `Testing.mcpClient` calls, by default. */
|
|
14
|
+
const defaultPath = "/mcp";
|
|
15
|
+
/** The parameter a method's `mcp-name` header repeats, as the native runtime routes it. */
|
|
16
|
+
const routed = (method, params) => method === "resources/read" ? params.uri : params.name;
|
|
17
|
+
/**
|
|
18
|
+
* One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it:
|
|
19
|
+
* the headers routing it, which repeat what its body says, and its JSON-RPC body. The client
|
|
20
|
+
* metadata goes in `_meta`, under any given, such as a `progressToken`; the protocol version
|
|
21
|
+
* is always the request's own.
|
|
22
|
+
*/
|
|
23
|
+
const statelessRequest = (method, params) => {
|
|
24
|
+
const { protocolVersion } = httpProtocol;
|
|
25
|
+
const name = routed(method, params);
|
|
26
|
+
return {
|
|
27
|
+
headers: {
|
|
28
|
+
accept: "application/json, text/event-stream",
|
|
29
|
+
"mcp-protocol-version": protocolVersion,
|
|
30
|
+
"mcp-method": method,
|
|
31
|
+
...Predicate.isString(name) ? { "mcp-name": name } : {}
|
|
32
|
+
},
|
|
33
|
+
body: {
|
|
34
|
+
jsonrpc: "2.0",
|
|
35
|
+
id: 1,
|
|
36
|
+
method,
|
|
37
|
+
params: {
|
|
38
|
+
...params,
|
|
39
|
+
_meta: {
|
|
40
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
41
|
+
"io.modelcontextprotocol/clientInfo": {
|
|
42
|
+
name: "effect-actions",
|
|
43
|
+
version: "0"
|
|
44
|
+
},
|
|
45
|
+
...params._meta,
|
|
46
|
+
"io.modelcontextprotocol/protocolVersion": protocolVersion
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/** The `_meta` key of a lifted block naming its field. */
|
|
53
|
+
const fieldKey = "effect-actions/field";
|
|
54
|
+
const isMedia = Schema.is(Media);
|
|
55
|
+
const isMediaArray = Schema.is(Schema.Array(Media));
|
|
56
|
+
/** The schema `ast` stands for, a `Schema.suspend` resolved, which only delays its definition. */
|
|
57
|
+
const resolved = (ast) => SchemaAST.isSuspend(ast) ? resolved(ast.thunk()) : ast;
|
|
58
|
+
/** The kind `ast` is, suspended or not. */
|
|
59
|
+
const kindIn = (ast) => kindOf(resolved(ast));
|
|
60
|
+
/**
|
|
61
|
+
* The media `ast` is, one value or an array of them, as a whole success may be: an array without
|
|
62
|
+
* an encoding of its own whose every element is media of one kind, such as `Schema.Array` or
|
|
63
|
+
* `Schema.NonEmptyArray` of it.
|
|
64
|
+
*/
|
|
65
|
+
const wholeOf = (suspended) => {
|
|
66
|
+
const ast = resolved(suspended);
|
|
67
|
+
const one = kindOf(ast);
|
|
68
|
+
if (one !== void 0) return {
|
|
69
|
+
kind: one,
|
|
70
|
+
many: false
|
|
71
|
+
};
|
|
72
|
+
if (!SchemaAST.isArrays(ast) || ast.encoding !== void 0) return void 0;
|
|
73
|
+
const [kind, ...others] = new Set([...ast.elements, ...ast.rest].map(kindIn));
|
|
74
|
+
return kind === void 0 || others.length > 0 ? void 0 : {
|
|
75
|
+
kind,
|
|
76
|
+
many: true
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The media a field's `ast` is: one value, which `Schema.optional` or `Schema.optionalKey` may
|
|
81
|
+
* make absent, or an array, which they may not, as no block would tell an absent one from an
|
|
82
|
+
* empty one.
|
|
83
|
+
*/
|
|
84
|
+
const fieldOf = (field) => {
|
|
85
|
+
const optional = field.context?.isOptional === true;
|
|
86
|
+
const ast = resolved(field);
|
|
87
|
+
if (optional && SchemaAST.isUnion(ast) && ast.types.length === 2) {
|
|
88
|
+
const [value, ...more] = ast.types.filter((member) => !SchemaAST.isUndefined(member));
|
|
89
|
+
const kind = value === void 0 || more.length > 0 ? void 0 : kindIn(value);
|
|
90
|
+
return kind === void 0 ? void 0 : {
|
|
91
|
+
kind,
|
|
92
|
+
many: false
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const whole = wholeOf(ast);
|
|
96
|
+
return whole === void 0 || optional && whole.many ? void 0 : whole;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* How an MCP tool sends `success`: it lifts the success itself, one value or an array, or the
|
|
100
|
+
* top-level media fields of a struct without an encoding of its own, keyed by strings, and
|
|
101
|
+
* sends the rest of the struct, if any is left.
|
|
102
|
+
*/
|
|
103
|
+
const liftOf = (success) => {
|
|
104
|
+
const ast = resolved(success.ast);
|
|
105
|
+
const whole = wholeOf(ast);
|
|
106
|
+
if (whole !== void 0) return {
|
|
107
|
+
fields: [{
|
|
108
|
+
name: void 0,
|
|
109
|
+
...whole
|
|
110
|
+
}],
|
|
111
|
+
rest: void 0
|
|
112
|
+
};
|
|
113
|
+
if (!SchemaAST.isObjects(ast) || ast.encoding !== void 0) return {
|
|
114
|
+
fields: [],
|
|
115
|
+
rest: success
|
|
116
|
+
};
|
|
117
|
+
const fields = ast.propertySignatures.flatMap(({ name, type }) => {
|
|
118
|
+
const field = fieldOf(type);
|
|
119
|
+
return field === void 0 ? [] : [{
|
|
120
|
+
name: String(name),
|
|
121
|
+
...field
|
|
122
|
+
}];
|
|
123
|
+
});
|
|
124
|
+
if (fields.length === 0) return {
|
|
125
|
+
fields,
|
|
126
|
+
rest: success
|
|
127
|
+
};
|
|
128
|
+
const names = new Set(fields.map(({ name }) => name));
|
|
129
|
+
const rest = ast.propertySignatures.filter(({ name }) => !names.has(name));
|
|
130
|
+
const { identifier: _identifier, ...annotations } = ast.annotations ?? {};
|
|
131
|
+
return {
|
|
132
|
+
fields,
|
|
133
|
+
rest: rest.length === 0 && ast.indexSignatures.length === 0 ? void 0 : Schema.make(new SchemaAST.Objects(rest, ast.indexSignatures, annotations))
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
/** The schemas `ast` is made of, its encoded side included. */
|
|
137
|
+
const partsOf = (ast) => [
|
|
138
|
+
...SchemaAST.isDeclaration(ast) ? ast.typeParameters : [],
|
|
139
|
+
...SchemaAST.isArrays(ast) ? [...ast.elements, ...ast.rest] : [],
|
|
140
|
+
...SchemaAST.isObjects(ast) ? [...ast.propertySignatures, ...ast.indexSignatures].map(({ type }) => type) : [],
|
|
141
|
+
...SchemaAST.isUnion(ast) ? ast.types : [],
|
|
142
|
+
...SchemaAST.isSuspend(ast) ? [ast.thunk()] : [],
|
|
143
|
+
...(ast.encoding ?? []).map(({ to }) => to)
|
|
144
|
+
];
|
|
145
|
+
/** Whether `ast` holds media anywhere, each schema visited once, so a recursive one ends. */
|
|
146
|
+
const holdsMedia = (ast) => {
|
|
147
|
+
const seen = /* @__PURE__ */ new Set();
|
|
148
|
+
const pending = [ast];
|
|
149
|
+
for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
|
|
150
|
+
if (seen.has(next)) continue;
|
|
151
|
+
seen.add(next);
|
|
152
|
+
if (isMarked(next)) return true;
|
|
153
|
+
pending.push(...partsOf(next));
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Whether `action`, whose success an MCP tool lifts as `lift` says, holds media where no tool
|
|
159
|
+
* lifts it: in its input, in a declared error, or in the rest of its success, such as nested in
|
|
160
|
+
* a field or in a union.
|
|
161
|
+
*/
|
|
162
|
+
const isMisplaced = (action, { rest }) => {
|
|
163
|
+
return [
|
|
164
|
+
action.input,
|
|
165
|
+
...action.error,
|
|
166
|
+
...rest === void 0 ? [] : [rest]
|
|
167
|
+
].some(({ ast }) => holdsMedia(ast));
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* The media `lift` finds in `success`, a decoded value its schema has validated: the whole
|
|
171
|
+
* success's, or each field's, in field order, an array's in its order, none for an absent field.
|
|
172
|
+
*/
|
|
173
|
+
const liftedFrom = (lift, success) => lift.fields.flatMap(({ name, kind, many }) => {
|
|
174
|
+
const value = name === void 0 ? success : Predicate.hasProperty(success, name) ? success[name] : void 0;
|
|
175
|
+
return (many ? isMediaArray(value) ? value : [] : isMedia(value) ? [value] : []).map((media) => ({
|
|
176
|
+
kind,
|
|
177
|
+
field: name,
|
|
178
|
+
value: media
|
|
179
|
+
}));
|
|
180
|
+
});
|
|
181
|
+
//#endregion
|
|
182
|
+
export { defaultPath, fieldKey, httpProtocol, isMisplaced, liftOf, liftedFrom, statelessRequest };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Schema, SchemaAST } from "effect";
|
|
2
|
+
//#region src/internal/media.d.ts
|
|
3
|
+
/** An image, a media field's schema, as `Action.Image` exports it. */
|
|
4
|
+
export declare const Image: Schema.Struct<{
|
|
5
|
+
readonly data: Schema.Uint8Array;
|
|
6
|
+
readonly mimeType: Schema.String;
|
|
7
|
+
}>;
|
|
8
|
+
//#endregion
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
//#region src/internal/media.ts
|
|
3
|
+
/**
|
|
4
|
+
* The annotation marking a media kind's schema, naming the MCP content block a value of it is
|
|
5
|
+
* lifted into: `Action.Image`'s is `"image"`.
|
|
6
|
+
*/
|
|
7
|
+
const mediaKey = "effect-actions/media";
|
|
8
|
+
/** What every media kind holds: bytes, and their MIME type. */
|
|
9
|
+
const Media = Schema.Struct({
|
|
10
|
+
data: Schema.Uint8Array,
|
|
11
|
+
mimeType: Schema.String
|
|
12
|
+
});
|
|
13
|
+
/** An image, a media field's schema, as `Action.Image` exports it. */
|
|
14
|
+
const Image = Media.annotate({ [mediaKey]: "image" });
|
|
15
|
+
/**
|
|
16
|
+
* Whether `ast` is marked as media, read from its own annotations: a copy, such as an optional
|
|
17
|
+
* key or one with checks added, keeps them, and so does one with an encoding of its own.
|
|
18
|
+
*/
|
|
19
|
+
const isMarked = (ast) => ast.annotations?.[mediaKey] === "image";
|
|
20
|
+
/**
|
|
21
|
+
* The kind `ast` is, when its values are the media they encode to: marked, without an encoding
|
|
22
|
+
* of its own, which would send its values as something else.
|
|
23
|
+
*/
|
|
24
|
+
const kindOf = (ast) => isMarked(ast) && ast.encoding === void 0 ? "image" : void 0;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { Image, Media, isMarked, kindOf };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpClient, HttpRouter, HttpServer } from "effect/http";
|
|
3
|
+
//#region src/internal/memory.d.ts
|
|
4
|
+
/** What in-memory routes may leave to the host: the router, the platform, nothing per request. */
|
|
5
|
+
export type Served = HttpRouter.HttpRouter | HttpRouter.Request<"Requires", never> | HttpRouter.Request<"GlobalRequires", never> | HttpRouter.Request<"Error", any> | HttpRouter.Request<"GlobalError", any> | Layer.Success<typeof HttpServer.layerServices>;
|
|
6
|
+
//#endregion
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Effect, Layer, Predicate, Stream } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
//#region src/internal/memory.ts
|
|
4
|
+
/** Where relative request URLs resolve, so a client needs no `baseUrl`. */
|
|
5
|
+
const origin = "http://localhost/";
|
|
6
|
+
/**
|
|
7
|
+
* `request`, a streamed body turned into a web stream in the current context. The native client
|
|
8
|
+
* turns it in its send, under the in-memory `fetch`, where another client the stream calls
|
|
9
|
+
* would send to the routes too.
|
|
10
|
+
*/
|
|
11
|
+
const pulled = (request) => {
|
|
12
|
+
const body = request.body;
|
|
13
|
+
if (!Predicate.isTagged(body, "Stream")) return Effect.succeed(request);
|
|
14
|
+
return Effect.map(Stream.toReadableStreamEffect(body.stream), (readable) => HttpClientRequest.setBody(request, HttpBody.raw(readable, {
|
|
15
|
+
contentType: body.contentType,
|
|
16
|
+
contentLength: body.contentLength
|
|
17
|
+
})).pipe(HttpClientRequest.updateHeaders(() => request.headers)));
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The native `HttpClient`, sending every request to `handler` instead of the network. A
|
|
21
|
+
* relative URL resolves against `http://localhost/`, as a page there would resolve it, and a
|
|
22
|
+
* request carries the `Host` header of its URL, as one over the network does. The client is
|
|
23
|
+
* its own: the program's other clients keep their `fetch`, and get none of its requests.
|
|
24
|
+
*/
|
|
25
|
+
const clientOf = (handler) => {
|
|
26
|
+
const fetch = (input, init) => {
|
|
27
|
+
const request = new Request(input, init);
|
|
28
|
+
if (!request.headers.has("host")) request.headers.set("host", new URL(request.url).host);
|
|
29
|
+
return handler(request);
|
|
30
|
+
};
|
|
31
|
+
return Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (native) => HttpClient.makeWith((request) => Effect.flatMap(request, (mapped) => {
|
|
32
|
+
const resolved = URL.canParse(mapped.url) ? null : URL.parse(mapped.url, origin);
|
|
33
|
+
const sent = resolved === null ? mapped : HttpClientRequest.setUrl(mapped, resolved.href);
|
|
34
|
+
return Effect.flatMap(pulled(sent), (ready) => native.postprocess(Effect.succeed(ready)).pipe(Effect.provideService(FetchHttpClient.Fetch, fetch)));
|
|
35
|
+
}), native.preprocess))).pipe(Layer.provide(Layer.fresh(FetchHttpClient.layer)));
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { clientOf };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Refusal, statuses } from "./errors.mjs";
|
|
2
|
+
import { Cause, Context, Effect, Exit, Option, Predicate, Ref, Schema } from "effect";
|
|
3
|
+
import { HttpServerResponse } from "effect/http";
|
|
4
|
+
//#region src/internal/refusal.ts
|
|
5
|
+
/** An RFC 6750 error description: printable ASCII but `"` and `\`. */
|
|
6
|
+
const description = /^[\x20\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
7
|
+
/**
|
|
8
|
+
* An RFC 9110 challenge of `scheme` and the parameters given, each a quoted string whose `"`
|
|
9
|
+
* and `\` are escaped: a metadata URL's query may hold a `\`.
|
|
10
|
+
*/
|
|
11
|
+
const challenge = (scheme, parameters) => {
|
|
12
|
+
const given = parameters.flatMap(([name, value]) => value === void 0 ? [] : [`${name}="${value.replace(/["\\]/g, "\\$&")}"`]);
|
|
13
|
+
return given.length === 0 ? scheme : `${scheme} ${given.join(", ")}`;
|
|
14
|
+
};
|
|
15
|
+
/** An RFC 6750 `Bearer` challenge of the parameters given. */
|
|
16
|
+
const bearer = (parameters) => challenge("Bearer", parameters);
|
|
17
|
+
const encode = Schema.encodeSync(Refusal);
|
|
18
|
+
/** Whether `error` is a built-in refusal, `Unauthenticated` or `Forbidden`. */
|
|
19
|
+
const isRefusal = Schema.is(Refusal);
|
|
20
|
+
/**
|
|
21
|
+
* The RFC 6750 `insufficient_scope` challenge an OAuth client steps up on, for a `Forbidden`
|
|
22
|
+
* naming scopes, naming the resource's metadata URL when there is one.
|
|
23
|
+
*/
|
|
24
|
+
const insufficientScope = (error, metadataUrl) => Predicate.isTagged(error, "Forbidden") && error.scopes !== void 0 ? bearer([
|
|
25
|
+
["error", "insufficient_scope"],
|
|
26
|
+
["scope", error.scopes.join(" ")],
|
|
27
|
+
["resource_metadata", metadataUrl],
|
|
28
|
+
["error_description", description.test(error.message) ? error.message : void 0]
|
|
29
|
+
]) : void 0;
|
|
30
|
+
/** A refusal as its JSON with its status alone, as a scheme no OAuth client acts on answers it. */
|
|
31
|
+
const plain = (error) => HttpServerResponse.jsonUnsafe(encode(error), { status: statuses[error._tag] });
|
|
32
|
+
/**
|
|
33
|
+
* A refusal as its JSON with its status, as every endpoint declares it, and its
|
|
34
|
+
* `insufficient_scope` challenge if it has one.
|
|
35
|
+
*/
|
|
36
|
+
const answer = (error, metadataUrl) => {
|
|
37
|
+
const response = plain(error);
|
|
38
|
+
const scoped = insufficientScope(error, metadataUrl);
|
|
39
|
+
return scoped === void 0 ? response : HttpServerResponse.setHeader(response, "www-authenticate", scoped);
|
|
40
|
+
};
|
|
41
|
+
/** The step-up refusal a tool's call under `answerStepUp` failed with, which answers its request. */
|
|
42
|
+
var SteppedUp = class extends Context.Service()("effect-actions/SteppedUp") {};
|
|
43
|
+
/**
|
|
44
|
+
* Whether `error` is a refusal an OAuth client acts on: `Unauthenticated`, or `Forbidden`
|
|
45
|
+
* naming scopes.
|
|
46
|
+
*/
|
|
47
|
+
const isStepUp = (error) => isRefusal(error) && (!Predicate.isTagged(error, "Forbidden") || error.scopes !== void 0);
|
|
48
|
+
/**
|
|
49
|
+
* A protected MCP tool's `call`, whose step-up refusal answers the request under a Bearer
|
|
50
|
+
* descriptor's `answerStepUp`. Elsewhere it is `call` as it is.
|
|
51
|
+
*/
|
|
52
|
+
const recordStepUp = (call) => Effect.tapError(call, (error) => isStepUp(error) ? Effect.flatMap(Effect.serviceOption(SteppedUp), (slot) => Option.isSome(slot) ? Ref.set(slot.value, Option.some(error)) : Effect.void) : Effect.void);
|
|
53
|
+
/**
|
|
54
|
+
* An MCP request, answered with the step-up refusal a tool's call in it failed with, its
|
|
55
|
+
* status, JSON and challenge naming `metadataUrl`, however the request ended short of a defect
|
|
56
|
+
* or an interruption: a tool's failure is a successful result, and MCP authorization requires
|
|
57
|
+
* 401 or 403.
|
|
58
|
+
*/
|
|
59
|
+
const answerStepUp = (route, metadataUrl) => Effect.gen(function* () {
|
|
60
|
+
const slot = yield* Ref.make(Option.none());
|
|
61
|
+
const exit = yield* Effect.exit(Effect.provideService(route, SteppedUp, slot));
|
|
62
|
+
const refused = yield* Ref.get(slot);
|
|
63
|
+
const broken = Exit.isFailure(exit) && (Cause.hasDies(exit.cause) || Cause.hasInterrupts(exit.cause));
|
|
64
|
+
return Option.isSome(refused) && !broken ? answer(refused.value, metadataUrl) : yield* exit;
|
|
65
|
+
});
|
|
66
|
+
//#endregion
|
|
67
|
+
export { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain, recordStepUp };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { InvalidInput } from "./errors.mjs";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { HttpApiMiddleware } from "effect/http-api";
|
|
4
|
+
//#region src/internal/schema-errors.ts
|
|
5
|
+
/**
|
|
6
|
+
* `HttpApiBuilder` reports these kinds while encoding the handler's answer, after the
|
|
7
|
+
* handler ran; every other kind (`Payload`, `Params`, `Headers`, `Query`) comes from
|
|
8
|
+
* decoding the request before it.
|
|
9
|
+
*/
|
|
10
|
+
const responseKinds = /* @__PURE__ */ new Set(["Body", "ResponseHeaders"]);
|
|
11
|
+
/**
|
|
12
|
+
* The native middleware answering schema failures: a request that does not decode with
|
|
13
|
+
* `InvalidInput`, the schema's own message and issues. A result that does not encode stays the
|
|
14
|
+
* native failure, which Effect answers with an empty 500 and reports.
|
|
15
|
+
*/
|
|
16
|
+
var SchemaErrors = class extends HttpApiMiddleware.Service()("effect-actions/http/SchemaErrors", { error: [InvalidInput] }) {};
|
|
17
|
+
/** `SchemaErrors`' server side, built by `ActionHttp.layer` alone, so a client's bundle leaves it out. */
|
|
18
|
+
const schemaErrors = () => HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(responseKinds.has(failure.kind) ? failure : InvalidInput.fromSchemaError(failure.cause)));
|
|
19
|
+
//#endregion
|
|
20
|
+
export { SchemaErrors, schemaErrors };
|
package/dist/internal/tools.mjs
CHANGED
|
@@ -1,74 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { assertOnce, projectedErrors } from "./actions.mjs";
|
|
2
|
+
import { acquire } from "./implementation.mjs";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
4
|
import { Tool, Toolkit } from "effect/ai";
|
|
5
5
|
//#region src/internal/tools.ts
|
|
6
|
+
/** `tool` annotated with `value` under `key`, or as it is where `value` is not given. */
|
|
7
|
+
const annotateIf = (tool, key, value) => value === void 0 ? tool : tool.annotate(key, value);
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* it
|
|
9
|
+
* A tool's annotations: read-only exactly when its action is, and the contract's `mcp` ones
|
|
10
|
+
* where given, so the native defaults, MCP's, stand for the others. A read-only tool is not
|
|
11
|
+
* destructive unless it says so, where the native default calls every tool destructive.
|
|
10
12
|
*/
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
app,
|
|
18
|
-
actions
|
|
19
|
-
}];
|
|
20
|
-
});
|
|
21
|
-
/** Flatten the projected contracts only after deciding which groups are served. */
|
|
22
|
-
const entries = (apps) => apps.flatMap(({ app, actions }) => actions.map((action) => ({
|
|
23
|
-
app,
|
|
24
|
-
action
|
|
25
|
-
})));
|
|
26
|
-
const annotate = (tool, mcp) => tool.annotate(Tool.Readonly, mcp.readOnly).annotate(Tool.Destructive, mcp.destructive).annotate(Tool.Idempotent, mcp.idempotent).annotate(Tool.OpenWorld, mcp.openWorld);
|
|
27
|
-
/**
|
|
28
|
-
* A native Effect AI tool. Its schemas retain action transforms and its result
|
|
29
|
-
* is the action result itself, rather than an MCP response envelope.
|
|
30
|
-
*/
|
|
31
|
-
const nativeTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
|
|
32
|
-
description: action.description,
|
|
33
|
-
parameters: action.input,
|
|
34
|
-
success: action.success,
|
|
35
|
-
failure: Schema.Union(errors),
|
|
36
|
-
failureMode: "return"
|
|
37
|
-
}), action.mcp);
|
|
13
|
+
const annotate = (tool, { readOnly, mcp }) => {
|
|
14
|
+
const destructive = mcp.destructiveHint ?? (readOnly ? false : void 0);
|
|
15
|
+
const meta = mcp._meta === void 0 ? void 0 : { ...mcp._meta };
|
|
16
|
+
const hinted = annotateIf(annotateIf(annotateIf(tool.annotate(Tool.Readonly, readOnly), Tool.Destructive, destructive), Tool.Idempotent, mcp.idempotentHint), Tool.OpenWorld, mcp.openWorldHint);
|
|
17
|
+
return annotateIf(annotateIf(hinted, Tool.Title, mcp.title), Tool.Meta, meta);
|
|
18
|
+
};
|
|
38
19
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
20
|
+
* The native tool of `action`. A model and an MCP client speak JSON, so it takes and gives
|
|
21
|
+
* the JSON encoding its schemas advertise, the whole success; handlers and callers see decoded
|
|
22
|
+
* values. A refusal by authentication or `authorize`, or a handler's built-in failure, is the implementation's failure, so
|
|
23
|
+
* it declares the built-in errors alongside the action's own, and returns them as its result.
|
|
41
24
|
*/
|
|
42
|
-
const
|
|
25
|
+
const toolOf = (action) => Tool.make(action.name, {
|
|
43
26
|
description: action.description,
|
|
44
27
|
parameters: Schema.toCodecJson(action.input),
|
|
45
28
|
success: Schema.toCodecJson(action.success),
|
|
46
|
-
failure: Schema.toCodecJson(Schema.Union(
|
|
29
|
+
failure: Schema.toCodecJson(Schema.Union(projectedErrors(action))),
|
|
47
30
|
failureMode: "return"
|
|
48
|
-
})
|
|
49
|
-
const project = (projection, entry, options) => {
|
|
50
|
-
const errors = projectedErrors(entry.action, options.errors);
|
|
51
|
-
const tool = projection === "native" ? nativeTool(entry.action, errors) : mcpTool(entry.action, errors);
|
|
52
|
-
if (projection === "native") return tool;
|
|
53
|
-
return tool.annotate(Tool.Strict, true);
|
|
54
|
-
};
|
|
31
|
+
});
|
|
55
32
|
/**
|
|
56
|
-
* Project
|
|
57
|
-
* Native and MCP differ only in tool codecs; selection, scoped acquisition, dispatch
|
|
58
|
-
* and native Toolkit binding stay identical.
|
|
33
|
+
* Project actions as tools named after them, sharing each implementation's built handlers.
|
|
59
34
|
*/
|
|
60
|
-
const bindTools = (apps, projection
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
assertDistinct("MCP tool", selected.map(({ action }) => action.mcp.name));
|
|
64
|
-
const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry, options)));
|
|
35
|
+
const bindTools = (apps, projection) => {
|
|
36
|
+
assertOnce(projection.label, apps.flatMap((app) => app.actions));
|
|
37
|
+
const toolkit = Toolkit.make(...apps.flatMap((app) => app.actions.map((action) => annotate(projection.tool(toolOf(action), action, app), action))));
|
|
65
38
|
return {
|
|
66
39
|
toolkit,
|
|
67
|
-
layer: toolkit.toLayer(Effect.map(
|
|
68
|
-
const record = handlers;
|
|
69
|
-
return actions.map((action) => [action.mcp.name, dispatch(app.group, action, record, options.before)]);
|
|
70
|
-
})), (built) => Object.fromEntries(built.flat())))
|
|
40
|
+
layer: toolkit.toLayer(Effect.map(acquire(apps), (bound) => Object.fromEntries(bound.map(([action, run]) => [action.name, projection.handler?.(run, action) ?? run]))))
|
|
71
41
|
};
|
|
72
42
|
};
|
|
73
43
|
//#endregion
|
|
74
|
-
export {
|
|
44
|
+
export { bindTools };
|