@gjermundgaraba/effect-actions 0.1.0-rc.1 → 0.1.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 (49) hide show
  1. package/README.md +50 -219
  2. package/dist/Action.d.mts +62 -2
  3. package/dist/Action.mjs +7 -4
  4. package/dist/ActionCatalog.d.mts +31 -0
  5. package/dist/ActionCatalog.mjs +36 -0
  6. package/dist/ActionCli.d.mts +27 -0
  7. package/dist/ActionCli.mjs +26 -0
  8. package/dist/ActionCliClient.d.mts +45 -0
  9. package/dist/ActionCliClient.mjs +52 -0
  10. package/dist/ActionGroup.d.mts +15 -14
  11. package/dist/ActionGroup.mjs +3 -2
  12. package/dist/ActionHttp.d.mts +21 -34
  13. package/dist/ActionHttp.mjs +44 -51
  14. package/dist/ActionMcp.d.mts +28 -7
  15. package/dist/ActionMcp.mjs +20 -128
  16. package/dist/ActionToolkit.d.mts +37 -0
  17. package/dist/ActionToolkit.mjs +10 -0
  18. package/dist/Authentication.d.mts +11 -12
  19. package/dist/Authentication.mjs +24 -57
  20. package/dist/Testing.d.mts +7 -4
  21. package/dist/Testing.mjs +4 -2
  22. package/dist/TestingClient.d.mts +6 -3
  23. package/dist/TestingClient.mjs +2 -2
  24. package/dist/_virtual/_rolldown/runtime.mjs +13 -0
  25. package/dist/internal/actions.d.mts +18 -0
  26. package/dist/internal/actions.mjs +16 -0
  27. package/dist/internal/cli.d.mts +29 -0
  28. package/dist/internal/cli.mjs +36 -0
  29. package/dist/internal/implementation.d.mts +39 -0
  30. package/dist/internal/implementation.mjs +37 -0
  31. package/dist/internal/tools.mjs +84 -0
  32. package/docs/Action.md +117 -0
  33. package/docs/ActionCatalog.md +56 -0
  34. package/docs/ActionCli.md +91 -0
  35. package/docs/ActionCliClient.md +77 -0
  36. package/docs/ActionGroup.md +141 -0
  37. package/docs/ActionHttp.md +106 -0
  38. package/docs/ActionMcp.md +131 -0
  39. package/docs/ActionToolkit.md +73 -0
  40. package/docs/Authentication.md +114 -0
  41. package/docs/CONTEXT.md +36 -0
  42. package/docs/README.md +85 -0
  43. package/docs/Testing.md +91 -0
  44. package/docs/guarantees.md +55 -0
  45. package/docs/setup.md +63 -0
  46. package/package.json +19 -14
  47. package/dist/Action-CXsfTEhx.d.mts +0 -54
  48. package/dist/implementation-4Q2BESw5.mjs +0 -61
  49. package/dist/implementation-DTqp_0DW.d.mts +0 -61
@@ -1,66 +1,37 @@
1
- import { Effect, Schema, SchemaAST } from "effect";
1
+ import { Effect } from "effect";
2
2
  import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
3
  //#region src/Authentication.ts
4
4
  /**
5
5
  * Authenticate each request and provide its identity to the downstream handler.
6
- * Only authentication failures are encoded, using each schema's httpApiStatus.
7
- * Dependencies remain native router request requirements. Acquired resources live
8
- * until the request scope closes, including while the handler is running.
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.
9
11
  */
10
- const middleware = (service, options) => {
11
- const errors = options.errors.map((schema) => ({
12
- matches: Schema.is(schema),
13
- encode: HttpServerResponse.schemaJson(schema),
14
- status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500
15
- }));
16
- return HttpRouter.middleware()((httpEffect) => options.authenticate.pipe(Effect.matchEffect({
17
- onFailure: (error) => {
18
- const selected = errors.find((candidate) => candidate.matches(error));
19
- if (selected === void 0) return Effect.die(/* @__PURE__ */ new Error("Undeclared authentication error"));
20
- return selected.encode(error, {
21
- status: selected.status,
22
- headers: options.headers?.(error)
23
- }).pipe(Effect.orDie);
24
- },
25
- onSuccess: (identity) => Effect.provideService(httpEffect, service, identity)
26
- }), HttpEffect.withPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")))));
27
- };
28
- const oauthUrl = (value) => {
29
- const url = new URL(value);
30
- const loopback = [
31
- "localhost",
32
- "127.0.0.1",
33
- "[::1]"
34
- ].includes(url.hostname);
35
- if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username !== "" || url.password !== "" || value.includes("#")) throw new Error("OAuth URLs must use HTTPS (or loopback HTTP) without credentials or fragment");
36
- return url;
37
- };
38
- const scopeToken = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
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, "\\$&")}"`;
39
17
  /**
40
18
  * Publish RFC 9728 discovery independently from the authenticated routes.
41
19
  * This supplies metadata and challenges; the host still verifies access tokens.
42
20
  */
43
21
  const protectedResource = (options) => {
44
- const resource = oauthUrl(options.resource);
45
- if (options.authorizationServers.length === 0) throw new Error("An authorization server is required");
46
- for (const issuer of options.authorizationServers) {
47
- oauthUrl(issuer);
48
- if (issuer.includes("?")) throw new Error("Authorization server issuers must not contain a query");
49
- }
50
- for (const scope of options.scopesSupported ?? []) if (!scopeToken.test(scope)) throw new Error("Invalid OAuth scope token");
22
+ const resource = new URL(options.resource);
51
23
  const path = `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`;
52
24
  const discoveryUrl = new URL(resource);
53
25
  discoveryUrl.pathname = path;
54
26
  const metadataUrl = discoveryUrl.href;
55
27
  const target = metadataUrl.slice(discoveryUrl.origin.length);
56
- const metadata = {
28
+ const response = HttpServerResponse.jsonUnsafe({
57
29
  resource: options.resource,
58
30
  authorization_servers: options.authorizationServers,
59
- bearer_methods_supported: ["header"]
60
- };
61
- if (options.scopesSupported !== void 0 && options.scopesSupported.length > 0) metadata.scopes_supported = options.scopesSupported;
62
- if (options.resourceName !== void 0) metadata.resource_name = options.resourceName;
63
- const response = HttpServerResponse.jsonUnsafe(metadata);
31
+ bearer_methods_supported: ["header"],
32
+ scopes_supported: options.scopesSupported,
33
+ resource_name: options.resourceName
34
+ });
64
35
  return {
65
36
  layer: HttpRouter.middleware((next) => Effect.gen(function* () {
66
37
  const request = yield* HttpServerRequest.HttpServerRequest;
@@ -69,18 +40,14 @@ const protectedResource = (options) => {
69
40
  return yield* next;
70
41
  }), { global: true }),
71
42
  metadataUrl,
43
+ /** The `WWW-Authenticate` value; parameter values are quoted and escaped. */
72
44
  challenge: (challenge = {}) => {
73
- const parameters = [`resource_metadata="${metadataUrl.replace(/["\\]/g, "\\$&")}"`];
74
- if (challenge.error !== void 0) parameters.push(`error="${challenge.error}"`);
75
- if (challenge.errorDescription !== void 0) {
76
- if (!/^[\x20-\x21\x23-\x5B\x5D-\x7E]*$/.test(challenge.errorDescription)) throw new Error("Invalid OAuth error description");
77
- parameters.push(`error_description="${challenge.errorDescription}"`);
78
- }
79
- if (challenge.scope !== void 0) {
80
- if (!challenge.scope.split(" ").every((scope) => scopeToken.test(scope))) throw new Error("Invalid OAuth challenge scope");
81
- parameters.push(`scope="${challenge.scope}"`);
82
- }
83
- return `Bearer ${parameters.join(", ")}`;
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(", ")}`;
84
51
  }
85
52
  };
86
53
  };
@@ -1,14 +1,15 @@
1
- import { o as Actions } from "./implementation-DTqp_0DW.mjs";
2
- import { Client, ClientOptions, Http, t as ActionHttp_d_exports } from "./ActionHttp.mjs";
3
1
  import { Effect } from "effect";
2
+ import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi";
4
3
  //#region src/Testing.d.ts
5
4
  /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
6
5
  export type Handler = (request: Request) => Promise<Response>;
7
6
  /**
8
- * The typed HTTP client, calling `handler` in memory instead of the network.
7
+ * The native grouped HTTP client, calling `handler` in memory instead of the network.
9
8
  * `baseUrl` defaults to `http://localhost`.
10
9
  */
11
- export declare const httpClient: <G extends Actions>(http: Pick<Http<G>, "client">, handler: Handler, options?: ClientOptions) => Effect.Effect<Client<G>>;
10
+ export declare const httpClient: <Id extends string, Groups extends HttpApiGroup.Constraint>(api: HttpApi.HttpApi<Id, Groups>, handler: Handler, options?: NonNullable<Parameters<typeof HttpApiClient.make>[1]>) => Effect.Effect<import("effect/Types").Simplify<{ readonly [Group in Extract<Groups, {
11
+ readonly topLevel: false;
12
+ }> as HttpApiGroup.Identifier<Group>]: HttpApiClient.Client.GroupByEndpoint<Group, never, never>; } & HttpApiClient.Client.TopLevelMethods<Groups, never, never>>, never, Exclude<import("effect/unstable/httpapi/HttpApiMiddleware").MiddlewareClient<import("effect/unstable/httpapi/HttpApiEndpoint").Middleware<HttpApiGroup.Endpoints<Groups>>>, import("effect/unstable/http/HttpClient").HttpClient>>;
12
13
  /** A stateless 2026-07-28 request. */
13
14
  export interface McpRequestOptions {
14
15
  readonly url: string | URL;
@@ -23,11 +24,13 @@ export interface McpRequestOptions {
23
24
  export type McpRequestValue = string | number | boolean | null | undefined | ReadonlyArray<McpRequestValue> | {
24
25
  readonly [key: string]: McpRequestValue;
25
26
  };
27
+ /** JSON-RPC `params`; `_meta` is merged shallowly over the defaults `mcpRequest` supplies. */
26
28
  export interface McpRequestParams {
27
29
  readonly _meta?: {
28
30
  readonly [key: string]: McpRequestValue;
29
31
  };
30
32
  readonly [key: string]: McpRequestValue;
31
33
  }
34
+ /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
32
35
  export declare const mcpRequest: ({ url, method, params, headers: init }: McpRequestOptions) => Request;
33
36
  //#endregion
package/dist/Testing.mjs CHANGED
@@ -1,14 +1,16 @@
1
1
  import { Effect, Layer, Predicate } from "effect";
2
+ import { HttpApiClient } from "effect/unstable/httpapi";
2
3
  import { FetchHttpClient } from "effect/unstable/http";
3
4
  //#region src/Testing.ts
4
5
  /**
5
- * The typed HTTP client, calling `handler` in memory instead of the network.
6
+ * The native grouped HTTP client, calling `handler` in memory instead of the network.
6
7
  * `baseUrl` defaults to `http://localhost`.
7
8
  */
8
- const httpClient = (http, handler, options) => http.client({
9
+ const httpClient = (api, handler, options) => HttpApiClient.make(api, {
9
10
  baseUrl: "http://localhost",
10
11
  ...options
11
12
  }).pipe(Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, (input, init) => handler(new Request(input, init)))))));
13
+ /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
12
14
  const mcpRequest = ({ url, method, params = {}, headers: init }) => {
13
15
  const headers = new Headers(init);
14
16
  headers.set("content-type", "application/json");
@@ -1,13 +1,16 @@
1
- import { Client } from "@modelcontextprotocol/client";
1
+ import { Client, ClientOptions } from "@modelcontextprotocol/client";
2
2
  //#region src/TestingClient.d.ts
3
+ /** How `withMcpClient` connects the official client. */
3
4
  export interface McpClientOptions {
4
5
  /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
5
6
  readonly fetch: (request: Request) => Promise<Response>;
6
7
  readonly path: string;
7
- readonly mode?: "legacy" | "modern";
8
+ /** Passed directly to the official client; omitted options use its native default. */
9
+ readonly versionNegotiation?: ClientOptions["versionNegotiation"];
10
+ /** Defaults to `http://localhost`. */
8
11
  readonly baseUrl?: string | URL;
9
12
  readonly headers?: ConstructorParameters<typeof Headers>[0];
10
13
  }
11
14
  /** Connect an official client and always close its transport after the callback. */
12
- export declare const withMcpClient: <A>({ fetch, mode, path, baseUrl, headers }: McpClientOptions, run: (client: Client) => Promise<A>) => Promise<A>;
15
+ export declare const withMcpClient: <A>({ fetch, path, baseUrl, headers, versionNegotiation }: McpClientOptions, run: (client: Client) => Promise<A>) => Promise<A>;
13
16
  //#endregion
@@ -1,11 +1,11 @@
1
1
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
2
  //#region src/TestingClient.ts
3
3
  /** Connect an official client and always close its transport after the callback. */
4
- const withMcpClient = async ({ fetch, mode = "modern", path, baseUrl = "http://localhost", headers }, run) => {
4
+ const withMcpClient = async ({ fetch, path, baseUrl = "http://localhost", headers, versionNegotiation }, run) => {
5
5
  const client = new Client({
6
6
  name: "test",
7
7
  version: "0"
8
- }, { versionNegotiation: { mode: mode === "modern" ? { pin: "2026-07-28" } : "legacy" } });
8
+ }, versionNegotiation === void 0 ? {} : { versionNegotiation });
9
9
  try {
10
10
  const transportOptions = { fetch: (input, init) => fetch(new Request(input, init)) };
11
11
  if (headers !== void 0) transportOptions.requestInit = { headers };
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll };
@@ -0,0 +1,18 @@
1
+ import { Action_d_exports, Any, Codec } from "../Action.mjs";
2
+ import { HttpApiError } from "effect/unstable/httpapi";
3
+ //#region src/internal/actions.d.ts
4
+ /** Pure HTTP policy for native schema decoding and encoding failures. */
5
+ export interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
6
+ readonly errors: Errors;
7
+ readonly map: (failure: HttpApiError.HttpApiSchemaError) => NoInfer<Errors[number]["Type"]>;
8
+ }
9
+ /** 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>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> {
11
+ readonly name: Name;
12
+ readonly actions: A;
13
+ /** How HTTP answers failed decoding or encoding; native behavior without one. MCP is unaffected. */
14
+ readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
15
+ }
16
+ /** 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"]>["errors"][number];
18
+ //#endregion
@@ -0,0 +1,16 @@
1
+ //#region src/internal/actions.ts
2
+ const validName = /^[A-Za-z0-9_-]+$/;
3
+ /** Names become path segments, OpenAPI identifiers and client method keys. */
4
+ const assertName = (what, name) => {
5
+ if (!validName.test(name) || name === "then") throw new Error(`Invalid ${what}: ${name}`);
6
+ };
7
+ /** Each namespace is checked by whoever owns it: a group, the routes, or the MCP tools. */
8
+ const assertDistinct = (what, names) => {
9
+ const seen = /* @__PURE__ */ new Set();
10
+ for (const name of names) {
11
+ if (seen.has(name)) throw new Error(`Duplicate ${what}: ${name}`);
12
+ seen.add(name);
13
+ }
14
+ };
15
+ //#endregion
16
+ export { assertDistinct, assertName };
@@ -0,0 +1,29 @@
1
+ import "../Action.mjs";
2
+ import { Effect, Schema } from "effect";
3
+ import { Command } from "effect/unstable/cli";
4
+ //#region src/internal/cli.d.ts
5
+ /** Options shared by the local and HTTP command projections. */
6
+ interface CommonOptions<Output> {
7
+ /** Override the command name. The action name is used by default. */
8
+ readonly name?: string;
9
+ /** Human output. JSON remains available with `--json`. */
10
+ readonly render?: (output: Output) => string;
11
+ }
12
+ /** The default projection accepts a whole canonical JSON input with `--input`. */
13
+ export interface JsonOptions<Output> extends CommonOptions<Output> {
14
+ readonly parameters?: never;
15
+ readonly input?: never;
16
+ }
17
+ /** Supply native CLI parameters and map their parsed values to canonical action JSON. */
18
+ export interface ParametersOptions<Output, Parameters extends Command.Command.Config> extends CommonOptions<Output> {
19
+ /** Native Effect CLI flags and arguments. */
20
+ readonly parameters: Parameters;
21
+ /** Maps native parsed parameters to the action's canonical JSON input. */
22
+ readonly input: (parsed: Command.Command.Config.Infer<Parameters>) => Schema.Json;
23
+ }
24
+ /**
25
+ * A command either accepts whole JSON input or has explicit native CLI parameters.
26
+ * The latter intentionally has no implicit `--input` mode.
27
+ */
28
+ export type Options<Output, Parameters extends Command.Command.Config = never> = JsonOptions<Output> | ParametersOptions<Output, Parameters>;
29
+ //#endregion
@@ -0,0 +1,36 @@
1
+ import { Console, Effect, Option, Schema } from "effect";
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ //#region src/internal/cli.ts
4
+ const isParametersOptions = (options) => options?.parameters !== void 0;
5
+ const jsonOutput = Flag.Boolean("json").pipe(Flag.optional, Flag.withDescription("Print machine-readable JSON"));
6
+ const output = (action, execute, input, render, useJson) => Effect.gen(function* () {
7
+ const value = yield* execute(input);
8
+ const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.success))(value);
9
+ const text = render !== void 0 && !useJson ? render(value) : JSON.stringify(encoded, null, 2);
10
+ yield* Console.log(text);
11
+ });
12
+ const defaultCommand = (action, execute, options) => {
13
+ const input = Flag.String("input").pipe(Flag.withDefault("{}"), Flag.withSchema(Schema.fromJsonString(Schema.toCodecJson(action.input))), Flag.withDescription("Whole canonical action input as JSON"));
14
+ const config = options?.render === void 0 ? { input } : {
15
+ input,
16
+ output: { json: jsonOutput }
17
+ };
18
+ return Command.make(options?.name ?? action.name, config, (parsed) => Effect.gen(function* () {
19
+ const useJson = "output" in parsed && Option.isSome(parsed.output.json) && parsed.output.json.value === true;
20
+ return yield* output(action, execute, parsed.input, options?.render, useJson);
21
+ })).pipe(Command.withDescription(action.description));
22
+ };
23
+ const parametersCommand = (action, execute, options) => {
24
+ if (options.render === void 0) return Command.make(options.name ?? action.name, options.parameters, (parsed) => Effect.flatMap(Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))(options.input(parsed)), (input) => output(action, execute, input, void 0, true))).pipe(Command.withDescription(action.description));
25
+ return Command.make(options.name ?? action.name, {
26
+ parameters: options.parameters,
27
+ output: { json: jsonOutput }
28
+ }, (parsed) => Effect.flatMap(Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))(options.input(parsed.parameters)), (input) => output(action, execute, input, options.render, Option.isSome(parsed.output.json) && parsed.output.json.value === true))).pipe(Command.withDescription(action.description));
29
+ };
30
+ /** Build one native command around an action-bound operation. */
31
+ const command = (action, execute, options) => {
32
+ if (isParametersOptions(options)) return parametersCommand(action, execute, options);
33
+ return defaultCommand(action, execute, options);
34
+ };
35
+ //#endregion
36
+ export { command };
@@ -0,0 +1,39 @@
1
+ import { Action_d_exports, Any } from "../Action.mjs";
2
+ import { Actions } from "./actions.mjs";
3
+ import { Effect, Scope } from "effect";
4
+ //#region src/internal/implementation.d.ts
5
+ /** Decoded values at the adapter dispatch boundary. */
6
+ export type ErasedValue = Any["input"]["Type"];
7
+ type ErasedHandler<R> = {
8
+ handle(input: ErasedValue): Effect.Effect<ErasedValue, ErasedValue, R>;
9
+ }["handle"];
10
+ /** An adapter's erased view of a record of handlers with request requirements `R`. */
11
+ export type Handlers<R> = Readonly<Record<string, ErasedHandler<R>>>;
12
+ /** Per-request requirements of the handler selected by an action name. */
13
+ export type HandlerContext<H, Name extends keyof H> = H[Name] extends ((input: never) => Effect.Effect<infer _A, infer _E, infer R>) ? R : never;
14
+ /** The union of per-request requirements declared by a handler record. */
15
+ export type HandlersContext<H> = { readonly [K in keyof H]: HandlerContext<H, K>; }[keyof H];
16
+ /**
17
+ * An acquired handler record for a group.
18
+ *
19
+ * The private field makes this class nominal: a structurally similar object,
20
+ * including one made by spreading an implementation, is not an implementation.
21
+ */
22
+ export declare class Implementation<G extends Actions, H, EX, RX> {
23
+ #private;
24
+ /** The group whose exact handler record was built. */
25
+ readonly group: G;
26
+ private constructor();
27
+ /** Acquire the exact handler record in the adapter layer's scope. */
28
+ get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
29
+ static make<G extends Actions, H, EX, RX>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>): Implementation<G, H, EX, RX>;
30
+ }
31
+ /** Any nominal implementation of `G`, with its record and channels erased. */
32
+ export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
33
+ /** Per-request requirements of one implementation, or of a union of them. */
34
+ export type RequestContext<App> = App extends Implementation<any, infer H, any, any> ? HandlersContext<H> : never;
35
+ /** Handler-acquisition failures of one implementation, or of a union of them. */
36
+ export type BuildError<App> = App extends Implementation<any, any, infer EX, any> ? EX : never;
37
+ /** Handler-acquisition requirements of one implementation, or of a union of them. */
38
+ export type BuildContext<App> = App extends Implementation<any, any, any, infer RX> ? RX : never;
39
+ //#endregion
@@ -0,0 +1,37 @@
1
+ import { Effect } from "effect";
2
+ //#region src/internal/implementation.ts
3
+ /**
4
+ * An acquired handler record for a group.
5
+ *
6
+ * The private field makes this class nominal: a structurally similar object,
7
+ * including one made by spreading an implementation, is not an implementation.
8
+ */
9
+ var Implementation = class Implementation {
10
+ group;
11
+ #build;
12
+ constructor(group, build) {
13
+ this.group = group;
14
+ this.#build = build;
15
+ }
16
+ /** Acquire the exact handler record in the adapter layer's scope. */
17
+ get build() {
18
+ return this.#build;
19
+ }
20
+ static make(group, build) {
21
+ return new Implementation(group, build);
22
+ }
23
+ };
24
+ /**
25
+ * Select and invoke a handler after an adapter has selected a concrete action.
26
+ * `R` remains in the returned effect so transport layers cannot erase required
27
+ * per-request services while assembling routes.
28
+ */
29
+ const dispatch = (group, action, handlers) => {
30
+ const handle = handlers[action.name];
31
+ if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
32
+ return (input) => {
33
+ return Effect.withSpan(Effect.suspend(() => handle(input)), `${group.name}.${action.name}`, { captureStackTrace: false });
34
+ };
35
+ };
36
+ //#endregion
37
+ export { Implementation, dispatch };
@@ -0,0 +1,84 @@
1
+ import { dispatch } from "./implementation.mjs";
2
+ import { Effect, Schema } from "effect";
3
+ import { Tool, Toolkit } from "effect/unstable/ai";
4
+ //#region src/internal/tools.ts
5
+ const isToolAction = (action) => action.mcp !== false;
6
+ /** Only tool-enabled groups are acquired; each implementation appears once. */
7
+ const toolApps = (apps) => apps.flatMap((app) => {
8
+ const actions = app.group.actions.filter(isToolAction);
9
+ return actions.length === 0 ? [] : [{
10
+ app,
11
+ actions
12
+ }];
13
+ });
14
+ /** Flatten the projected contracts only after deciding which groups are served. */
15
+ const entries = (apps) => apps.flatMap(({ app, actions }) => actions.map((action) => ({
16
+ app,
17
+ action
18
+ })));
19
+ /** Fail before acquiring handlers when two exposed tools share a name. */
20
+ const assertDistinctToolNames = (tools) => {
21
+ const names = /* @__PURE__ */ new Set();
22
+ for (const { action } of tools) {
23
+ const name = action.mcp.name;
24
+ if (names.has(name)) throw new Error(`Duplicate MCP tool: ${name}`);
25
+ names.add(name);
26
+ }
27
+ };
28
+ const annotate = (tool, mcp) => tool.annotate(Tool.Readonly, mcp.readOnly).annotate(Tool.Destructive, mcp.destructive).annotate(Tool.Idempotent, mcp.idempotent).annotate(Tool.OpenWorld, mcp.openWorld);
29
+ /**
30
+ * A native Effect AI tool. Its schemas retain action transforms and its result
31
+ * is the action result itself, rather than an MCP response envelope.
32
+ */
33
+ const nativeTool = (action) => annotate(Tool.make(action.mcp.name, {
34
+ description: action.description,
35
+ parameters: action.input,
36
+ success: action.success,
37
+ failure: Schema.Union(action.errors),
38
+ failureMode: "return"
39
+ }), action.mcp);
40
+ /**
41
+ * MCP has a JSON-only wire contract. Success uses its documented `{ value }`
42
+ * structured-content envelope; declared failures are returned as JSON text.
43
+ */
44
+ const mcpTool = (action) => annotate(Tool.make(action.mcp.name, {
45
+ description: action.description,
46
+ parameters: Schema.toCodecJson(action.input),
47
+ success: Schema.toCodecJson(Schema.Struct({ value: action.success })),
48
+ failure: Schema.toCodecJson(Schema.Union(action.errors)),
49
+ failureMode: "return"
50
+ }), action.mcp);
51
+ /** MCP's input schema must describe a JSON object, including no-argument tools. */
52
+ const assertMcpObjectInput = (action, tool) => {
53
+ const root = Tool.getJsonSchemaFromSchema(tool.parametersSchema);
54
+ if (root.$ref === void 0 && root.type !== "object") throw new Error(`${action.name}: MCP input must have an object root; omit input for no arguments`);
55
+ };
56
+ const project = (projection, entry) => {
57
+ const tool = projection === "native" ? nativeTool(entry.action) : mcpTool(entry.action);
58
+ if (projection === "mcp") assertMcpObjectInput(entry.action, tool);
59
+ return tool;
60
+ };
61
+ const handler = (projection, app, action, handlers) => {
62
+ const run = (input) => dispatch(app.group, action, handlers)(input);
63
+ return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
64
+ };
65
+ /**
66
+ * Project MCP-enabled actions and acquire their handlers exactly once per adapter layer.
67
+ * Native and MCP differ only in tool codecs and the MCP success envelope; selection,
68
+ * scoped acquisition, dispatch and native Toolkit binding stay identical.
69
+ */
70
+ const bindTools = (apps, projection) => {
71
+ const served = toolApps(apps);
72
+ const selected = entries(served);
73
+ assertDistinctToolNames(selected);
74
+ const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry)));
75
+ return {
76
+ toolkit,
77
+ layer: toolkit.toLayer(Effect.map(Effect.forEach(served, ({ app, actions }) => Effect.map(app.build, (handlers) => {
78
+ const record = handlers;
79
+ return actions.map((action) => [action.mcp.name, handler(projection, app, action, record)]);
80
+ })), (built) => Object.fromEntries(built.flat())))
81
+ };
82
+ };
83
+ //#endregion
84
+ export { bindTools };
package/docs/Action.md ADDED
@@ -0,0 +1,117 @@
1
+ # Action
2
+
3
+ One contract: a name, schemas for input, success and declared errors, and per-transport
4
+ metadata. An action holds no behavior; handlers are bound by `ActionGroup.implement`.
5
+
6
+ ## API
7
+
8
+ ```ts
9
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
10
+
11
+ /** Any service-free schema. */
12
+ type Codec = Schema.Codec<unknown, unknown, never, never>;
13
+
14
+ function make<Name, Input = NoInput, Output, Errors = [], Http = true, Mcp = undefined>(
15
+ name: Name,
16
+ options: Options<Input, Output, Errors, Http, Mcp>,
17
+ ): Action<Name, Input, Output, Errors, Http, Mcp>;
18
+
19
+ interface Options<Input, Output, Errors, Http, Mcp> {
20
+ readonly description: string;
21
+ readonly input?: Input; // omit for an action without arguments
22
+ readonly success: Output;
23
+ readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
24
+ readonly http?: Http; // false hides the action from HTTP routes and clients
25
+ readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
26
+ }
27
+
28
+ interface McpOptions {
29
+ readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
30
+ readonly readOnly?: boolean; // readOnlyHint, default false
31
+ readonly destructive?: boolean; // destructiveHint, default !readOnly
32
+ readonly idempotent?: boolean; // idempotentHint, default false
33
+ readonly openWorld?: boolean; // openWorldHint, default true
34
+ }
35
+
36
+ interface Action<Name, Input, Output, Errors, Http, Mcp> {
37
+ readonly name: Name;
38
+ readonly description: string;
39
+ readonly input: Input;
40
+ readonly success: Output;
41
+ readonly errors: Errors;
42
+ readonly http: Http;
43
+ readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
44
+ }
45
+
46
+ /** Receives decoded input; may fail only with the declared errors. */
47
+ type Handler<A extends Any, R = never> = (
48
+ input: A["input"]["Type"],
49
+ ) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
50
+
51
+ type Any; // any action with its schemas erased
52
+ ```
53
+
54
+ ## Canonical
55
+
56
+ ```ts
57
+ import { Schema } from "effect";
58
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
59
+
60
+ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
61
+ "UserNotFound",
62
+ { id: Schema.String },
63
+ { httpApiStatus: 404 },
64
+ ) {}
65
+
66
+ export const GetUser = Action.make("getUser", {
67
+ description: "Look up a user in your tenant.",
68
+ input: Schema.Struct({ id: Schema.String }),
69
+ success: Schema.Struct({ id: Schema.String, name: Schema.String }),
70
+ errors: [UserNotFound],
71
+ mcp: { name: "get_user", readOnly: true },
72
+ });
73
+
74
+ // No arguments: omit `input`. Identity comes from request context, never from input.
75
+ export const WhoAmI = Action.make("whoAmI", {
76
+ description: "Inspect the authenticated actor.",
77
+ success: Schema.Struct({ id: Schema.String, tenantId: Schema.String }),
78
+ mcp: { readOnly: true },
79
+ });
80
+
81
+ // MCP-only: no HTTP route, no client method.
82
+ export const ListChanges = Action.make("listChanges", {
83
+ description: "List renames in your tenant, oldest first.",
84
+ success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
85
+ http: false,
86
+ mcp: { name: "list_changes", readOnly: true },
87
+ });
88
+
89
+ // Input decoding is the schema's: "21" on the wire, 21 in the handler.
90
+ export const Double = Action.make("double", {
91
+ description: "Double a finite number supplied as a string.",
92
+ input: Schema.Struct({ value: Schema.FiniteFromString }),
93
+ success: Schema.Finite,
94
+ mcp: { readOnly: true },
95
+ });
96
+ ```
97
+
98
+ ## Rules
99
+
100
+ - Names match `[A-Za-z0-9_-]+`. `then` is rejected: it would make a client thenable.
101
+ - Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
102
+ - `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
103
+ - A handler may fail only with the declared errors. Anything else is a defect.
104
+ - Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
105
+ - MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
106
+ - `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
107
+ - Hint defaults: `readOnly: false`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
108
+ - Schemas must be service-free. Put service access in the handler.
109
+ - The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
110
+ - Object schemas strip excess fields on decode unless the schema is configured to reject them.
111
+
112
+ ## Failure modes
113
+
114
+ - Throws at `make`: invalid name, name `then`, `mcp.name` longer than 128 characters or not matching the pattern.
115
+ - Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
116
+ - `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
117
+ - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.