@gjermundgaraba/effect-actions 0.9.0 → 0.10.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 +57 -50
- package/dist/Action.d.mts +285 -79
- package/dist/Action.mjs +124 -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 +108 -81
- 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 +82 -57
- package/dist/Testing.mjs +89 -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 +11 -0
- package/dist/internal/mcp.mjs +52 -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 +401 -73
- package/docs/ActionCli.md +280 -51
- package/docs/ActionHttp.md +266 -82
- package/docs/ActionMcp.md +149 -104
- package/docs/ActionToolkit.md +97 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +23 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +81 -43
- package/docs/setup.md +90 -27
- package/package.json +7 -15
- 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/Authentication.mjs
CHANGED
|
@@ -1,55 +1,248 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Unauthenticated, scopeToken } from "./internal/errors.mjs";
|
|
2
|
+
import { assertOwnTags, errorList } from "./internal/actions.mjs";
|
|
3
|
+
import { declaredResponse } from "./internal/declared.mjs";
|
|
4
|
+
import { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain } from "./internal/refusal.mjs";
|
|
5
|
+
import { Context, Effect, Layer, Match, Option, Predicate, Redacted } from "effect";
|
|
2
6
|
import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
7
|
+
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/http-api";
|
|
3
8
|
//#region src/Authentication.ts
|
|
4
9
|
/**
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* while the handler is running. Every response is marked `Cache-Control: no-store`,
|
|
10
|
-
* including private failures serialized by enclosing middleware.
|
|
10
|
+
* An `Authorization` header of the `Bearer` scheme, and its token, as Effect's own
|
|
11
|
+
* `HttpApiSecurity.bearer` decodes a request's: the scheme, matched case-insensitively, one or
|
|
12
|
+
* more spaces, then the rest of the header, whatever it holds, a tab included. The whitespace
|
|
13
|
+
* around a header value is no part of it, and an HTTP parser strips it before any route reads it.
|
|
11
14
|
*/
|
|
12
|
-
const
|
|
13
|
-
onFailure: Effect.succeed,
|
|
14
|
-
onSuccess: (identity) => Effect.provideService(httpEffect, service, identity)
|
|
15
|
-
}), HttpEffect.withPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")))));
|
|
16
|
-
const quoted = (value) => `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
15
|
+
const bearerScheme = /^[ \t]*Bearer +(.*[^ \t])[ \t]*$/i;
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* The bearer token of an `Authorization` header, `authorization`, or none, read as Effect's
|
|
18
|
+
* `HttpApiSecurity.bearer` reads it for routes and MCP, which a test holds equal. It is for a
|
|
19
|
+
* caller the router never routes, which holds the header and no request; a route of the host's
|
|
20
|
+
* own is authenticated by `protect`. The scheme is matched case-insensitively, as RFC 9110
|
|
21
|
+
* requires, and the token is `Redacted`, as Effect's own decoder gives it.
|
|
20
22
|
*/
|
|
21
|
-
const
|
|
23
|
+
const bearerTokenOf = (authorization) => Option.map(Option.fromNullishOr(bearerScheme.exec(authorization ?? "")?.[1]), (token) => Redacted.make(token));
|
|
24
|
+
/**
|
|
25
|
+
* The RFC 9728 metadata URL of `options`: `/.well-known/oauth-protected-resource` followed
|
|
26
|
+
* by the resource's path, where MCP clients look when a 401 names no metadata URL.
|
|
27
|
+
*/
|
|
28
|
+
const metadataUrl = (options) => {
|
|
22
29
|
const resource = new URL(options.resource);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const url = new URL(resource);
|
|
31
|
+
url.pathname = `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`;
|
|
32
|
+
return url;
|
|
33
|
+
};
|
|
34
|
+
/** What challenges about `options` name. */
|
|
35
|
+
const namedOf = (options) => ({
|
|
36
|
+
scope: options?.scopesRequired?.join(" "),
|
|
37
|
+
metadataUrl: options === void 0 ? void 0 : metadataUrl(options).href
|
|
38
|
+
});
|
|
39
|
+
/** Whether a request whose `Authorization` header is `authorization` presented a bearer token. */
|
|
40
|
+
const tokenPresented = (authorization) => Option.isSome(bearerTokenOf(authorization));
|
|
41
|
+
/**
|
|
42
|
+
* The challenge of a 401: `Bearer`, naming the scopes a client requests and the metadata URL
|
|
43
|
+
* where it finds its authorization server. A request that presented a bearer token is told
|
|
44
|
+
* it is invalid, RFC 6750's `invalid_token`, on which a client may refresh its token before
|
|
45
|
+
* it signs in again; one that presented none names no error code.
|
|
46
|
+
*/
|
|
47
|
+
const challengeOf = (named, presented) => bearer([
|
|
48
|
+
["error", presented ? "invalid_token" : void 0],
|
|
49
|
+
["scope", named.scope],
|
|
50
|
+
["resource_metadata", named.metadataUrl]
|
|
51
|
+
]);
|
|
52
|
+
/**
|
|
53
|
+
* Refuse a protected resource that cannot be published, as `layer` and `refusalResponse` both
|
|
54
|
+
* do: one under a scheme other than Bearer (`oauth` false), a `scopesSupported` or
|
|
55
|
+
* `scopesRequired` that is no list of OAuth scope tokens, as `Forbidden` refuses, and a
|
|
56
|
+
* resource with a fragment, which no request URL carries, so its discovery would never answer
|
|
57
|
+
* (RFC 9728). A resource an Effect builds is checked for its scheme alone, and for the rest
|
|
58
|
+
* once built.
|
|
59
|
+
*/
|
|
60
|
+
const assertResource = (oauth, options) => {
|
|
61
|
+
if (options === void 0) return;
|
|
62
|
+
if (!oauth) throw new Error("A protected resource is published only for a Bearer scheme");
|
|
63
|
+
if (Effect.isEffect(options)) return;
|
|
64
|
+
for (const key of ["scopesSupported", "scopesRequired"]) {
|
|
65
|
+
const invalid = options[key]?.find((scope) => !scopeToken.test(scope));
|
|
66
|
+
if (invalid !== void 0) throw new Error(`Invalid scope in ${key}: "${invalid}"`);
|
|
67
|
+
}
|
|
68
|
+
if (new URL(options.resource).href.includes("#")) throw new Error(`A protected resource has no fragment: ${options.resource}`);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* `response` as the routes of the authentication answer: `no-store` unless it states its
|
|
72
|
+
* own caching, and a 401 challenged with `challenge` unless it names its own.
|
|
73
|
+
*/
|
|
74
|
+
const settle = (response, challenge) => HttpServerResponse.setHeaders(response, {
|
|
75
|
+
...response.headers["cache-control"] === void 0 ? { "cache-control": "no-store" } : {},
|
|
76
|
+
...challenge !== void 0 && response.status === 401 && response.headers["www-authenticate"] === void 0 ? { "www-authenticate": challenge } : {}
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* The response `layer` answers a refusal with, for a caller the router never routes, such as
|
|
80
|
+
* a Node `upgrade` handler admitting a socket: its status, its JSON, `Cache-Control: no-store`
|
|
81
|
+
* and its challenge, an `Unauthenticated`'s `Bearer`, naming `invalid_token` when
|
|
82
|
+
* `authorization` presented a bearer token, or the `insufficient_scope` of a `Forbidden`
|
|
83
|
+
* naming scopes. Given the protected resource `layer` was, every challenge names its metadata
|
|
84
|
+
* URL and a 401's its `scopesRequired`. Under a descriptor of another scheme, it is the JSON
|
|
85
|
+
* and status alone, a 401 naming that scheme. An error `authentication` declares is sent as
|
|
86
|
+
* its endpoints declare it, its JSON with its status. `HttpServerResponse.toWeb` gives it as a
|
|
87
|
+
* web `Response`.
|
|
88
|
+
*/
|
|
89
|
+
const refusalResponse = (error, options) => {
|
|
90
|
+
const authentication = options?.authentication;
|
|
91
|
+
const oauth = authentication === void 0 || isBearer(authentication.security);
|
|
92
|
+
assertResource(oauth, options?.protectedResource);
|
|
93
|
+
const named = namedOf(options?.protectedResource);
|
|
94
|
+
const challenge = authentication !== void 0 && !oauth ? schemeChallenge(authentication.security, authentication.name) : challengeOf(named, tokenPresented(options?.authorization));
|
|
95
|
+
if (!isRefusal(error)) {
|
|
96
|
+
const response = declaredResponse(authentication?.error ?? [], error);
|
|
97
|
+
if (response === void 0) throw new Error("Not a refusal, nor an error the authentication declares");
|
|
98
|
+
return settle(response, challenge);
|
|
99
|
+
}
|
|
100
|
+
return settle(oauth ? answer(error, named.metadataUrl) : plain(error), challenge);
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Whether a request URL may resolve to a discovery path, which every other request skips
|
|
104
|
+
* parsing: the URL parser copies `.well-known` as it is, removing only a tab or a newline. Node
|
|
105
|
+
* refuses a target holding those before routing; a platform passing one through still parses.
|
|
106
|
+
*/
|
|
107
|
+
const mayDiscover = /\.well-known|[\t\n\r]/;
|
|
108
|
+
/**
|
|
109
|
+
* RFC 9728 discovery of `options` at its metadata URL, as global router middleware: it
|
|
110
|
+
* answers before routing, so no route middleware, authentication included, ever covers it.
|
|
111
|
+
* The metadata is public to every origin, as a browser client needs it after a 401: it
|
|
112
|
+
* carries `Access-Control-Allow-Origin: *`, and discovery answers its own CORS preflight.
|
|
113
|
+
* Where the host's CORS middleware runs before it, that policy answers the preflight and
|
|
114
|
+
* adds its headers to reads, which keep the `*` where it sets no origin.
|
|
115
|
+
*/
|
|
116
|
+
const discovery = (options) => {
|
|
117
|
+
const discoveryUrl = metadataUrl(options);
|
|
118
|
+
const target = discoveryUrl.href.slice(discoveryUrl.origin.length);
|
|
119
|
+
const metadata = HttpServerResponse.jsonUnsafe({
|
|
29
120
|
resource: options.resource,
|
|
30
121
|
authorization_servers: options.authorizationServers,
|
|
31
122
|
bearer_methods_supported: ["header"],
|
|
32
123
|
scopes_supported: options.scopesSupported,
|
|
33
124
|
resource_name: options.resourceName
|
|
125
|
+
}, { headers: { "access-control-allow-origin": "*" } });
|
|
126
|
+
/** The preflight of a cross-origin read, allowing the headers it asks for. */
|
|
127
|
+
const preflight = (request) => {
|
|
128
|
+
const requested = request.headers["access-control-request-headers"];
|
|
129
|
+
return HttpServerResponse.empty({
|
|
130
|
+
status: 204,
|
|
131
|
+
headers: {
|
|
132
|
+
"access-control-allow-origin": "*",
|
|
133
|
+
"access-control-allow-methods": "GET, HEAD, OPTIONS",
|
|
134
|
+
...requested === void 0 ? {} : {
|
|
135
|
+
"access-control-allow-headers": requested,
|
|
136
|
+
vary: "Access-Control-Request-Headers"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
return (next) => Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => {
|
|
142
|
+
const targeted = mayDiscover.test(request.url) && URL.canParse(request.url, discoveryUrl.origin) && new URL(request.url, discoveryUrl.origin).href.slice(discoveryUrl.origin.length) === target;
|
|
143
|
+
if (targeted && (request.method === "GET" || request.method === "HEAD")) return Effect.succeed(metadata);
|
|
144
|
+
if (targeted && request.method === "OPTIONS") return Effect.succeed(preflight(request));
|
|
145
|
+
return next;
|
|
34
146
|
});
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* What `layer` answers about the protected resource `options`, written once: its metadata
|
|
150
|
+
* URL, each 401's challenge, to a request that presented a bearer token and to one that did
|
|
151
|
+
* not, and its discovery, none without a resource.
|
|
152
|
+
*/
|
|
153
|
+
const answersOf = (options) => {
|
|
154
|
+
const named = namedOf(options);
|
|
35
155
|
return {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return yield* next;
|
|
41
|
-
}), { global: true }),
|
|
42
|
-
metadataUrl,
|
|
43
|
-
/** The `WWW-Authenticate` value; parameter values are quoted and escaped. */
|
|
44
|
-
challenge: (challenge = {}) => {
|
|
45
|
-
return `Bearer ${[
|
|
46
|
-
["resource_metadata", metadataUrl],
|
|
47
|
-
["error", challenge.error],
|
|
48
|
-
["error_description", challenge.errorDescription],
|
|
49
|
-
["scope", challenge.scope]
|
|
50
|
-
].flatMap(([name, value]) => value === void 0 ? [] : [`${name}=${quoted(value)}`]).join(", ")}`;
|
|
51
|
-
}
|
|
156
|
+
metadataUrl: named.metadataUrl,
|
|
157
|
+
invalid: challengeOf(named, true),
|
|
158
|
+
anonymous: challengeOf(named, false),
|
|
159
|
+
published: options === void 0 ? void 0 : discovery(options)
|
|
52
160
|
};
|
|
53
161
|
};
|
|
162
|
+
/** Whether `security` is the Bearer scheme, the one OAuth challenges and discovery name. */
|
|
163
|
+
const isBearer = (security) => Predicate.isTagged(security, "Http") && security.scheme.toLowerCase() === "bearer";
|
|
164
|
+
/** What an OpenAPI component key may hold, and so a descriptor's name, which is its key. */
|
|
165
|
+
const componentKey = /^[\w.-]+$/;
|
|
166
|
+
/**
|
|
167
|
+
* The challenge of a 401 under a scheme other than Bearer: the scheme an `Http` one names,
|
|
168
|
+
* Basic with its realm, the descriptor's name; an API key has no scheme to name.
|
|
169
|
+
*/
|
|
170
|
+
const schemeChallenge = (security, name) => Match.value(security).pipe(Match.tag("Http", ({ scheme }) => challenge(scheme, [])), Match.tag("Basic", () => challenge("Basic", [["realm", name]])), Match.tag("ApiKey", () => void 0), Match.exhaustive);
|
|
171
|
+
/**
|
|
172
|
+
* Browser-safe declaration shared by clients and remote surfaces: the `identity` it
|
|
173
|
+
* authenticates, by the one native scheme `security`, Bearer unless it names another, such
|
|
174
|
+
* as `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`. The literal name
|
|
175
|
+
* identifies the provider, like a native Context.Key name, and the scheme's OpenAPI key, so it
|
|
176
|
+
* holds only letters, digits, `_`, `.` and `-`; reuse one name only for one declaration.
|
|
177
|
+
*/
|
|
178
|
+
const make = (name, identity, ...options) => {
|
|
179
|
+
const security = options[0]?.security ?? HttpApiSecurity.bearer;
|
|
180
|
+
const error = errorList(options[0]?.error);
|
|
181
|
+
if (!componentKey.test(name)) throw new Error(`Invalid authentication name: ${JSON.stringify(name)}, not an OpenAPI key`);
|
|
182
|
+
assertOwnTags(`Authentication "${name}"`, error);
|
|
183
|
+
if (!Predicate.isTagged(security, "Http") && !Predicate.isTagged(security, "ApiKey") && !Predicate.isTagged(security, "Basic")) throw new Error("Authentication takes one native HttpApiSecurity scheme");
|
|
184
|
+
return {
|
|
185
|
+
name,
|
|
186
|
+
identity,
|
|
187
|
+
security,
|
|
188
|
+
error,
|
|
189
|
+
"~provider": Context.Service(`effect-actions/Authentication/Provider/${name}`),
|
|
190
|
+
"~middleware": HttpApiMiddleware.Service()(`effect-actions/Authentication/Security/${name}`, { security: { [name]: security } }),
|
|
191
|
+
"~verified": Context.Service(`effect-actions/Authentication/Verified/${name}`)
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
/** Whether `credential`, as a native scheme decodes it, holds anything: an absent one is empty. */
|
|
195
|
+
const presented = (credential) => Redacted.isRedacted(credential) ? Redacted.value(credential) !== "" : credential.username !== "" || Redacted.value(credential.password) !== "";
|
|
196
|
+
function layer(authentication, verify, options = {}) {
|
|
197
|
+
const { security } = authentication;
|
|
198
|
+
const oauth = isBearer(security);
|
|
199
|
+
const protectedResource = options.protectedResource;
|
|
200
|
+
assertResource(oauth, protectedResource);
|
|
201
|
+
const resource = Effect.isEffect(protectedResource) ? Effect.map(protectedResource, (built) => {
|
|
202
|
+
assertResource(oauth, built);
|
|
203
|
+
return answersOf(built);
|
|
204
|
+
}) : Effect.succeed(answersOf(protectedResource));
|
|
205
|
+
const missing = new Unauthenticated({ message: oauth ? "A bearer token is required." : "A credential is required." });
|
|
206
|
+
return Layer.effect(authentication["~provider"], Effect.gen(function* () {
|
|
207
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
208
|
+
const { metadataUrl, invalid, anonymous, published } = yield* resource;
|
|
209
|
+
if (published !== void 0) yield* router.addGlobalMiddleware(published);
|
|
210
|
+
const verifier = Effect.isEffect(verify) ? yield* verify : verify;
|
|
211
|
+
if (!Predicate.isFunction(verifier)) return yield* Effect.die(/* @__PURE__ */ new Error("Missing verify: pass a verify function, or an Effect building one"));
|
|
212
|
+
const authenticate = (credential) => presented(credential) ? verifier(credential) : Effect.fail(missing);
|
|
213
|
+
const failed = HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")));
|
|
214
|
+
const challengeOf = (credential) => !oauth ? schemeChallenge(security, authentication.name) : presented(credential) ? invalid : anonymous;
|
|
215
|
+
const refuse = (error) => isRefusal(error) ? Effect.succeed(oauth ? answer(error, metadataUrl) : plain(error)) : Effect.suspend(() => {
|
|
216
|
+
const response = declaredResponse(authentication.error, error);
|
|
217
|
+
return response === void 0 ? Effect.die(error) : Effect.succeed(response);
|
|
218
|
+
});
|
|
219
|
+
const verified = (credential, route, slot) => {
|
|
220
|
+
const challenge = challengeOf(credential);
|
|
221
|
+
return Effect.matchEffect(authenticate(credential), {
|
|
222
|
+
onFailure: refuse,
|
|
223
|
+
onSuccess: (actor) => Effect.provideService(route, slot, actor)
|
|
224
|
+
}).pipe(Effect.onError(() => failed), HttpEffect.withPreResponseHandler((_request, response) => Effect.succeed(settle(response, challenge))));
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
descriptor: authentication,
|
|
228
|
+
middleware: { [authentication.name]: (route, { credential }) => verified(credential, route, authentication.identity) },
|
|
229
|
+
stepUp: (route) => oauth ? Effect.catchIf(route, isStepUp, (error) => Effect.succeed(answer(error, metadataUrl))) : route,
|
|
230
|
+
http: ((route, optional) => Effect.flatMap(HttpApiBuilder.securityDecode(security), (credential) => optional && !presented(credential) ? route : verified(credential, oauth ? answerStepUp(route, metadataUrl) : route, authentication["~verified"]))),
|
|
231
|
+
route: ((route) => Effect.flatMap(HttpApiBuilder.securityDecode(security), (credential) => verified(credential, Effect.catchIf(route, isRefusal, refuse), authentication.identity)))
|
|
232
|
+
};
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Native router middleware authenticating a route of the host's own, such as an export, a
|
|
237
|
+
* page frame or a WebSocket upgrade, with `authentication`'s provider, as its actions' routes
|
|
238
|
+
* are: the credential its scheme decodes, verified by the same verifier, and the identity
|
|
239
|
+
* provided to the route. It answers as an action route: a missing or invalid credential with
|
|
240
|
+
* the refusal, every response to the request it authenticates `no-store` unless it states its
|
|
241
|
+
* own caching, and a refusal the route fails with, such as a scope check's `Forbidden`, as its
|
|
242
|
+
* status, JSON and challenge, stepping up under Bearer. The layer requires the provider,
|
|
243
|
+
* `layer(authentication, ...)`. An erased descriptor, `Any`, provides no service the types can
|
|
244
|
+
* name: its identity is `unknown`, which would discharge every request service the route owes.
|
|
245
|
+
*/
|
|
246
|
+
const protect = (authentication) => HttpRouter.middleware()(Effect.map(Effect.service(authentication["~provider"]), (runtime) => (route) => runtime.route(route)));
|
|
54
247
|
//#endregion
|
|
55
|
-
export {
|
|
248
|
+
export { bearerTokenOf, layer, make, protect, refusalResponse };
|
package/dist/Testing.d.mts
CHANGED
|
@@ -1,68 +1,93 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { BuiltIn } from "./internal/errors.mjs";
|
|
2
|
+
import { Call } from "./internal/call.mjs";
|
|
3
|
+
import { Action_d_exports, Any } from "./Action.mjs";
|
|
4
|
+
import { Params } from "./internal/mcp.mjs";
|
|
5
|
+
import { Served } from "./internal/memory.mjs";
|
|
6
|
+
import { Effect, Layer, Schema } from "effect";
|
|
7
|
+
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpRouter } from "effect/http";
|
|
4
8
|
//#region src/Testing.d.ts
|
|
5
|
-
/** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
|
|
6
|
-
export type Handler = (request: Request) => Promise<Response>;
|
|
7
9
|
/**
|
|
8
|
-
* The native
|
|
9
|
-
*
|
|
10
|
+
* The native `HttpClient`, answered by `handler` instead of the network: a web handler the
|
|
11
|
+
* test serves, which other tests may share, such as
|
|
12
|
+
* `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))).handler`,
|
|
13
|
+
* the routes' platform services provided, as `layer(routes)` provides them itself. It is the
|
|
14
|
+
* client `layer(routes)` gives, without building anything: the test owns the handler, and
|
|
15
|
+
* disposes of it.
|
|
10
16
|
*/
|
|
11
|
-
export declare
|
|
12
|
-
readonly topLevel: false;
|
|
13
|
-
}> as HttpApiGroup.Identifier<Group>]: import("effect/http-api/HttpApiClient").Client.GroupByEndpoint<Group, never, never>; } & import("effect/http-api/HttpApiClient").Client.TopLevelMethods<Groups, never, never>>, never, Exclude<Exclude<import("effect/http-api/HttpApiMiddleware").MiddlewareClient<import("effect/http-api/HttpApiEndpoint").Middleware<HttpApiGroup.Endpoints<Groups>>>, import("effect/http/HttpClient").HttpClient>, never>>;
|
|
14
|
-
/** A stateless 2026-07-28 request. */
|
|
15
|
-
export interface McpRequestOptions {
|
|
16
|
-
readonly url: string | URL;
|
|
17
|
-
readonly method: string;
|
|
18
|
-
readonly params?: McpRequestParams;
|
|
19
|
-
readonly headers?: ConstructorParameters<typeof Headers>[0];
|
|
20
|
-
}
|
|
17
|
+
export declare function layer(handler: (request: Request) => Promise<Response>): Layer.Layer<HttpClient.HttpClient>;
|
|
21
18
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* The native `HttpClient`, answered in memory by `routes` instead of the network: provide
|
|
20
|
+
* it to `ActionHttp.client` and to `mcpClient`. The routes are built with this layer and
|
|
21
|
+
* released with its scope, without request logs. What they still require is this layer's, as
|
|
22
|
+
* under `HttpRouter.serve`: a builder's services, and a per-request service no middleware of
|
|
23
|
+
* theirs provides, including one a global middleware reads. The routes keep their real
|
|
24
|
+
* authentication: a client sends its caller's credential. Provided around it, the test
|
|
25
|
+
* program shares them. It never requires
|
|
26
|
+
* the platform services, `FileSystem`, `Path`, `HttpPlatform` and `Etag.Generator`: one
|
|
27
|
+
* provided around it is the routes' too, and `HttpServer.layerServices`' defaults stand in for
|
|
28
|
+
* the rest, whose `FileSystem` is a no-op. A relative URL resolves against `http://localhost`.
|
|
29
|
+
* The client is the layer's own: the program's other HTTP clients get none of its requests,
|
|
30
|
+
* and it none of theirs.
|
|
24
31
|
*/
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
export declare function layer<A, E, R>(routes: Layer.Layer<A, E, R>): Layer.Layer<HttpClient.HttpClient, E, Exclude<HttpRouter.Request.Without<R> | HttpRouter.Request.Only<"Requires", R> | HttpRouter.Request.Only<"GlobalRequires", R>, Served>>;
|
|
33
|
+
/** Where `mcpClient` sends, and through what client. */
|
|
34
|
+
export interface McpClientOptions {
|
|
35
|
+
/**
|
|
36
|
+
* The endpoint, resolved by the `HttpClient`: relative under `layer`. Defaults to `/mcp`,
|
|
37
|
+
* the default `ActionMcp.layerHttp` path.
|
|
38
|
+
*/
|
|
39
|
+
readonly url?: string;
|
|
40
|
+
/** Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token, say. */
|
|
41
|
+
readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
|
|
34
42
|
}
|
|
35
|
-
/**
|
|
36
|
-
export
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
readonly
|
|
44
|
-
readonly [key: string]: McpRequestValue;
|
|
45
|
-
};
|
|
46
|
-
readonly headers?: ConstructorParameters<typeof Headers>[0];
|
|
43
|
+
/** Where an `mcpRequest` goes, and with what headers. */
|
|
44
|
+
export interface McpRequestOptions {
|
|
45
|
+
/**
|
|
46
|
+
* The endpoint, resolved by the `HttpClient` that sends the request: relative under `layer`.
|
|
47
|
+
* Defaults to `/mcp`, as `mcpClient`'s does; converting to a web `Request` needs an absolute
|
|
48
|
+
* one.
|
|
49
|
+
*/
|
|
50
|
+
readonly url?: string;
|
|
51
|
+
readonly headers?: Headers.Input;
|
|
47
52
|
}
|
|
53
|
+
declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<"McpCallError", {
|
|
54
|
+
readonly message: Schema.String;
|
|
55
|
+
}>, import("effect/Cause").YieldableError>;
|
|
56
|
+
/**
|
|
57
|
+
* An answer a client method cannot decode as the action's success or a declared error,
|
|
58
|
+
* such as the native server's message for invalid arguments or an unknown tool. Its message
|
|
59
|
+
* holds the answer.
|
|
60
|
+
*/
|
|
61
|
+
export declare class McpCallError extends McpCallError_base {}
|
|
62
|
+
/**
|
|
63
|
+
* What one call of `A` fails with: a declared error value (the action's own or a built-in one
|
|
64
|
+
* from the tool, or a refusal from the endpoint's authentication), a `SchemaError` when the
|
|
65
|
+
* input does not encode or the success does not decode, an `HttpClientError` when the
|
|
66
|
+
* endpoint could not be reached, or an `McpCallError` for any other answer.
|
|
67
|
+
*/
|
|
68
|
+
type CallError<A extends Any> = A["error"][number]["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
|
|
69
|
+
/** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
|
|
70
|
+
export type McpClient<Actions extends ReadonlyArray<Any>> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A>>>; };
|
|
48
71
|
/**
|
|
49
|
-
* A
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
72
|
+
* A client of an MCP endpoint served by `ActionMcp.layerHttp`, one method per action calling
|
|
73
|
+
* its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
|
|
74
|
+
* encoded with the action's schema, and the success decoded from its structured content. A
|
|
75
|
+
* declared error the tool returns, the action's own or a refusal, is its decoded value, and so
|
|
76
|
+
* is a refusal the endpoint's authentication answers with. The argument may be omitted when
|
|
77
|
+
* `{}` is a valid input. Requires the native `HttpClient`, such as the one `layer` provides.
|
|
53
78
|
*/
|
|
54
|
-
export
|
|
55
|
-
readonly isError: false;
|
|
56
|
-
readonly value: Schema.Json;
|
|
57
|
-
readonly text?: string;
|
|
58
|
-
} | {
|
|
59
|
-
readonly isError: true;
|
|
60
|
-
readonly error: Schema.Json;
|
|
61
|
-
};
|
|
79
|
+
export declare function mcpClient<const Actions extends ReadonlyArray<Any>>(actions: Actions, options?: McpClientOptions): Effect.Effect<McpClient<Actions>, never, HttpClient.HttpClient>;
|
|
62
80
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
81
|
+
* One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it,
|
|
82
|
+
* as the native request value: the JSON-RPC envelope, the 2026-07-28 headers, `mcp-name` from
|
|
83
|
+
* `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
|
|
84
|
+
* `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
|
|
85
|
+
* version is always the request's own. The test sends it: `HttpClient.execute` answers the
|
|
86
|
+
* response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
|
|
87
|
+
* gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
|
|
88
|
+
* asserting on what `mcpClient` decodes away, such as `tools/list`, a refusal's challenge, or
|
|
89
|
+
* a call its types would not send.
|
|
66
90
|
*/
|
|
67
|
-
export declare const
|
|
68
|
-
//#endregion
|
|
91
|
+
export declare const mcpRequest: (method: string, params?: Params, { headers, url }?: McpRequestOptions) => HttpClientRequest.HttpClientRequest;
|
|
92
|
+
//#endregion
|
|
93
|
+
export type { Params as McpParams };
|
package/dist/Testing.mjs
CHANGED
|
@@ -1,91 +1,101 @@
|
|
|
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, 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 { 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
|
-
* authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
|
|
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.
|
|
58
38
|
*/
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
/** Fail with the value of one of `errors` that `text` holds, or else with `otherwise`. */
|
|
48
|
+
const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
|
|
49
|
+
/** The tool call of `action` with `input` on `client`, sending to `url`. */
|
|
50
|
+
const callTool = (client, url, action, input) => {
|
|
51
|
+
const { name } = action;
|
|
52
|
+
const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
|
|
53
|
+
return Effect.gen(function* () {
|
|
54
|
+
const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
|
|
55
|
+
const response = yield* client.execute(mcpRequest("tools/call", {
|
|
64
56
|
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
|
-
|
|
57
|
+
arguments: encoded
|
|
58
|
+
}, { url }));
|
|
59
|
+
const text = yield* response.text;
|
|
60
|
+
if (response.status !== 200) return yield* failWith(Refusal.members, text, other(`answered ${response.status}: ${text}`));
|
|
61
|
+
const reply = replyOf(response, text);
|
|
62
|
+
if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
|
|
63
|
+
if ("error" in reply.value) {
|
|
64
|
+
const { code, message } = reply.value.error;
|
|
65
|
+
return yield* Effect.fail(other(`failed with ${code}: ${message}`));
|
|
66
|
+
}
|
|
67
|
+
const { result } = reply.value;
|
|
68
|
+
if (result.isError === true) {
|
|
69
|
+
const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
|
|
70
|
+
return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
|
|
71
|
+
}
|
|
72
|
+
if (result.structuredContent === void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
|
|
73
|
+
return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(result.structuredContent);
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
function mcpClient(actions, { url = defaultPath, transformClient = identity } = {}) {
|
|
77
|
+
assertOnce("action", actions);
|
|
78
|
+
return Effect.map(HttpClient.HttpClient, (native) => {
|
|
79
|
+
const client = transformClient(native);
|
|
80
|
+
return Object.fromEntries(actions.map((action) => {
|
|
81
|
+
return [action.name, (...args) => Effect.flatMap(inputOf(action, args), (input) => callTool(client, url, action, input))];
|
|
82
|
+
}));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it,
|
|
87
|
+
* as the native request value: the JSON-RPC envelope, the 2026-07-28 headers, `mcp-name` from
|
|
88
|
+
* `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
|
|
89
|
+
* `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
|
|
90
|
+
* version is always the request's own. The test sends it: `HttpClient.execute` answers the
|
|
91
|
+
* response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
|
|
92
|
+
* gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
|
|
93
|
+
* asserting on what `mcpClient` decodes away, such as `tools/list`, a refusal's challenge, or
|
|
94
|
+
* a call its types would not send.
|
|
95
|
+
*/
|
|
96
|
+
const mcpRequest = (method, params = {}, { headers = {}, url = defaultPath } = {}) => {
|
|
97
|
+
const { headers: routing, body } = statelessRequest(method, params);
|
|
98
|
+
return HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.setHeaders(routing), HttpClientRequest.bodyJsonUnsafe(body));
|
|
89
99
|
};
|
|
90
100
|
//#endregion
|
|
91
|
-
export {
|
|
101
|
+
export { McpCallError, layer, mcpClient, mcpRequest };
|