@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.
Files changed (68) hide show
  1. package/README.md +57 -50
  2. package/dist/Action.d.mts +285 -79
  3. package/dist/Action.mjs +124 -23
  4. package/dist/ActionCli.d.mts +102 -31
  5. package/dist/ActionCli.mjs +114 -16
  6. package/dist/ActionHttp.d.mts +170 -51
  7. package/dist/ActionHttp.mjs +130 -79
  8. package/dist/ActionMcp.d.mts +71 -21
  9. package/dist/ActionMcp.mjs +108 -81
  10. package/dist/ActionToolkit.d.mts +56 -27
  11. package/dist/ActionToolkit.mjs +16 -5
  12. package/dist/Authentication.d.mts +129 -33
  13. package/dist/Authentication.mjs +231 -38
  14. package/dist/Testing.d.mts +82 -57
  15. package/dist/Testing.mjs +89 -79
  16. package/dist/internal/actions.d.mts +5 -25
  17. package/dist/internal/actions.mjs +75 -27
  18. package/dist/internal/authentication.d.mts +102 -0
  19. package/dist/internal/authentication.mjs +11 -0
  20. package/dist/internal/call.d.mts +12 -0
  21. package/dist/internal/call.mjs +12 -0
  22. package/dist/internal/cli.d.mts +36 -22
  23. package/dist/internal/cli.mjs +311 -33
  24. package/dist/internal/client.d.mts +43 -0
  25. package/dist/internal/client.mjs +25 -0
  26. package/dist/internal/console.mjs +77 -0
  27. package/dist/internal/declared.d.mts +13 -0
  28. package/dist/internal/declared.mjs +34 -0
  29. package/dist/internal/errors.d.mts +77 -0
  30. package/dist/internal/errors.mjs +79 -0
  31. package/dist/internal/implementation.d.mts +171 -41
  32. package/dist/internal/implementation.mjs +147 -29
  33. package/dist/internal/mcp.d.mts +11 -0
  34. package/dist/internal/mcp.mjs +52 -0
  35. package/dist/internal/memory.d.mts +6 -0
  36. package/dist/internal/memory.mjs +38 -0
  37. package/dist/internal/refusal.mjs +67 -0
  38. package/dist/internal/schema-errors.mjs +20 -0
  39. package/dist/internal/tools.mjs +27 -57
  40. package/docs/Action.md +401 -73
  41. package/docs/ActionCli.md +280 -51
  42. package/docs/ActionHttp.md +266 -82
  43. package/docs/ActionMcp.md +149 -104
  44. package/docs/ActionToolkit.md +97 -35
  45. package/docs/Authentication.md +369 -62
  46. package/docs/CONTEXT.md +23 -36
  47. package/docs/README.md +29 -73
  48. package/docs/Testing.md +154 -77
  49. package/docs/guarantees.md +81 -43
  50. package/docs/setup.md +90 -27
  51. package/package.json +7 -15
  52. package/dist/ActionCatalog.d.mts +0 -30
  53. package/dist/ActionCatalog.mjs +0 -39
  54. package/dist/ActionCliClient.d.mts +0 -48
  55. package/dist/ActionCliClient.mjs +0 -43
  56. package/dist/ActionGroup.d.mts +0 -44
  57. package/dist/ActionGroup.mjs +0 -35
  58. package/dist/ActionHttpClient.d.mts +0 -33
  59. package/dist/ActionHttpClient.mjs +0 -16
  60. package/dist/TestingClient.d.mts +0 -17
  61. package/dist/TestingClient.mjs +0 -22
  62. package/dist/internal/fetchClient.d.mts +0 -6
  63. package/dist/internal/fetchClient.mjs +0 -8
  64. package/dist/internal/tools.d.mts +0 -22
  65. package/docs/ActionCatalog.md +0 -54
  66. package/docs/ActionCliClient.md +0 -63
  67. package/docs/ActionGroup.md +0 -128
  68. package/docs/ActionHttpClient.md +0 -80
@@ -1,55 +1,248 @@
1
- import { Effect } from "effect";
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
- * Authenticate each request and provide its identity to the downstream handler.
6
- * `authenticate` fails with the response to send instead, so the host owns its
7
- * status, body and challenge headers. Dependencies remain native router request
8
- * requirements. Acquired resources live until the request scope closes, including
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 middleware = (service, authenticate) => HttpRouter.middleware()((httpEffect) => authenticate.pipe(Effect.matchEffect({
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
- * Publish RFC 9728 discovery independently from the authenticated routes.
19
- * This supplies metadata and challenges; the host still verifies access tokens.
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 protectedResource = (options) => {
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 path = `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`;
24
- const discoveryUrl = new URL(resource);
25
- discoveryUrl.pathname = path;
26
- const metadataUrl = discoveryUrl.href;
27
- const target = metadataUrl.slice(discoveryUrl.origin.length);
28
- const response = HttpServerResponse.jsonUnsafe({
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
- layer: HttpRouter.middleware((next) => Effect.gen(function* () {
37
- const request = yield* HttpServerRequest.HttpServerRequest;
38
- const url = new URL(request.url, resource.origin);
39
- if ((request.method === "GET" || request.method === "HEAD") && url.href.slice(url.origin.length) === target) return response;
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 { middleware, protectedResource };
248
+ export { bearerTokenOf, layer, make, protect, refusalResponse };
@@ -1,68 +1,93 @@
1
- import { ClientOptions } from "./internal/fetchClient.mjs";
2
- import { Schema } from "effect";
3
- import { HttpApi, HttpApiGroup } from "effect/http-api";
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 grouped HTTP client, calling `handler` in memory instead of the network.
9
- * `baseUrl` defaults to `http://localhost`.
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 const httpClient: <Id extends string, Groups extends HttpApiGroup.Constraint>(api: HttpApi.HttpApi<Id, Groups>, handler: Handler, options?: ClientOptions) => import("effect/Effect").Effect<import("effect/Types").Simplify<{ readonly [Group in Extract<Groups, {
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
- * What `JSON.stringify` accepts, not only valid JSON: `undefined` fields are
23
- * dropped, and tests send malformed arguments on purpose.
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 type McpRequestValue = string | number | boolean | null | undefined | ReadonlyArray<McpRequestValue> | {
26
- readonly [key: string]: McpRequestValue;
27
- };
28
- /** JSON-RPC `params`; `_meta` is merged shallowly over the defaults `mcpRequest` supplies. */
29
- export interface McpRequestParams {
30
- readonly _meta?: {
31
- readonly [key: string]: McpRequestValue;
32
- };
33
- readonly [key: string]: McpRequestValue;
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
- /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
36
- export declare const mcpRequest: ({ url, method, params, headers: init }: McpRequestOptions) => Request;
37
- /** One `tools/call` for `mcpCall`: the endpoint, the tool and its arguments. */
38
- export interface McpCallOptions {
39
- readonly url: string | URL;
40
- /** The tool name, `mcp.name` of its action. */
41
- readonly name: string;
42
- /** Defaults to `{}`. Like `params`, it may be malformed on purpose. */
43
- readonly arguments?: {
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 tool call's outcome: the success from `structuredContent`, with the text an action's
50
- * `mcp.text` field sent before it; or the error text of an `isError` result, parsed as
51
- * JSON when it is JSON (a declared error, whatever its shape) and kept as the text
52
- * otherwise (the native server's own message, such as for invalid arguments).
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 type McpCallResult = {
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
- * Call one tool through `handler` with a stateless 2026-07-28 request and return its
64
- * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
65
- * authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
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 mcpCall: (handler: Handler, { url, name, headers, arguments: args }: McpCallOptions) => Promise<McpCallResult>;
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 { fetchApiClient } from "./internal/fetchClient.mjs";
2
- import { Option, Predicate, Schema } from "effect";
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
- * The native grouped HTTP client, calling `handler` in memory instead of the network.
6
- * `baseUrl` defaults to `http://localhost`.
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
- const httpClient = (api, handler, options) => fetchApiClient(api, (input, init) => handler(new Request(input, init)), {
9
- baseUrl: "http://localhost",
10
- ...options
11
- });
12
- /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
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
- * Call one tool through `handler` with a stateless 2026-07-28 request and return its
56
- * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
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 mcpCall = async (handler, { url, name, headers, arguments: args = {} }) => {
60
- const response = await handler(mcpRequest({
61
- url,
62
- method: "tools/call",
63
- params: {
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: args
66
- },
67
- ...headers === void 0 ? {} : { headers }
68
- }));
69
- const body = await response.text();
70
- if (response.status !== 200) throw new Error(`MCP tools/call "${name}" answered ${response.status}: ${body}`);
71
- const reply = body.split("\n").map((line) => line.replace(/^data:/, "").trim()).flatMap((line) => Option.toArray(decodeReply(line))).at(-1);
72
- if (reply === void 0) throw new Error(`MCP tools/call "${name}" had no reply: ${body}`);
73
- if ("error" in reply) throw new Error(`MCP tools/call "${name}" failed with ${reply.error.code}: ${reply.error.message}`);
74
- const { result } = reply;
75
- if (result.isError === true) {
76
- const text = result.content.find((content) => content.type === "text")?.text ?? "";
77
- return {
78
- isError: true,
79
- error: Option.getOrElse(parseJson(text), () => text)
80
- };
81
- }
82
- if (result.structuredContent === void 0) throw new Error(`MCP tools/call "${name}" returned no structured content: ${body}`);
83
- const [text] = result.content.length > 1 ? result.content : [];
84
- return {
85
- isError: false,
86
- value: result.structuredContent,
87
- ...text?.text === void 0 ? {} : { text: text.text }
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 { httpClient, mcpCall, mcpRequest };
101
+ export { McpCallError, layer, mcpClient, mcpRequest };