@gjermundgaraba/effect-actions 0.5.0 → 0.6.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 +7 -6
- package/dist/ActionCatalog.mjs +2 -2
- package/dist/ActionCli.mjs +5 -1
- package/dist/ActionGroup.d.mts +14 -10
- package/dist/ActionGroup.mjs +12 -1
- package/dist/ActionHttp.d.mts +6 -0
- package/dist/ActionHttp.mjs +13 -7
- package/dist/ActionHttpClient.d.mts +45 -0
- package/dist/ActionHttpClient.mjs +24 -0
- package/dist/ActionMcp.d.mts +11 -22
- package/dist/ActionMcp.mjs +12 -8
- package/dist/Testing.d.mts +31 -1
- package/dist/Testing.mjs +50 -2
- package/dist/TestingClient.d.mts +6 -5
- package/dist/TestingClient.mjs +6 -3
- package/dist/internal/actions.d.mts +18 -7
- package/dist/internal/actions.mjs +11 -1
- package/dist/internal/implementation.mjs +3 -2
- package/dist/internal/tools.mjs +1 -1
- package/docs/ActionGroup.md +24 -16
- package/docs/ActionHttp.md +7 -5
- package/docs/ActionHttpClient.md +73 -0
- package/docs/ActionMcp.md +18 -21
- package/docs/CONTEXT.md +3 -2
- package/docs/README.md +18 -18
- package/docs/Testing.md +27 -16
- package/docs/guarantees.md +4 -4
- package/docs/setup.md +7 -6
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -7,7 +7,6 @@ Effect's own servers and clients underneath.
|
|
|
7
7
|
## Looks like this
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import { McpProtocol } from "effect/unstable/ai";
|
|
11
10
|
import { Effect, Layer, Schema } from "effect";
|
|
12
11
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
13
12
|
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
@@ -32,7 +31,6 @@ const app = Actions.implement({
|
|
|
32
31
|
export const routes = Layer.mergeAll(
|
|
33
32
|
Http.layer([app]),
|
|
34
33
|
ActionMcp.layerHttp([app], {
|
|
35
|
-
protocols: [McpProtocol.v2026_07_28],
|
|
36
34
|
name: "greetings",
|
|
37
35
|
version: "1.0.0",
|
|
38
36
|
path: "/mcp",
|
|
@@ -60,6 +58,9 @@ const greeting = Effect.gen(function* () {
|
|
|
60
58
|
});
|
|
61
59
|
```
|
|
62
60
|
|
|
61
|
+
Code that does not run Effects, such as a browser page, gets the same calls as Promises:
|
|
62
|
+
`await ActionHttpClient.promise(Http, { baseUrl }).greetings.greet({ name: "Ada" })`.
|
|
63
|
+
|
|
63
64
|
## Why
|
|
64
65
|
|
|
65
66
|
- Input, success, and errors are declared once. Routes, tools, commands, clients, OpenAPI, and the catalog are derived from that declaration, so they cannot disagree.
|
|
@@ -72,10 +73,10 @@ const greeting = Effect.gen(function* () {
|
|
|
72
73
|
## Install
|
|
73
74
|
|
|
74
75
|
```sh
|
|
75
|
-
pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.
|
|
76
|
+
pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.117
|
|
76
77
|
```
|
|
77
78
|
|
|
78
|
-
Add `@effect/platform-node@4.0.0-rc.
|
|
79
|
+
Add `@effect/platform-node@4.0.0-rc.117` to serve from Node. Every module is a subpath import
|
|
79
80
|
(`.../Action`, `.../ActionHttp`, ...); there is no package root, so a contracts-only bundle
|
|
80
81
|
never loads a server.
|
|
81
82
|
|
|
@@ -100,8 +101,8 @@ in `node_modules/@gjermundgaraba/effect-actions/docs`) gives the same content.
|
|
|
100
101
|
|
|
101
102
|
## Status
|
|
102
103
|
|
|
103
|
-
The `effect` peer
|
|
104
|
-
|
|
104
|
+
The `effect` peer accepts any Effect 4.0 release candidate from `4.0.0-rc.116` on; the package
|
|
105
|
+
is built and tested against the release candidate in its `devDependencies` (see [docs/setup.md](docs/setup.md)). Actions are unary JSON over HTTP and MCP: no streaming, uploads, prompts, or resources.
|
|
105
106
|
Authentication and authorization belong to the application. See [docs/setup.md](docs/setup.md).
|
|
106
107
|
|
|
107
108
|
## Acknowledgements
|
package/dist/ActionCatalog.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertDistinct } from "./internal/actions.mjs";
|
|
1
|
+
import { assertDistinct, policyErrors } from "./internal/actions.mjs";
|
|
2
2
|
import { JsonSchema, Schema } from "effect";
|
|
3
3
|
//#region src/ActionCatalog.ts
|
|
4
4
|
/** Describe the JSON wire form; decoding on the server remains authoritative. */
|
|
@@ -29,7 +29,7 @@ const make = (...groups) => {
|
|
|
29
29
|
input: describe(action.input),
|
|
30
30
|
success: describe(action.success),
|
|
31
31
|
errors: action.errors.map(describe),
|
|
32
|
-
httpSchemaErrors: action.http ? group.schemaError
|
|
32
|
+
httpSchemaErrors: action.http && group.schemaError !== void 0 ? policyErrors(group.schemaError).map(describe) : []
|
|
33
33
|
})))
|
|
34
34
|
};
|
|
35
35
|
};
|
package/dist/ActionCli.mjs
CHANGED
|
@@ -4,6 +4,10 @@ import { command as command$1 } from "./internal/cli.mjs";
|
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { Command } from "effect/unstable/cli";
|
|
6
6
|
//#region src/ActionCli.ts
|
|
7
|
+
/**
|
|
8
|
+
* Acquire the implementation in a scope of its own, run one action through the hook
|
|
9
|
+
* and its handler, and release it: every local command, selected or grouped.
|
|
10
|
+
*/
|
|
7
11
|
const local = (app, action, before, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, before)(input)));
|
|
8
12
|
/**
|
|
9
13
|
* Project one explicitly selected local action into a native Effect CLI command.
|
|
@@ -15,7 +19,7 @@ const command = (app, name, options) => {
|
|
|
15
19
|
};
|
|
16
20
|
/** Project every local action below its group namespace with default CLI options. */
|
|
17
21
|
const group = (app, options) => {
|
|
18
|
-
const commands = app.group.actions.map((action) => command$1(action, (input) =>
|
|
22
|
+
const commands = app.group.actions.map((action) => command$1(action, (input) => local(app, action, options?.before, input)));
|
|
19
23
|
return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
|
|
20
24
|
};
|
|
21
25
|
//#endregion
|
package/dist/ActionGroup.d.mts
CHANGED
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
import { Action, Action_d_exports, Any as Any$1, Codec, Handler } from "./Action.mjs";
|
|
2
|
-
import { Actions, SchemaErrorPolicy } from "./internal/actions.mjs";
|
|
2
|
+
import { Actions, SchemaErrorAnswer, SchemaErrorPolicy } from "./internal/actions.mjs";
|
|
3
3
|
import { Implementation } from "./internal/implementation.mjs";
|
|
4
4
|
import { Effect, Scope, Types } from "effect";
|
|
5
5
|
declare namespace ActionGroup_d_exports {
|
|
6
|
-
export { Any, Contracts, Group, Implementation, Options, SchemaErrorPolicy, contracts, make };
|
|
6
|
+
export { Any, Contracts, Group, Implementation, Options, SchemaErrorAnswer, SchemaErrorPolicy, contracts, make };
|
|
7
7
|
}
|
|
8
8
|
type HandlersFrom<Actions extends ReadonlyArray<Any$1>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
|
|
9
9
|
/** Group-level errors join each action's own, so a shared set is declared once. */
|
|
10
10
|
type WithErrors<Actions extends ReadonlyArray<Any$1>, Errors extends ReadonlyArray<Codec>> = { readonly [K in keyof Actions]: Actions[K] extends Action<infer Name, infer Input, infer Output, infer Own, infer Access, infer Http, infer Mcp> ? Action<Name, Input, Output, readonly [...Own, ...Errors], Access, Http, Mcp> : never; };
|
|
11
11
|
/** What `make` needs to define a group. */
|
|
12
|
-
export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>,
|
|
12
|
+
export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>, Invalid extends Codec = never, Internal extends Codec = never> {
|
|
13
13
|
/** The `HttpApiGroup` identifier, and so the OpenAPI tag and operation-ID prefix. */
|
|
14
14
|
readonly name: Name;
|
|
15
15
|
/** Failures every action of the group may have, added to each action's own. */
|
|
16
16
|
readonly errors?: Errors;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* How HTTP answers a request that fails decoding (`invalid`) and a result that fails
|
|
19
|
+
* encoding (`internal`); without one, both are Effect's empty 400. MCP keeps its
|
|
20
|
+
* native answers.
|
|
21
|
+
*/
|
|
22
|
+
readonly schemaError?: SchemaErrorPolicy<Invalid, Internal>;
|
|
19
23
|
}
|
|
20
24
|
/** A named set of action contracts: what adapters serve and what `implement` binds. */
|
|
21
|
-
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>,
|
|
25
|
+
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyError extends Codec = Codec> extends Actions<Name, Actions$1, PolicyError> {
|
|
22
26
|
/** Bind every handler at once, resolving build-time services once per adapter layer. */
|
|
23
|
-
readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1,
|
|
27
|
+
readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1, PolicyError>, H, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope>>>;
|
|
24
28
|
}
|
|
25
29
|
/** Any group, with its actions and error schemas erased. */
|
|
26
|
-
export type Any = Group<string, ReadonlyArray<Any$1>,
|
|
30
|
+
export type Any = Group<string, ReadonlyArray<Any$1>, Codec>;
|
|
27
31
|
/** Define a group. Invalid or duplicate action and MCP names fail here, at definition time. */
|
|
28
|
-
export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [],
|
|
32
|
+
export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], Invalid extends Codec = never, Internal extends Codec = never>(options: Options<Name, Errors, Invalid, Internal>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, Invalid | Internal>;
|
|
29
33
|
/** `G extends unknown` distributes, so each group maps only its own actions. */
|
|
30
34
|
type PerGroup<G extends Actions> = G extends unknown ? { readonly [A in G["actions"][number] as `${G["name"]}.${A["name"]}`]: A; } : never;
|
|
31
35
|
/** Every action of `Groups`, keyed `<group>.<action>`: the per-group maps merged into one. */
|
|
@@ -37,4 +41,4 @@ export type Contracts<Groups extends ReadonlyArray<Actions>> = Types.UnionToInte
|
|
|
37
41
|
*/
|
|
38
42
|
export declare function contracts<const Groups extends ReadonlyArray<Actions>>(...groups: Groups): Contracts<Groups>;
|
|
39
43
|
//#endregion
|
|
40
|
-
export { ActionGroup_d_exports, type Implementation, type SchemaErrorPolicy };
|
|
44
|
+
export { ActionGroup_d_exports, type Implementation, type SchemaErrorAnswer, type SchemaErrorPolicy };
|
package/dist/ActionGroup.mjs
CHANGED
|
@@ -2,6 +2,17 @@ import { assertDistinct, assertName } from "./internal/actions.mjs";
|
|
|
2
2
|
import { Implementation } from "./internal/implementation.mjs";
|
|
3
3
|
import { Effect } from "effect";
|
|
4
4
|
//#region src/ActionGroup.ts
|
|
5
|
+
/**
|
|
6
|
+
* The types require every handler, but a record built in plain JavaScript or through a
|
|
7
|
+
* cast may still lack one, or hold `undefined` under its key. It fails before anything is served rather than on the
|
|
8
|
+
* first request for the missing action.
|
|
9
|
+
*/
|
|
10
|
+
const assertHandlers = (group, handlers) => {
|
|
11
|
+
const bound = new Map(Object.entries(handlers));
|
|
12
|
+
const missing = group.actions.filter((action) => bound.get(action.name) === void 0);
|
|
13
|
+
if (missing.length > 0) throw new Error(`Missing handlers for group "${group.name}": ${missing.map((action) => action.name).join(", ")}`);
|
|
14
|
+
return handlers;
|
|
15
|
+
};
|
|
5
16
|
function make(options, ...declared) {
|
|
6
17
|
const { name } = options;
|
|
7
18
|
assertName("action group name", name);
|
|
@@ -16,7 +27,7 @@ function make(options, ...declared) {
|
|
|
16
27
|
actions,
|
|
17
28
|
schemaError: options.schemaError,
|
|
18
29
|
implement: (build) => {
|
|
19
|
-
const built = Effect.isEffect(build) ? build : Effect.succeed(build);
|
|
30
|
+
const built = Effect.isEffect(build) ? Effect.map(build, (handlers) => assertHandlers(group, handlers)) : Effect.succeed(assertHandlers(group, build));
|
|
20
31
|
return Implementation.make(group, built);
|
|
21
32
|
}
|
|
22
33
|
};
|
package/dist/ActionHttp.d.mts
CHANGED
|
@@ -48,6 +48,12 @@ export interface Http<Groups extends ReadonlyArray<Actions>, Errors extends Read
|
|
|
48
48
|
* services are unions over precisely those apps and that hook.
|
|
49
49
|
*/
|
|
50
50
|
readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(apps: readonly [...Apps], options?: LayerOptions<Errors, RB>) => Layer.Layer<never, BuildError<Apps[number], "http">, BuildContext<Apps[number], "http"> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<Apps[number], "http"> | RB> | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path>;
|
|
51
|
+
/**
|
|
52
|
+
* Serve the OpenAPI document of `api` with `GET path`, by default
|
|
53
|
+
* `<apiPath>/openapi.json`. It is a plain route: middleware provided to this layer
|
|
54
|
+
* covers it, and none is applied otherwise.
|
|
55
|
+
*/
|
|
56
|
+
readonly openApi: (path?: HttpRouter.PathInput) => Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
|
51
57
|
}
|
|
52
58
|
/**
|
|
53
59
|
* Bind the contract-level configuration once. Every route is `POST
|
package/dist/ActionHttp.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { assertDistinct, projectedErrors } from "./internal/actions.mjs";
|
|
1
|
+
import { answerSchemaError, assertDistinct, policyErrors, projectedErrors } from "./internal/actions.mjs";
|
|
2
2
|
import { dispatch } from "./internal/implementation.mjs";
|
|
3
3
|
import { Effect, Layer } from "effect";
|
|
4
|
-
import "effect/unstable/http";
|
|
4
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
5
5
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
|
|
6
6
|
//#region src/ActionHttp.ts
|
|
7
7
|
const httpActions = (group) => group.actions.filter((action) => action.http);
|
|
@@ -12,10 +12,10 @@ const endpoint = (options, group, action) => HttpApiEndpoint.post(action.name, `
|
|
|
12
12
|
}).annotate(OpenApi.Description, action.description);
|
|
13
13
|
/** The native middleware declares the policy's errors on every endpoint it wraps. */
|
|
14
14
|
const schemaErrorMiddleware = (name, policy) => {
|
|
15
|
-
class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policy
|
|
15
|
+
class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policyErrors(policy) }) {}
|
|
16
16
|
return {
|
|
17
17
|
SchemaErrors,
|
|
18
|
-
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy
|
|
18
|
+
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(answerSchemaError(policy, failure)))
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
21
|
function bind(options, group) {
|
|
@@ -49,7 +49,10 @@ const erasedHandlers = (handlers) => {
|
|
|
49
49
|
};
|
|
50
50
|
/** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
|
|
51
51
|
const groupHandlers = (api, app, middleware, before) => {
|
|
52
|
-
const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) =>
|
|
52
|
+
const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => {
|
|
53
|
+
const run = dispatch(app.group, action, record, before);
|
|
54
|
+
return [action.name, (request) => run(request.payload)];
|
|
55
|
+
}));
|
|
53
56
|
return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(middleware));
|
|
54
57
|
};
|
|
55
58
|
function make(options, ...groups) {
|
|
@@ -72,10 +75,13 @@ function make(options, ...groups) {
|
|
|
72
75
|
const [first, ...rest] = served.map(({ app, middleware }) => groupHandlers(api, app, middleware, layerOptions.before));
|
|
73
76
|
return first === void 0 ? HttpApiBuilder.layer(api) : HttpApiBuilder.layer(api).pipe(Layer.provide([first, ...rest]));
|
|
74
77
|
};
|
|
78
|
+
const api = apiOf([...bound.values()].map(({ native }) => native));
|
|
79
|
+
const openApi = (path = `${options.apiPath}/openapi.json`) => HttpRouter.add("GET", path, HttpServerResponse.jsonUnsafe(OpenApi.fromApi(api)));
|
|
75
80
|
return {
|
|
76
81
|
groups,
|
|
77
|
-
api
|
|
78
|
-
layer
|
|
82
|
+
api,
|
|
83
|
+
layer,
|
|
84
|
+
openApi
|
|
79
85
|
};
|
|
80
86
|
}
|
|
81
87
|
//#endregion
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Action_d_exports, Any, Codec } from "./Action.mjs";
|
|
2
|
+
import { Actions } from "./internal/actions.mjs";
|
|
3
|
+
import { ServedNames } from "./internal/implementation.mjs";
|
|
4
|
+
import { ActionHttp_d_exports, Http } from "./ActionHttp.mjs";
|
|
5
|
+
//#region src/ActionHttpClient.d.ts
|
|
6
|
+
/** Where and how a Promise client sends its requests. */
|
|
7
|
+
export interface Options {
|
|
8
|
+
/**
|
|
9
|
+
* What the binding's routes are resolved against, such as `https://api.example.com`.
|
|
10
|
+
* Omitted, routes stay relative, which a browser resolves against the page's origin.
|
|
11
|
+
*/
|
|
12
|
+
readonly baseUrl?: string | URL;
|
|
13
|
+
/** Sent as `Authorization: Bearer <token>` with every call. */
|
|
14
|
+
readonly token?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The transport. Defaults to the global `fetch`, looked up per call. Wrap it to add
|
|
17
|
+
* headers or to observe responses, such as a proxy's 401.
|
|
18
|
+
*/
|
|
19
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
20
|
+
}
|
|
21
|
+
/** The HTTP-served actions of `G`: an action with a literal `http: false` has no method. */
|
|
22
|
+
type HttpAction<G extends Actions> = Extract<G["actions"][number], {
|
|
23
|
+
readonly name: ServedNames<G, "http">;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* One action as a Promise of its decoded success. An action whose input may be empty,
|
|
27
|
+
* such as one declared without `input`, may be called without an argument.
|
|
28
|
+
*/
|
|
29
|
+
export type Method<A extends Any> = {} extends A["input"]["Type"] ? (input?: A["input"]["Type"]) => Promise<A["success"]["Type"]> : (input: A["input"]["Type"]) => Promise<A["success"]["Type"]>;
|
|
30
|
+
/** Every HTTP-served action of the binding, as `client.<group>.<action>(input)`. */
|
|
31
|
+
export type Client<Groups extends ReadonlyArray<Actions>> = { readonly [G in Groups[number] as [HttpAction<G>] extends [never] ? never : G["name"]]: { readonly [A in HttpAction<G> as A["name"]]: Method<A>; }; };
|
|
32
|
+
/**
|
|
33
|
+
* A Promise client for an HTTP binding, for code that does not run Effects, such as a
|
|
34
|
+
* browser page. It is Effect's native `HttpApiClient` over `fetch`, built once, and
|
|
35
|
+
* each call runs one request: nothing is retried.
|
|
36
|
+
*
|
|
37
|
+
* A call resolves with the action's decoded success. It rejects with what the native
|
|
38
|
+
* client fails with: a declared error value (the action's, its group's schema-error
|
|
39
|
+
* policy's, or the binding's surface `errors`), a native `HttpClientError` when the
|
|
40
|
+
* server could not be reached or answered with a status or body the contract does not
|
|
41
|
+
* declare, or a `SchemaError` when the input does not encode or the success does not
|
|
42
|
+
* decode.
|
|
43
|
+
*/
|
|
44
|
+
export declare function promise<const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>>(http: Http<Groups, Errors>, options?: Options): Client<Groups>;
|
|
45
|
+
//#endregion
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
|
+
//#region src/ActionHttpClient.ts
|
|
5
|
+
function promise(http, { baseUrl, token, fetch } = {}) {
|
|
6
|
+
const send = fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
7
|
+
const native = Effect.runSync(HttpApiClient.make(http.api, {
|
|
8
|
+
baseUrl,
|
|
9
|
+
transformClient: token === void 0 ? void 0 : HttpClient.mapRequest(HttpClientRequest.bearerToken(token))
|
|
10
|
+
}).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, send)));
|
|
11
|
+
return Object.fromEntries(http.groups.flatMap((group) => {
|
|
12
|
+
const endpoints = native[group.name];
|
|
13
|
+
if (endpoints === void 0) return [];
|
|
14
|
+
const methods = group.actions.flatMap((action) => {
|
|
15
|
+
const endpoint = endpoints[action.name];
|
|
16
|
+
if (endpoint === void 0) return [];
|
|
17
|
+
const method = (input) => Effect.runPromise(endpoint({ payload: input ?? {} }));
|
|
18
|
+
return [[action.name, method]];
|
|
19
|
+
});
|
|
20
|
+
return [[group.name, Object.fromEntries(methods)]];
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { promise };
|
package/dist/ActionMcp.d.mts
CHANGED
|
@@ -6,37 +6,26 @@ import { HttpRouter } from "effect/unstable/http";
|
|
|
6
6
|
import { McpSchema, McpServer } from "effect/unstable/ai";
|
|
7
7
|
import { Stdio } from "effect/Stdio";
|
|
8
8
|
//#region src/ActionMcp.d.ts
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** Browser origins accepted by the native server; passed through to `McpServer.layerHttp`. */
|
|
18
|
-
readonly allowedOrigins?: ReadonlyArray<string>;
|
|
19
|
-
readonly instructions?: string;
|
|
20
|
-
}
|
|
21
|
-
/** Server information for an MCP subprocess connected through standard I/O. */
|
|
22
|
-
export interface StdioOptions<Errors extends ReadonlyArray<Codec> = [], R = never> extends SurfaceOptions<Errors, R> {
|
|
23
|
-
readonly name: string;
|
|
24
|
-
readonly version: string;
|
|
25
|
-
/** Native protocol adapters to serve; negotiation is owned by Effect. */
|
|
26
|
-
readonly protocols: Parameters<typeof McpServer.layerStdio>[0]["protocols"];
|
|
27
|
-
readonly instructions?: string;
|
|
28
|
-
}
|
|
9
|
+
/**
|
|
10
|
+
* One Streamable HTTP MCP endpoint: every native `McpServer.layerHttp` option except
|
|
11
|
+
* `protocols` (server information, `path`, `allowedOrigins`, `instructions`,
|
|
12
|
+
* `extensions`, ...), plus the surface's `errors` and `before`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Options<Errors extends ReadonlyArray<Codec> = [], R = never> extends Omit<Parameters<typeof McpServer.layerHttp>[0], "protocols">, SurfaceOptions<Errors, R> {}
|
|
15
|
+
/** An MCP subprocess on standard I/O: every native `McpServer.layerStdio` option except `protocols`. */
|
|
16
|
+
export interface StdioOptions<Errors extends ReadonlyArray<Codec> = [], R = never> extends Omit<Parameters<typeof McpServer.layerStdio>[0], "protocols">, SurfaceOptions<Errors, R> {}
|
|
29
17
|
/** The native server supplies its own request context to every tool call. */
|
|
30
18
|
type ToolRequestContext<App, RB> = Exclude<RequestContext<App, "mcp"> | RB, McpSchema.McpRequestContext>;
|
|
31
19
|
/**
|
|
32
|
-
* Serve MCP tools over one Streamable HTTP endpoint.
|
|
20
|
+
* Serve MCP tools over one Streamable HTTP endpoint, speaking MCP 2026-07-28 only.
|
|
33
21
|
*
|
|
34
22
|
* Middleware provided around this layer has the normal HTTP lifetime. Native
|
|
35
23
|
* context capture applies: never provide request-identity tags at startup.
|
|
36
24
|
*/
|
|
37
25
|
export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(apps: readonly [...Apps], options: Options<Errors, RB>): Layer.Layer<never, BuildError<Apps[number], "mcp"> | Cause.IllegalArgumentError, BuildContext<Apps[number], "mcp"> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ToolRequestContext<Apps[number], RB>>>;
|
|
38
26
|
/**
|
|
39
|
-
* Serve MCP tools through newline-delimited JSON-RPC on standard I/O
|
|
27
|
+
* Serve MCP tools through newline-delimited JSON-RPC on standard I/O, speaking MCP
|
|
28
|
+
* 2026-07-28 only.
|
|
40
29
|
*
|
|
41
30
|
* The host supplies the `Stdio` service. Arguments are tool input only and
|
|
42
31
|
* never establish request identity or authority.
|
package/dist/ActionMcp.mjs
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import "./internal/implementation.mjs";
|
|
2
2
|
import { bindTools } from "./internal/tools.mjs";
|
|
3
3
|
import { Layer } from "effect";
|
|
4
|
-
import { McpServer } from "effect/unstable/ai";
|
|
4
|
+
import { McpProtocol, McpServer } from "effect/unstable/ai";
|
|
5
5
|
//#region src/ActionMcp.ts
|
|
6
|
+
/**
|
|
7
|
+
* The one protocol revision served. 2026-07-28 is stateless over HTTP: no
|
|
8
|
+
* initialize handshake and no session, so every request stands alone.
|
|
9
|
+
*/
|
|
10
|
+
const protocols = [McpProtocol.v2026_07_28];
|
|
6
11
|
const registration = (apps, options) => {
|
|
7
12
|
const binding = bindTools(apps, "mcp", options);
|
|
8
13
|
return Layer.effectDiscard(McpServer.registerToolkit(binding.toolkit)).pipe(Layer.provide(binding.layer));
|
|
@@ -10,16 +15,15 @@ const registration = (apps, options) => {
|
|
|
10
15
|
const server = (apps, options, transport) => registration(apps, options).pipe(Layer.provide(transport), Layer.fresh);
|
|
11
16
|
function layerHttp(apps, options) {
|
|
12
17
|
return server(apps, options, McpServer.layerHttp({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
instructions: options.instructions,
|
|
16
|
-
path: options.path,
|
|
17
|
-
protocols: options.protocols,
|
|
18
|
-
allowedOrigins: options.allowedOrigins
|
|
18
|
+
...options,
|
|
19
|
+
protocols
|
|
19
20
|
}));
|
|
20
21
|
}
|
|
21
22
|
function layerStdio(apps, options) {
|
|
22
|
-
return server(apps, options, McpServer.layerStdio(
|
|
23
|
+
return server(apps, options, McpServer.layerStdio({
|
|
24
|
+
...options,
|
|
25
|
+
protocols
|
|
26
|
+
}));
|
|
23
27
|
}
|
|
24
28
|
//#endregion
|
|
25
29
|
export { layerHttp, layerStdio };
|
package/dist/Testing.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
2
|
import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi";
|
|
3
3
|
//#region src/Testing.d.ts
|
|
4
4
|
/** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
|
|
@@ -33,4 +33,34 @@ export interface McpRequestParams {
|
|
|
33
33
|
}
|
|
34
34
|
/** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
|
|
35
35
|
export declare const mcpRequest: ({ url, method, params, headers: init }: McpRequestOptions) => Request;
|
|
36
|
+
/** One `tools/call` for `mcpCall`: the endpoint, the tool and its arguments. */
|
|
37
|
+
export interface McpCallOptions {
|
|
38
|
+
readonly url: string | URL;
|
|
39
|
+
/** The tool name, `mcp.name` of its action. */
|
|
40
|
+
readonly name: string;
|
|
41
|
+
/** Defaults to `{}`. Like `params`, it may be malformed on purpose. */
|
|
42
|
+
readonly arguments?: {
|
|
43
|
+
readonly [key: string]: McpRequestValue;
|
|
44
|
+
};
|
|
45
|
+
readonly headers?: ConstructorParameters<typeof Headers>[0];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A tool call's outcome with the library's wire envelope removed: the success from
|
|
49
|
+
* `structuredContent.value`, or the error text of an `isError` result, parsed as JSON
|
|
50
|
+
* when it is JSON (a declared error) and kept as the text otherwise (the native
|
|
51
|
+
* server's own message, such as for invalid arguments).
|
|
52
|
+
*/
|
|
53
|
+
export type McpCallResult = {
|
|
54
|
+
readonly isError: false;
|
|
55
|
+
readonly value: Schema.Json;
|
|
56
|
+
} | {
|
|
57
|
+
readonly isError: true;
|
|
58
|
+
readonly error: Schema.Json;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Call one tool through `handler` with a stateless 2026-07-28 request and return its
|
|
62
|
+
* outcome. A response that is not an HTTP 200 carrying a tool result, such as an
|
|
63
|
+
* authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
|
|
64
|
+
*/
|
|
65
|
+
export declare const mcpCall: (handler: Handler, { url, name, headers, ...options }: McpCallOptions) => Promise<McpCallResult>;
|
|
36
66
|
//#endregion
|
package/dist/Testing.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Layer, Predicate } from "effect";
|
|
1
|
+
import { Effect, Layer, Option, Predicate, Schema } from "effect";
|
|
2
2
|
import { FetchHttpClient } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
4
|
//#region src/Testing.ts
|
|
@@ -38,5 +38,53 @@ const mcpRequest = ({ url, method, params = {}, headers: init }) => {
|
|
|
38
38
|
})
|
|
39
39
|
});
|
|
40
40
|
};
|
|
41
|
+
/** The JSON-RPC response to a `tools/call`: a tool result or a protocol error. */
|
|
42
|
+
const ToolReply = Schema.Union([Schema.Struct({ result: Schema.Struct({
|
|
43
|
+
isError: Schema.optionalKey(Schema.Boolean),
|
|
44
|
+
structuredContent: Schema.optionalKey(Schema.Struct({ value: Schema.Json })),
|
|
45
|
+
content: Schema.Array(Schema.Struct({
|
|
46
|
+
type: Schema.String,
|
|
47
|
+
text: Schema.optionalKey(Schema.String)
|
|
48
|
+
}))
|
|
49
|
+
}) }), Schema.Struct({ error: Schema.Struct({
|
|
50
|
+
code: Schema.Finite,
|
|
51
|
+
message: Schema.String
|
|
52
|
+
}) })]);
|
|
53
|
+
const decodeReply = Schema.decodeUnknownOption(Schema.fromJsonString(ToolReply));
|
|
54
|
+
const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
|
|
55
|
+
/**
|
|
56
|
+
* Call one tool through `handler` with a stateless 2026-07-28 request and return its
|
|
57
|
+
* outcome. A response that is not an HTTP 200 carrying a tool result, such as an
|
|
58
|
+
* authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
|
|
59
|
+
*/
|
|
60
|
+
const mcpCall = async (handler, { url, name, headers, ...options }) => {
|
|
61
|
+
const response = await handler(mcpRequest({
|
|
62
|
+
url,
|
|
63
|
+
method: "tools/call",
|
|
64
|
+
params: {
|
|
65
|
+
name,
|
|
66
|
+
arguments: options.arguments ?? {}
|
|
67
|
+
},
|
|
68
|
+
...headers === void 0 ? {} : { headers }
|
|
69
|
+
}));
|
|
70
|
+
const body = await response.text();
|
|
71
|
+
if (response.status !== 200) throw new Error(`MCP tools/call "${name}" answered ${response.status}: ${body}`);
|
|
72
|
+
const reply = body.split("\n").map((line) => line.replace(/^data:/, "").trim()).flatMap((line) => Option.toArray(decodeReply(line))).at(-1);
|
|
73
|
+
if (reply === void 0) throw new Error(`MCP tools/call "${name}" had no reply: ${body}`);
|
|
74
|
+
if ("error" in reply) throw new Error(`MCP tools/call "${name}" failed with ${reply.error.code}: ${reply.error.message}`);
|
|
75
|
+
const { result } = reply;
|
|
76
|
+
if (result.isError === true) {
|
|
77
|
+
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
|
78
|
+
return {
|
|
79
|
+
isError: true,
|
|
80
|
+
error: Option.getOrElse(parseJson(text), () => text)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (result.structuredContent === void 0) throw new Error(`MCP tools/call "${name}" returned no structured content: ${body}`);
|
|
84
|
+
return {
|
|
85
|
+
isError: false,
|
|
86
|
+
value: result.structuredContent.value
|
|
87
|
+
};
|
|
88
|
+
};
|
|
41
89
|
//#endregion
|
|
42
|
-
export { httpClient, mcpRequest };
|
|
90
|
+
export { httpClient, mcpCall, mcpRequest };
|
package/dist/TestingClient.d.mts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { Client
|
|
1
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
2
2
|
//#region src/TestingClient.d.ts
|
|
3
3
|
/** How `withMcpClient` connects the official client. */
|
|
4
4
|
export interface McpClientOptions {
|
|
5
5
|
/** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
|
|
6
6
|
readonly fetch: (request: Request) => Promise<Response>;
|
|
7
7
|
readonly path: string;
|
|
8
|
-
/** Passed directly to the official client; omitted options use its native default. */
|
|
9
|
-
readonly versionNegotiation?: ClientOptions["versionNegotiation"];
|
|
10
8
|
/** Defaults to `http://localhost`. */
|
|
11
9
|
readonly baseUrl?: string | URL;
|
|
12
10
|
readonly headers?: ConstructorParameters<typeof Headers>[0];
|
|
13
11
|
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Connect an official client pinned to MCP 2026-07-28, the revision `ActionMcp`
|
|
14
|
+
* serves, and always close its transport after the callback.
|
|
15
|
+
*/
|
|
16
|
+
export declare const withMcpClient: <A>({ fetch, path, baseUrl, headers }: McpClientOptions, run: (client: Client) => Promise<A>) => Promise<A>;
|
|
16
17
|
//#endregion
|
package/dist/TestingClient.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
2
2
|
//#region src/TestingClient.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Connect an official client pinned to MCP 2026-07-28, the revision `ActionMcp`
|
|
5
|
+
* serves, and always close its transport after the callback.
|
|
6
|
+
*/
|
|
7
|
+
const withMcpClient = async ({ fetch, path, baseUrl = "http://localhost", headers }, run) => {
|
|
5
8
|
const client = new Client({
|
|
6
9
|
name: "test",
|
|
7
10
|
version: "0"
|
|
8
|
-
}, versionNegotiation
|
|
11
|
+
}, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
|
9
12
|
try {
|
|
10
13
|
const transportOptions = { fetch: (input, init) => fetch(new Request(input, init)) };
|
|
11
14
|
if (headers !== void 0) transportOptions.requestInit = { headers };
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import { Action_d_exports, Any, Codec } from "../Action.mjs";
|
|
2
2
|
import { HttpApiError } from "effect/unstable/httpapi";
|
|
3
3
|
//#region src/internal/actions.d.ts
|
|
4
|
-
/**
|
|
5
|
-
export interface
|
|
6
|
-
|
|
7
|
-
readonly
|
|
4
|
+
/** One side of a schema-error policy: the error it answers with, and how to make it. */
|
|
5
|
+
export interface SchemaErrorAnswer<E extends Codec> {
|
|
6
|
+
/** A declared error schema; its `httpApiStatus` is the HTTP status of the answer. */
|
|
7
|
+
readonly schema: E;
|
|
8
|
+
readonly make: (failure: HttpApiError.HttpApiSchemaError) => NoInfer<E["Type"]>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pure HTTP policy for native schema failures, split by whose fault they are. The
|
|
12
|
+
* library owns the split, so every group draws it the same way.
|
|
13
|
+
*/
|
|
14
|
+
export interface SchemaErrorPolicy<Invalid extends Codec = Codec, Internal extends Codec = Codec> {
|
|
15
|
+
/** The request did not decode: its payload, or its params, headers or query. */
|
|
16
|
+
readonly invalid: SchemaErrorAnswer<Invalid>;
|
|
17
|
+
/** The handler's result did not encode: its body or its response headers. */
|
|
18
|
+
readonly internal: SchemaErrorAnswer<Internal>;
|
|
8
19
|
}
|
|
9
20
|
/** The contract half of a group: what adapters and clients need, without `implement`. */
|
|
10
|
-
export interface Actions<Name extends string = string, A extends ReadonlyArray<Any> = ReadonlyArray<Any>,
|
|
21
|
+
export interface Actions<Name extends string = string, A extends ReadonlyArray<Any> = ReadonlyArray<Any>, PolicyError extends Codec = Codec> {
|
|
11
22
|
readonly name: Name;
|
|
12
23
|
readonly actions: A;
|
|
13
24
|
/** How HTTP answers failed decoding or encoding; native behavior without one. MCP is unaffected. */
|
|
14
|
-
readonly schemaError: SchemaErrorPolicy<
|
|
25
|
+
readonly schemaError: SchemaErrorPolicy<PolicyError, PolicyError> | undefined;
|
|
15
26
|
}
|
|
16
27
|
/** The errors a group's policy may answer with, by lookup rather than a conditional type. */
|
|
17
|
-
export type PolicyError<G extends Actions> = NonNullable<G["schemaError"]>["
|
|
28
|
+
export type PolicyError<G extends Actions> = NonNullable<G["schemaError"]>[keyof SchemaErrorPolicy]["schema"];
|
|
18
29
|
//#endregion
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
//#region src/internal/actions.ts
|
|
2
|
+
/** Each distinct error a policy may answer with, invalid first. */
|
|
3
|
+
const policyErrors = (policy) => [.../* @__PURE__ */ new Set([policy.invalid.schema, policy.internal.schema])];
|
|
4
|
+
/**
|
|
5
|
+
* `HttpApiBuilder` reports these kinds while encoding the handler's answer, after the
|
|
6
|
+
* handler ran; every other kind (`Payload`, `Params`, `Headers`, `Query`) comes from
|
|
7
|
+
* decoding the request before it.
|
|
8
|
+
*/
|
|
9
|
+
const responseKinds = /* @__PURE__ */ new Set(["Body", "ResponseHeaders"]);
|
|
10
|
+
/** The policy's answer to one native failure: `internal` for the response side, else `invalid`. */
|
|
11
|
+
const answerSchemaError = (policy, failure) => responseKinds.has(failure.kind) ? policy.internal.make(failure) : policy.invalid.make(failure);
|
|
2
12
|
/**
|
|
3
13
|
* An action's own failures plus the ones its surface answers with, which is what
|
|
4
14
|
* a projection of that action declares. A schema the action already declares is
|
|
@@ -28,4 +38,4 @@ const assertDistinct = (what, names) => {
|
|
|
28
38
|
}
|
|
29
39
|
};
|
|
30
40
|
//#endregion
|
|
31
|
-
export { assertDistinct, assertName, projectedErrors, selectNamed };
|
|
41
|
+
export { answerSchemaError, assertDistinct, assertName, policyErrors, projectedErrors, selectNamed };
|
|
@@ -22,8 +22,9 @@ var Implementation = class Implementation {
|
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
24
|
/**
|
|
25
|
-
* Select
|
|
26
|
-
*
|
|
25
|
+
* Select the handler of a concrete action, once, when an adapter binds its acquired
|
|
26
|
+
* record; `implement` has already refused a record without it. The returned function
|
|
27
|
+
* invokes it: the surface's pre-handler hook runs first, outside the action's span, so a
|
|
27
28
|
* refusal is attributed to the surface rather than to a handler that never ran.
|
|
28
29
|
* `R` remains in the returned effect so transport layers cannot erase required
|
|
29
30
|
* per-request services while assembling routes.
|
package/dist/internal/tools.mjs
CHANGED
|
@@ -47,7 +47,7 @@ const project = (projection, entry, options) => {
|
|
|
47
47
|
return tool.annotate(Tool.Strict, true);
|
|
48
48
|
};
|
|
49
49
|
const handler = (projection, app, action, handlers, before) => {
|
|
50
|
-
const run =
|
|
50
|
+
const run = dispatch(app.group, action, handlers, before);
|
|
51
51
|
return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
|
|
52
52
|
};
|
|
53
53
|
/**
|