@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 +4 -0
- package/dist/ActionHttp.mjs +41 -28
- package/dist/ActionMcp.d.mts +5 -3
- package/dist/internal/tools.mjs +3 -2
- package/docs/Action.md +1 -1
- package/docs/ActionGroup.md +2 -2
- package/docs/ActionMcp.md +1 -0
- package/docs/guarantees.md +1 -1
- package/package.json +1 -1
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.
|
package/dist/ActionHttp.mjs
CHANGED
|
@@ -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:
|
|
10
|
+
error: action.errors
|
|
11
11
|
}).annotate(OpenApi.Description, action.description);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
-
|
|
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:
|
|
75
|
+
api: apiOf([...bound.values()].map(({ native }) => native)),
|
|
63
76
|
layer
|
|
64
77
|
};
|
|
65
78
|
}
|
package/dist/ActionMcp.d.mts
CHANGED
|
@@ -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",
|
|
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 |
|
|
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
|
package/dist/internal/tools.mjs
CHANGED
|
@@ -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 === "
|
|
59
|
-
|
|
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
|
-
-
|
|
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
|
|
package/docs/ActionGroup.md
CHANGED
|
@@ -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)
|
|
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`
|
|
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
|
|
package/docs/guarantees.md
CHANGED
|
@@ -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
|
-
-
|
|
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.
|
|
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": {
|