@gjermundgaraba/effect-actions 0.1.0 → 0.2.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 CHANGED
@@ -101,6 +101,10 @@ The `effect` peer is pinned to an exact Effect 4 release candidate and moves wit
101
101
  release of this package for each Effect release candidate it adopts. Actions are unary JSON over HTTP and MCP: no streaming, uploads, prompts, or resources.
102
102
  Authentication and authorization belong to the application. See [docs/setup.md](docs/setup.md).
103
103
 
104
+ ## Acknowledgements
105
+
106
+ A few idea (like the native Toolkit, CLI, and catalog projections) are inspired by the excellent [rat-stack](https://github.com/joelhooks/rat-stack) by Joel Hooks.
107
+
104
108
  ## Contributing
105
109
 
106
110
  See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
@@ -7,23 +7,35 @@ const httpActions = (group) => group.actions.filter((action) => action.http);
7
7
  const endpoint = (apiPath, group, action) => HttpApiEndpoint.post(action.name, `${apiPath}/${group.name}/${action.name}`, {
8
8
  payload: action.input,
9
9
  success: action.success,
10
- error: [...action.errors, ...group.schemaError?.errors ?? []]
10
+ error: action.errors
11
11
  }).annotate(OpenApi.Description, action.description);
12
- function erasedApi(groups, options) {
13
- const [first, ...rest] = groups.flatMap((group) => {
14
- const [first, ...rest] = httpActions(group).map((action) => endpoint(options.apiPath, group, action));
15
- return first === void 0 ? [] : [HttpApiGroup.make(group.name).add(first, ...rest)];
16
- });
17
- const empty = HttpApi.make("actions");
18
- return first === void 0 ? empty : empty.add(first, ...rest);
19
- }
20
- const schemaErrorMiddleware = (policy) => {
21
- class SchemaErrors extends HttpApiMiddleware.Service()("effect-actions/http/SchemaErrors", { error: policy.errors }) {}
12
+ /** The native middleware declares the policy's errors on every endpoint it wraps. */
13
+ const schemaErrorMiddleware = (name, policy) => {
14
+ class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policy.errors }) {}
22
15
  return {
23
16
  SchemaErrors,
24
17
  layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy.map(failure)))
25
18
  };
26
19
  };
20
+ function bind(options, group) {
21
+ const [first, ...rest] = httpActions(group).map((action) => endpoint(options.apiPath, group, action));
22
+ if (first === void 0) return void 0;
23
+ const native = HttpApiGroup.make(group.name).add(first, ...rest);
24
+ if (group.schemaError === void 0) return {
25
+ native,
26
+ policy: Layer.empty
27
+ };
28
+ const policy = schemaErrorMiddleware(group.name, group.schemaError);
29
+ return {
30
+ native: native.middleware(policy.SchemaErrors),
31
+ policy: policy.layer
32
+ };
33
+ }
34
+ function apiOf(groups) {
35
+ const [first, ...rest] = groups;
36
+ const empty = HttpApi.make("actions").annotate(HttpApi.ParseOptions, { errors: "all" });
37
+ return first === void 0 ? empty : empty.add(first, ...rest);
38
+ }
27
39
  /**
28
40
  * `HttpApiBuilder.handleAll` registers a dynamically assembled record. The
29
41
  * action selected by the native router determines the input before `dispatch`
@@ -34,32 +46,33 @@ const schemaErrorMiddleware = (policy) => {
34
46
  const erasedHandlers = (handlers) => {
35
47
  return handlers;
36
48
  };
37
- /** Build routes for one group. Handler erasure is limited to native dynamic endpoint registration. */
38
- const groupLayer = (options, app) => {
39
- const actions = httpActions(app.group);
40
- if (actions.length === 0) return Layer.empty;
41
- const api = erasedApi([app.group], options);
42
- const entries = (record) => Object.fromEntries(actions.map((action) => [action.name, (request) => dispatch(app.group, action, record)(request.payload)]));
43
- const handlers = (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(record)));
44
- if (app.group.schemaError === void 0) return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.layer(api).pipe(Layer.provide(handlers(erasedHandlers(record))))));
45
- const schemaErrors = schemaErrorMiddleware(app.group.schemaError);
46
- const policyApi = api.middleware(schemaErrors.SchemaErrors);
47
- const policyHandlers = (record) => HttpApiBuilder.group(policyApi, app.group.name, (builder) => {
48
- const dynamicEntries = entries(record);
49
- return builder.handleAll(dynamicEntries);
50
- });
51
- return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.layer(policyApi).pipe(Layer.provide(policyHandlers(erasedHandlers(record))), Layer.provide(schemaErrors.layer))));
49
+ /** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
50
+ const groupHandlers = (api, app, policy) => {
51
+ const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record)(request.payload)]));
52
+ return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(policy));
52
53
  };
53
54
  function make(options, ...groups) {
54
55
  assertDistinct("action group", groups.map((group) => group.name));
56
+ const bound = new Map(groups.flatMap((group) => {
57
+ const binding = bind(options, group);
58
+ return binding === void 0 ? [] : [[group, binding]];
59
+ }));
55
60
  const layer = (...apps) => {
56
61
  assertDistinct("implementation group", apps.map((app) => app.group.name));
57
62
  for (const app of apps) if (!groups.includes(app.group)) throw new Error(`Implementation of group "${app.group.name}" is not served by this adapter`);
58
- return apps.map((app) => groupLayer(options, app)).reduce((all, app) => Layer.merge(all, app), Layer.empty);
63
+ const served = apps.flatMap((app) => {
64
+ const binding = bound.get(app.group);
65
+ return binding === void 0 ? [] : [{
66
+ app,
67
+ ...binding
68
+ }];
69
+ });
70
+ const api = apiOf(served.map(({ native }) => native));
71
+ return served.reduce((layer, { app, policy }) => layer.pipe(Layer.provide(groupHandlers(api, app, policy))), HttpApiBuilder.layer(api));
59
72
  };
60
73
  return {
61
74
  groups,
62
- api: erasedApi(groups, options),
75
+ api: apiOf([...bound.values()].map(({ native }) => native)),
63
76
  layer
64
77
  };
65
78
  }
@@ -1,6 +1,6 @@
1
1
  import { AnyImplementation, BuildContext, BuildError, RequestContext } from "./internal/implementation.mjs";
2
2
  import { Cause, Layer } from "effect";
3
- import { McpServer } from "effect/unstable/ai";
3
+ import { McpSchema, McpServer } from "effect/unstable/ai";
4
4
  import { HttpRouter } from "effect/unstable/http";
5
5
  import { Stdio } from "effect/Stdio";
6
6
  //#region src/ActionMcp.d.ts
@@ -24,18 +24,20 @@ export interface StdioOptions {
24
24
  readonly protocols: Parameters<typeof McpServer.layerStdio>[0]["protocols"];
25
25
  readonly instructions?: string;
26
26
  }
27
+ /** The native server supplies its own request context to every tool call. */
28
+ type ToolRequestContext<App> = Exclude<RequestContext<App>, McpSchema.McpRequestContext>;
27
29
  /**
28
30
  * Serve MCP tools over one Streamable HTTP endpoint.
29
31
  *
30
32
  * Middleware provided around this layer has the normal HTTP lifetime. Native
31
33
  * context capture applies: never provide request-identity tags at startup.
32
34
  */
33
- export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(options: Options, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]>>>;
35
+ export declare function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(options: Options, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ToolRequestContext<Apps[number]>>>;
34
36
  /**
35
37
  * Serve MCP tools through newline-delimited JSON-RPC on standard I/O.
36
38
  *
37
39
  * The host supplies the `Stdio` service. Arguments are tool input only and
38
40
  * never establish request identity or authority.
39
41
  */
40
- export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(options: StdioOptions, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio | RequestContext<Apps[number]>>;
42
+ export declare function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(options: StdioOptions, ...apps: Apps): Layer.Layer<never, BuildError<Apps[number]> | Cause.IllegalArgumentError, BuildContext<Apps[number]> | Stdio | ToolRequestContext<Apps[number]>>;
41
43
  //#endregion
@@ -55,8 +55,9 @@ const assertMcpObjectInput = (action, tool) => {
55
55
  };
56
56
  const project = (projection, entry) => {
57
57
  const tool = projection === "native" ? nativeTool(entry.action) : mcpTool(entry.action);
58
- if (projection === "mcp") assertMcpObjectInput(entry.action, tool);
59
- return tool;
58
+ if (projection === "native") return tool;
59
+ assertMcpObjectInput(entry.action, tool);
60
+ return tool.annotate(Tool.Strict, true);
60
61
  };
61
62
  const handler = (projection, app, action, handlers) => {
62
63
  const run = (input) => dispatch(app.group, action, handlers)(input);
package/docs/Action.md CHANGED
@@ -107,7 +107,7 @@ export const Double = Action.make("double", {
107
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
108
  - Schemas must be service-free. Put service access in the handler.
109
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.
110
+ - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP cannot be strict natively: `HttpApi.ParseOptions` also governs error encoding, where `onExcessProperty: "error"` rejects an error's own `stack`.
111
111
 
112
112
  ## Failure modes
113
113
 
@@ -124,11 +124,11 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
124
124
 
125
125
  - Without a policy, HTTP answers decoding and encoding failures with Effect's native empty 400.
126
126
  - With a policy, `map` receives the native `HttpApiSchemaError` (`kind`, `cause`) and returns one of `errors`. HTTP uses that error's `httpApiStatus`, and the error appears in `Http.api`, in clients, and in OpenAPI.
127
- - `kind` values: `Payload` (input decoding), `Body` (success encoding), plus `Parameters`, `RequestHeaders`, `Query`, `ResponseHeaders`.
127
+ - `kind` values: `Payload` (input decoding) and `Body` (success encoding). Actions declare no parameters, query, or headers, so no other kind occurs.
128
128
  - MCP is unaffected. The native `McpServer` answers invalid arguments and unencodable results itself.
129
129
  - Policy errors extend the transport contract, not the handler contract. A handler cannot return them.
130
130
  - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
131
- - `cause` may contain input values. Never reflect it into the response.
131
+ - HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
132
132
  - Groups served by one adapter may have different policies; each action answers with its own group's.
133
133
  - Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
134
134
 
package/docs/ActionMcp.md CHANGED
@@ -118,6 +118,7 @@ Layer.launch(layer).pipe(
118
118
  - Defects and encoding failures produce the generic `isError` text `Tool execution failed due to an internal server error.`; the cause is logged, not sent.
119
119
  - Tool discovery is not filtered by actor. Every caller sees every tool of the endpoint. Authorization happens in the handler.
120
120
  - Cancellation is Effect's native RPC interruption.
121
+ - Handlers may yield `McpSchema.McpRequestContext` for the client's declared information; the native server supplies it to every tool call, so it is never a router or host requirement.
121
122
  - stdio: the host supplies `Stdio` (`NodeStdio.layer`) and any request-time services explicitly, including the trusted principal. There is no authentication middleware. Tool arguments never establish identity. Keep stdout for protocol messages only and route logs to stderr.
122
123
  - Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
123
124
 
@@ -27,7 +27,7 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
27
27
 
28
28
  - A group `schemaError` policy replaces the empty 400s on HTTP with a declared error and status. It never affects MCP. Details in [ActionGroup.md](ActionGroup.md).
29
29
  - Routes are `POST <apiPath>/<group>/<action>`. Operation IDs are `<group>.<action>`. Effect generates OpenAPI component names and references.
30
- - Object schemas strip excess fields unless configured to reject them.
30
+ - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP cannot be strict natively: `HttpApi.ParseOptions` also governs error encoding, where `onExcessProperty: "error"` rejects an error's own `stack`.
31
31
 
32
32
  ## Namespaces
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, native Toolkits, catalogs, and CLIs.",
5
5
  "homepage": "https://github.com/gjermundgaraba/effect-actions#readme",
6
6
  "bugs": {