@skaile/forge-app-actions 0.3.0 → 0.4.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 (42) hide show
  1. package/README.md +111 -4
  2. package/bin/forge-actions.mjs +274 -0
  3. package/dist/module.d.mts +10 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +55 -16
  6. package/dist/runtime/chat/ai-sdk.d.ts +23 -0
  7. package/dist/runtime/chat/ai-sdk.js +25 -0
  8. package/dist/runtime/client/index.d.ts +57 -0
  9. package/dist/runtime/client/index.js +60 -0
  10. package/dist/runtime/internal/bearer.d.ts +6 -0
  11. package/dist/runtime/internal/bearer.js +5 -0
  12. package/dist/runtime/internal/manifest.d.ts +17 -0
  13. package/dist/runtime/internal/manifest.js +0 -0
  14. package/dist/runtime/server/http/create-handler.d.ts +50 -0
  15. package/dist/runtime/server/http/create-handler.js +135 -0
  16. package/dist/runtime/server/http/index.d.ts +8 -0
  17. package/dist/runtime/server/http/index.js +1 -0
  18. package/dist/runtime/server/http/route.d.ts +16 -0
  19. package/dist/runtime/server/http/route.js +28 -0
  20. package/dist/runtime/server/index.d.ts +1 -0
  21. package/dist/runtime/server/index.js +1 -0
  22. package/dist/runtime/server/mcp/create-handler.d.ts +8 -6
  23. package/dist/runtime/server/mcp/create-handler.js +8 -26
  24. package/dist/runtime/server/mcp/route.d.ts +2 -0
  25. package/dist/runtime/server/mcp/route.js +7 -10
  26. package/dist/runtime/server/utils/invoke-action.d.ts +19 -0
  27. package/dist/runtime/server/utils/invoke-action.js +32 -0
  28. package/dist/runtime/server/utils/server-actions.d.ts +57 -12
  29. package/dist/runtime/server/utils/server-actions.js +20 -2
  30. package/package.json +26 -2
  31. package/src/runtime/chat/ai-sdk.ts +53 -0
  32. package/src/runtime/client/index.ts +131 -0
  33. package/src/runtime/internal/bearer.ts +11 -0
  34. package/src/runtime/internal/manifest.ts +19 -0
  35. package/src/runtime/server/http/create-handler.ts +220 -0
  36. package/src/runtime/server/http/index.ts +9 -0
  37. package/src/runtime/server/http/route.ts +54 -0
  38. package/src/runtime/server/index.ts +1 -0
  39. package/src/runtime/server/mcp/create-handler.ts +16 -43
  40. package/src/runtime/server/mcp/route.ts +8 -11
  41. package/src/runtime/server/utils/invoke-action.ts +71 -0
  42. package/src/runtime/server/utils/server-actions.ts +95 -14
@@ -0,0 +1,60 @@
1
+ export class ActionsClientError extends Error {
2
+ statusCode;
3
+ statusMessage;
4
+ data;
5
+ constructor(statusCode, statusMessage, message, data) {
6
+ super(message);
7
+ this.name = "ActionsClientError";
8
+ this.statusCode = statusCode;
9
+ this.statusMessage = statusMessage;
10
+ this.data = data;
11
+ }
12
+ }
13
+ function decode(text) {
14
+ if (text === "") return void 0;
15
+ try {
16
+ return JSON.parse(text);
17
+ } catch {
18
+ return text;
19
+ }
20
+ }
21
+ const nativeFetch = async (url, init) => {
22
+ const hasBody = init.body !== void 0;
23
+ const response = await globalThis.fetch(url, {
24
+ method: init.method,
25
+ headers: {
26
+ accept: "application/json",
27
+ ...hasBody ? { "content-type": "application/json" } : {},
28
+ ...init.headers
29
+ },
30
+ body: hasBody ? JSON.stringify(init.body) : void 0
31
+ });
32
+ const data = decode(await response.text());
33
+ if (!response.ok) {
34
+ const body = typeof data === "object" && data !== null ? data : {};
35
+ const statusMessage = typeof body.statusMessage === "string" ? body.statusMessage : response.statusText;
36
+ const message = typeof body.message === "string" ? body.message : statusMessage;
37
+ throw new ActionsClientError(response.status, statusMessage, message, data);
38
+ }
39
+ return data;
40
+ };
41
+ export function createActionsClient(options = {}) {
42
+ const base = `${(options.baseUrl ?? "").replace(/\/+$/, "")}/${(options.route ?? "/api/actions").replace(/^\/+|\/+$/g, "")}`;
43
+ const transport = options.fetch ?? nativeFetch;
44
+ const headersOf = async () => {
45
+ const headers = options.headers;
46
+ return typeof headers === "function" ? await headers() : headers;
47
+ };
48
+ const send = async (method, url, body) => {
49
+ const headers = await headersOf();
50
+ return transport(url, {
51
+ method,
52
+ ...body === void 0 ? {} : { body },
53
+ ...headers ? { headers } : {}
54
+ });
55
+ };
56
+ return {
57
+ call: (id, input) => send("POST", `${base}/${encodeURIComponent(id)}`, input ?? {}),
58
+ manifest: () => send("GET", base)
59
+ };
60
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The credential of an `Authorization: Bearer …` header value, or `null` when
3
+ * the header is absent, not a bearer scheme, or empty. Shared by the MCP and
4
+ * HTTP adapters so both read credentials the same way.
5
+ */
6
+ export declare function bearerOf(header: string | string[] | null | undefined): string | null;
@@ -0,0 +1,5 @@
1
+ const BEARER = /^Bearer\s+(\S.*)$/i;
2
+ export function bearerOf(header) {
3
+ const value = Array.isArray(header) ? header[0] : header;
4
+ return value?.match(BEARER)?.[1]?.trim() || null;
5
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Wire types of the HTTP action manifest. Types only: the client imports them
3
+ * without pulling any server code.
4
+ */
5
+ /** One action as `GET <route>` lists it. */
6
+ export interface ActionManifestEntry<Id extends string = string> {
7
+ id: Id;
8
+ /** Canonical name `app.<id>`. */
9
+ name: string;
10
+ description: string;
11
+ kind: "effect" | "query";
12
+ /** JSON Schema (draft-07, input side) of the action's input. */
13
+ inputSchema: Record<string, unknown>;
14
+ }
15
+ export interface ActionsManifest<Id extends string = string> {
16
+ actions: ActionManifestEntry<Id>[];
17
+ }
File without changes
@@ -0,0 +1,50 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type ServerAction } from "../utils/server-actions.js";
3
+ /** What the `authorize` callback receives and may change. */
4
+ export interface ActionsHttpAuthorizeDecision {
5
+ /** The host event (under Nitro an `H3Event`), or the Node request when the host passed none. */
6
+ event: unknown;
7
+ req: IncomingMessage;
8
+ /** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
9
+ bearer: string | null;
10
+ /** Defaults to `false`; both the manifest and every call answer 401 unless the callback sets it. */
11
+ allowed: boolean;
12
+ /** What the actions see as `ctx.principal`. */
13
+ principal?: unknown;
14
+ /** What the actions see as `ctx.deps`, e.g. injected service clients. */
15
+ deps?: unknown;
16
+ }
17
+ export interface ActionsHttpHandlerOptions {
18
+ /** Narrowed to the `user` audience by the handler itself; the rest answer 404. */
19
+ actions: readonly ServerAction[];
20
+ /** Mount path, e.g. `/api/actions`. The manifest is `GET <route>`, a call `POST <route>/<id>`. */
21
+ route: string;
22
+ /**
23
+ * Asked once per request, before any action id is looked up or any body is
24
+ * read. Set `decision.allowed = true` to let it in. An error thrown here
25
+ * denies. A host that authenticates by cookie MUST also check the request's
26
+ * origin here before allowing a `POST`: this adapter performs no CSRF defence.
27
+ */
28
+ authorize: (decision: ActionsHttpAuthorizeDecision) => void | Promise<void>;
29
+ }
30
+ /** Per-request inputs a host may supply instead of the handler deriving them from `req`. */
31
+ export interface ActionsHttpRequestInput {
32
+ /** Passed to `authorize` and to the actions as `ctx.event`. Defaults to `req`. */
33
+ event?: unknown;
34
+ /** The raw request body, for hosts whose framework already consumed the stream. Called only after authorization. */
35
+ readBody?: () => Promise<string | undefined>;
36
+ }
37
+ /**
38
+ * A Node `(req, res, input?)` handler for the HTTP projection of server actions.
39
+ *
40
+ * - `GET <route>`: `{ actions: [{ id, name, description, kind, inputSchema }] }` of the
41
+ * `user`-audience actions, `cache-control: no-store`.
42
+ * - `POST <route>/<id>`: the JSON body is the action's input; the response is its raw
43
+ * result. A failure is an H3-shaped error body (`statusCode`, `statusMessage`, `message`,
44
+ * `data.issues` for schema errors) with the outcome's status.
45
+ *
46
+ * Both routes deny by default (401) until `authorize` sets `decision.allowed`.
47
+ * Unknown and non-`user` actions answer 404, malformed JSON 400. Input parsing and
48
+ * the action's own `authorize` happen inside `action.run`, once, via `invokeAction`.
49
+ */
50
+ export declare function createActionsHttpHandler(options: ActionsHttpHandlerOptions): (req: IncomingMessage, res: ServerResponse, input?: ActionsHttpRequestInput) => Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { bearerOf } from "../../internal/bearer.js";
2
+ import { invokeAction } from "../utils/invoke-action.js";
3
+ import { forAudience } from "../utils/server-actions.js";
4
+ const MAX_BODY_BYTES = 1024 * 1024;
5
+ function errorBody(statusCode, message, data) {
6
+ return {
7
+ statusCode,
8
+ statusMessage: message,
9
+ message,
10
+ ...data === void 0 ? {} : { data }
11
+ };
12
+ }
13
+ function send(res, status, body, headers = {}) {
14
+ res.writeHead(status, {
15
+ "content-type": "application/json; charset=utf-8",
16
+ "cache-control": "no-store",
17
+ "x-content-type-options": "nosniff",
18
+ ...headers
19
+ });
20
+ res.end(JSON.stringify(body) ?? "null");
21
+ }
22
+ async function readStream(req) {
23
+ const chunks = [];
24
+ let size = 0;
25
+ for await (const chunk of req) {
26
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
27
+ size += buffer.length;
28
+ if (size > MAX_BODY_BYTES) throw Object.assign(new Error("body too large"), { tooLarge: true });
29
+ chunks.push(buffer);
30
+ }
31
+ return Buffer.concat(chunks).toString("utf8");
32
+ }
33
+ function actionIdOf(url, route) {
34
+ const pathname = new URL(url ?? "/", "http://localhost").pathname;
35
+ if (pathname !== route && !pathname.startsWith(`${route}/`)) return void 0;
36
+ const rest = pathname.slice(route.length).replace(/^\/+/, "");
37
+ if (rest === "") return null;
38
+ if (rest.includes("/")) return void 0;
39
+ try {
40
+ return decodeURIComponent(rest);
41
+ } catch {
42
+ return void 0;
43
+ }
44
+ }
45
+ function manifestOf(actions) {
46
+ return {
47
+ actions: actions.map(
48
+ (action) => ({
49
+ id: action.id,
50
+ name: action.name,
51
+ description: action.description,
52
+ kind: action.kind,
53
+ inputSchema: action.inputSchema
54
+ })
55
+ )
56
+ };
57
+ }
58
+ export function createActionsHttpHandler(options) {
59
+ const route = `/${options.route.replace(/^\/+|\/+$/g, "")}`;
60
+ const actions = forAudience(options.actions, "user");
61
+ const byId = /* @__PURE__ */ new Map();
62
+ for (const action of actions) {
63
+ const other = byId.get(action.id);
64
+ if (other) throw new Error(`Duplicate action id: ${action.id} (${other.name}, ${action.name})`);
65
+ byId.set(action.id, action);
66
+ }
67
+ return async (req, res, input = {}) => {
68
+ const id = actionIdOf(req.url, route);
69
+ if (id === void 0) {
70
+ return send(res, 404, errorBody(404, "Not found"));
71
+ }
72
+ const method = req.method ?? "GET";
73
+ const allowedMethod = id === null ? "GET" : "POST";
74
+ if (method !== allowedMethod && !(id === null && method === "HEAD")) {
75
+ return send(
76
+ res,
77
+ 405,
78
+ errorBody(405, `Use ${allowedMethod} here`),
79
+ { allow: allowedMethod }
80
+ );
81
+ }
82
+ const event = input.event ?? req;
83
+ const decision = {
84
+ event,
85
+ req,
86
+ bearer: bearerOf(req.headers.authorization),
87
+ allowed: false,
88
+ principal: void 0,
89
+ deps: void 0
90
+ };
91
+ try {
92
+ await options.authorize(decision);
93
+ } catch {
94
+ decision.allowed = false;
95
+ }
96
+ if (decision.allowed !== true) {
97
+ return send(res, 401, errorBody(401, "Invalid or missing credential"), {
98
+ "www-authenticate": "Bearer"
99
+ });
100
+ }
101
+ if (id === null) return send(res, 200, manifestOf(actions));
102
+ const action = byId.get(id);
103
+ if (!action) return send(res, 404, errorBody(404, `Unknown action: ${id}`));
104
+ let raw;
105
+ try {
106
+ raw = await (input.readBody ? input.readBody() : readStream(req));
107
+ } catch (error) {
108
+ const tooLarge = error?.tooLarge === true;
109
+ return send(res, tooLarge ? 413 : 400, errorBody(tooLarge ? 413 : 400, "Unreadable body"));
110
+ }
111
+ let body = {};
112
+ if (raw !== void 0 && raw.trim() !== "") {
113
+ try {
114
+ body = JSON.parse(raw);
115
+ } catch {
116
+ return send(res, 400, errorBody(400, "Malformed JSON body"));
117
+ }
118
+ }
119
+ const outcome = await invokeAction(action, body, {
120
+ principal: decision.principal,
121
+ event,
122
+ deps: decision.deps
123
+ });
124
+ if (outcome.ok) return send(res, 200, outcome.result);
125
+ return send(
126
+ res,
127
+ outcome.status,
128
+ errorBody(
129
+ outcome.status,
130
+ outcome.message,
131
+ outcome.issues === void 0 ? void 0 : { issues: outcome.issues }
132
+ )
133
+ );
134
+ };
135
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Explicit entry point for the HTTP projection of server actions, via the
3
+ * `./http` subpath. The Nuxt module mounts the routes itself when
4
+ * `forgeAppActions.http` is set; use these to serve actions from elsewhere.
5
+ */
6
+ export type { ActionManifestEntry, ActionsManifest } from "../../internal/manifest.js";
7
+ export * from "./create-handler.js";
8
+ export type { HttpAuthorizeDecision } from "./route.js";
@@ -0,0 +1 @@
1
+ export * from "./create-handler.js";
@@ -0,0 +1,16 @@
1
+ import type { H3Event } from "h3";
2
+ /** What the `forge-app-actions:http:authorize` hook receives and may change. */
3
+ export interface HttpAuthorizeDecision {
4
+ event: H3Event;
5
+ /** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
6
+ bearer: string | null;
7
+ /** Defaults to `false`; manifest and calls answer 401 unless the hook sets it. */
8
+ allowed: boolean;
9
+ /** What the actions see as `ctx.principal`. */
10
+ principal?: unknown;
11
+ /** What the actions see as `ctx.deps`, e.g. injected service clients. */
12
+ deps?: unknown;
13
+ }
14
+ /** `GET <route>` (manifest) and `POST <route>/:id` over the registered server actions. */
15
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
16
+ export default _default;
@@ -0,0 +1,28 @@
1
+ import { defineEventHandler, readRawBody } from "h3";
2
+ import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
3
+ import { serverActionRegistry } from "../utils/server-actions.js";
4
+ import { createActionsHttpHandler } from "./create-handler.js";
5
+ export default defineEventHandler(async (event) => {
6
+ const { http } = useRuntimeConfig(event).forgeAppActions;
7
+ await createActionsHttpHandler({
8
+ actions: serverActionRegistry.list(),
9
+ route: http.route,
10
+ authorize: async (inner) => {
11
+ const decision = {
12
+ event,
13
+ bearer: inner.bearer,
14
+ allowed: false,
15
+ principal: void 0,
16
+ deps: void 0
17
+ };
18
+ const { hooks } = useNitroApp();
19
+ await hooks.callHook("forge-app-actions:http:authorize", decision);
20
+ inner.allowed = decision.allowed === true;
21
+ inner.principal = decision.principal;
22
+ inner.deps = decision.deps;
23
+ }
24
+ })(event.node.req, event.node.res, {
25
+ event,
26
+ readBody: () => readRawBody(event, "utf8")
27
+ });
28
+ });
@@ -6,3 +6,4 @@
6
6
  export * from "./utils/capability-registry.js";
7
7
  export * from "./utils/client-capability-bridge.js";
8
8
  export * from "./utils/server-actions.js";
9
+ export * from "./utils/invoke-action.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./utils/capability-registry.js";
2
2
  export * from "./utils/client-capability-bridge.js";
3
3
  export * from "./utils/server-actions.js";
4
+ export * from "./utils/invoke-action.js";
@@ -1,21 +1,23 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { toolNameOf } from "../../internal/tool-name.js";
4
- import type { ServerAction, ServerActionContext } from "../utils/server-actions.js";
4
+ import type { ServerAction, ServerActionScope } from "../utils/server-actions.js";
5
5
  export { toolNameOf };
6
6
  export interface ActionsMcpServerOptions {
7
- actions: ServerAction[];
7
+ /** Served as given; narrow them with `forAudience(actions, "llm")` first to honor audiences. */
8
+ actions: readonly ServerAction[];
8
9
  serverInfo: {
9
10
  name: string;
10
11
  version: string;
11
12
  };
12
- /** `principal` and `event` handed to every action as `ctx`; `name` is set per call. */
13
- context: Omit<ServerActionContext, "name">;
13
+ /** `principal`, `event` and `deps` handed to every action as `ctx`; its `name` is derived per action. */
14
+ context: ServerActionScope;
14
15
  }
15
16
  /**
16
17
  * An MCP server whose tools are the given server actions, named
17
- * `toolNameOf(action.name)`. Every call runs the action with
18
- * `{ ...context, name: action.name }`.
18
+ * `toolNameOf(action.name)`. Every call goes through `invokeAction` with `context`;
19
+ * a failed call (invalid input, denied by the action's `authorize`, handler error)
20
+ * is an `isError` result carrying the outcome's message, never an HTTP error.
19
21
  */
20
22
  export declare function createActionsMcpServer(options: ActionsMcpServerOptions): Server;
21
23
  export type ActionsMcpHttpHandlerOptions = ActionsMcpServerOptions;
@@ -1,23 +1,11 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
- import * as z from "zod";
5
4
  import { toolNameOf } from "../../internal/tool-name.js";
5
+ import { invokeAction } from "../utils/invoke-action.js";
6
6
  export { toolNameOf };
7
7
  const text = (value) => [{ type: "text", text: value }];
8
8
  const failure = (message) => ({ isError: true, content: text(message) });
9
- function isZodError(error) {
10
- const candidate = error;
11
- return typeof candidate?.name === "string" && /^\$?ZodError$/.test(candidate.name) && Array.isArray(candidate.issues);
12
- }
13
- function messageOf(error) {
14
- if (error && typeof error === "object") {
15
- const { statusMessage, message } = error;
16
- if (typeof statusMessage === "string" && statusMessage) return statusMessage;
17
- if (typeof message === "string") return message;
18
- }
19
- return String(error);
20
- }
21
9
  export function createActionsMcpServer(options) {
22
10
  const byTool = /* @__PURE__ */ new Map();
23
11
  for (const action of options.actions) {
@@ -41,20 +29,14 @@ export function createActionsMcpServer(options) {
41
29
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
42
30
  const action = byTool.get(params.name);
43
31
  if (!action) return failure(`unknown tool: ${params.name}`);
44
- try {
45
- const result = await action.run(params.arguments ?? {}, {
46
- ...options.context,
47
- name: action.name
48
- });
49
- const content = text(JSON.stringify(result) ?? "null");
50
- if (result !== null && typeof result === "object" && !Array.isArray(result)) {
51
- return { content, structuredContent: result };
52
- }
53
- return { content };
54
- } catch (error) {
55
- if (isZodError(error)) return failure(`invalid input: ${z.prettifyError(error)}`);
56
- return failure(messageOf(error));
32
+ const outcome = await invokeAction(action, params.arguments ?? {}, options.context);
33
+ if (!outcome.ok) return failure(outcome.message);
34
+ const { result } = outcome;
35
+ const content = text(JSON.stringify(result) ?? "null");
36
+ if (result !== null && typeof result === "object" && !Array.isArray(result)) {
37
+ return { content, structuredContent: result };
57
38
  }
39
+ return { content };
58
40
  });
59
41
  return server;
60
42
  }
@@ -8,6 +8,8 @@ export interface McpAuthorizeDecision {
8
8
  allowed: boolean;
9
9
  /** What the actions see as `ctx.principal`. */
10
10
  principal?: unknown;
11
+ /** What the actions see as `ctx.deps`, e.g. injected service clients. */
12
+ deps?: unknown;
11
13
  }
12
14
  /** The Streamable HTTP MCP endpoint over the registered server actions. */
13
15
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<unknown>>;
@@ -6,13 +6,9 @@ import {
6
6
  setResponseStatus
7
7
  } from "h3";
8
8
  import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
9
- import { serverActionRegistry } from "../utils/server-actions.js";
9
+ import { bearerOf } from "../../internal/bearer.js";
10
+ import { forAudience, serverActionRegistry } from "../utils/server-actions.js";
10
11
  import { createActionsMcpHttpHandler } from "./create-handler.js";
11
- const BEARER = /^Bearer\s+(\S.*)$/i;
12
- function bearerOf(event) {
13
- const header = getRequestHeader(event, "authorization");
14
- return header?.match(BEARER)?.[1]?.trim() || null;
15
- }
16
12
  export default defineEventHandler(async (event) => {
17
13
  const reply = (status, body, headers = {}) => {
18
14
  setResponseStatus(event, status);
@@ -24,9 +20,10 @@ export default defineEventHandler(async (event) => {
24
20
  }
25
21
  const decision = {
26
22
  event,
27
- bearer: bearerOf(event),
23
+ bearer: bearerOf(getRequestHeader(event, "authorization")),
28
24
  allowed: false,
29
- principal: void 0
25
+ principal: void 0,
26
+ deps: void 0
30
27
  };
31
28
  try {
32
29
  const { hooks } = useNitroApp();
@@ -53,8 +50,8 @@ export default defineEventHandler(async (event) => {
53
50
  }
54
51
  const { mcp } = useRuntimeConfig(event).forgeAppActions;
55
52
  await createActionsMcpHttpHandler({
56
- actions: serverActionRegistry.list(),
53
+ actions: forAudience(serverActionRegistry.list(), "llm"),
57
54
  serverInfo: { name: mcp.name, version: mcp.version },
58
- context: { principal: decision.principal, event }
55
+ context: { principal: decision.principal, event, deps: decision.deps }
59
56
  })(event.node.req, event.node.res, parsed);
60
57
  });
@@ -0,0 +1,19 @@
1
+ import type { ServerAction, ServerActionScope } from "./server-actions.js";
2
+ /** What a projection maps to its own error shape: `status` is HTTP-style, whatever the transport. */
3
+ export type ActionOutcome = {
4
+ ok: true;
5
+ result: unknown;
6
+ } | {
7
+ ok: false;
8
+ status: number;
9
+ message: string;
10
+ issues?: unknown[];
11
+ };
12
+ /**
13
+ * Runs `action` and classifies the result: schema error → 400 with
14
+ * `invalid input: …` and its `issues`; an error with a numeric `statusCode` →
15
+ * that status; anything else → 500. Calls `action.run` only: parsing and the
16
+ * action's `authorize` happen there, exactly once, so this never reparses or
17
+ * authorizes a second time.
18
+ */
19
+ export declare function invokeAction(action: ServerAction, input: unknown, ctx: ServerActionScope): Promise<ActionOutcome>;
@@ -0,0 +1,32 @@
1
+ import * as z from "zod";
2
+ function isZodError(error) {
3
+ const candidate = error;
4
+ return typeof candidate?.name === "string" && /^\$?ZodError$/.test(candidate.name) && Array.isArray(candidate.issues);
5
+ }
6
+ function messageOf(error) {
7
+ if (error && typeof error === "object") {
8
+ const { statusMessage, message } = error;
9
+ if (typeof statusMessage === "string" && statusMessage) return statusMessage;
10
+ if (typeof message === "string") return message;
11
+ }
12
+ return String(error);
13
+ }
14
+ function statusOf(error) {
15
+ const { statusCode } = error ?? {};
16
+ return typeof statusCode === "number" && Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600 ? statusCode : void 0;
17
+ }
18
+ export async function invokeAction(action, input, ctx) {
19
+ try {
20
+ return { ok: true, result: await action.run(input, ctx) };
21
+ } catch (error) {
22
+ if (isZodError(error)) {
23
+ return {
24
+ ok: false,
25
+ status: 400,
26
+ message: `invalid input: ${z.prettifyError(error)}`,
27
+ issues: error.issues
28
+ };
29
+ }
30
+ return { ok: false, status: statusOf(error) ?? 500, message: messageOf(error) };
31
+ }
32
+ }
@@ -1,39 +1,84 @@
1
1
  import * as z from "zod";
2
- export interface ServerActionContext<P = unknown> {
3
- /** Capability name as invoked, e.g. `app.trainings.create`. */
2
+ /** Who may be offered an action: people/CLIs (`user`) and/or models (`llm`). Exposure control, not authorization. */
3
+ export type ServerActionAudience = "user" | "llm";
4
+ /**
5
+ * What an action sees. The core fixes no principal shape: `P` is whatever the
6
+ * app's transport hook resolved, `D` whatever dependencies it injects.
7
+ */
8
+ export interface ServerActionContext<P = unknown, D = unknown> {
9
+ /** Canonical capability name `app.<id>`, derived by `run`; never taken from the caller. */
4
10
  name: string;
5
- /** What the `forge-app-actions:mcp:authorize` hook set as `principal`. */
11
+ /** What the transport's authorize hook set as `principal` (MCP: `forge-app-actions:mcp:authorize`). */
6
12
  principal: P;
7
13
  /** Host event (under Nitro an `H3Event`); apps pass it on to their server utils. */
8
14
  event: unknown;
15
+ /** What the transport's authorize hook set as `deps`, e.g. injected service clients. */
16
+ deps: D;
9
17
  }
10
- export interface DefineServerActionArgs<T extends z.ZodType, P = unknown> {
18
+ /**
19
+ * The caller-supplied part of a context. `run` derives `name` itself and
20
+ * ignores one passed here.
21
+ */
22
+ export type ServerActionScope<P = unknown, D = unknown> = Omit<ServerActionContext<P, D>, "name">;
23
+ export interface DefineServerActionArgs<Id extends string, T extends z.ZodType, P = unknown, D = unknown, R = unknown> {
11
24
  /** Without the `app.` prefix, e.g. `trainings.create`; the name becomes `app.<id>`. An id starting with `app.` throws. */
12
- id: string;
25
+ id: Id;
13
26
  /** Description the model reads. Required; empty throws. */
14
27
  description: string;
15
28
  /** Zod schema of the input. Defaults to `z.object({})`. */
16
29
  input?: T;
17
30
  /** Documentation only, as with `defineForgeAction`. Defaults to `"effect"`. */
18
31
  kind?: "effect" | "query";
19
- run: (input: z.infer<T>, ctx: ServerActionContext<P>) => unknown | Promise<unknown>;
32
+ /** Who is offered the action. Defaults to both; an empty array throws. */
33
+ audience?: readonly ServerActionAudience[];
34
+ /**
35
+ * Runs after the input parsed and before the handler, with the canonical
36
+ * context. Throw to deny (an error with a numeric `statusCode` keeps it);
37
+ * the handler then does not run. Read the current authority here, not from a
38
+ * value captured earlier.
39
+ */
40
+ authorize?: (input: z.infer<T>, ctx: ServerActionContext<P, D>) => void | Promise<void>;
41
+ run: (input: z.infer<T>, ctx: ServerActionContext<P, D>) => R;
20
42
  }
21
- export interface ServerAction {
22
- id: string;
43
+ export interface ServerAction<Id extends string = string, I = unknown, O = unknown> {
44
+ id: Id;
23
45
  name: string;
24
46
  description: string;
25
47
  kind: "effect" | "query";
48
+ readonly audience: readonly ServerActionAudience[];
26
49
  readonly inputZod: z.ZodType;
27
50
  /** `z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })` without `$schema`. */
28
51
  inputSchema: Record<string, unknown>;
29
- /** Parses `input` through `inputZod` (a `ZodError` propagates), then calls the handler. */
30
- run(input: unknown, ctx: ServerActionContext): Promise<unknown>;
52
+ /** Type-level only: never set at runtime. Read it through `ActionInput` / `ActionOutput`. */
53
+ readonly types?: {
54
+ input: I;
55
+ output: O;
56
+ };
57
+ /**
58
+ * Parses `input` through `inputZod` once (a `ZodError` propagates), awaits
59
+ * `authorize` (a rejection propagates and nothing ran), then calls the handler.
60
+ * The context's `name` is always `app.<id>`; a `name` in `ctx` is ignored.
61
+ */
62
+ run(input: unknown, ctx: ServerActionScope & {
63
+ name?: string;
64
+ }): Promise<unknown>;
31
65
  }
32
- export declare function defineServerAction<T extends z.ZodType = z.ZodObject<Record<string, never>>, P = unknown>(args: DefineServerActionArgs<T, P>): ServerAction;
66
+ /** The input type an action accepts (`z.input` of its schema). */
67
+ export type ActionInput<A> = A extends ServerAction<string, infer I, unknown> ? I : never;
68
+ /** The output type an action resolves to. */
69
+ export type ActionOutput<A> = A extends ServerAction<string, unknown, infer O> ? O : never;
70
+ export declare function defineServerAction<const Id extends string, T extends z.ZodType = z.ZodObject<Record<string, never>>, P = unknown, D = unknown, R = unknown>(args: DefineServerActionArgs<Id, T, P, D, R>): ServerAction<Id, z.input<T>, Awaited<R>>;
71
+ /**
72
+ * `defineServerAction` with the app's principal and deps types bound once, so
73
+ * handlers and `authorize` callbacks are typed without annotating every definition.
74
+ */
75
+ export declare function createServerActionDefiner<P = unknown, D = unknown>(): <const Id extends string, T extends z.ZodType = z.ZodObject<Record<string, never>>, R = unknown>(args: DefineServerActionArgs<Id, T, P, D, R>) => ServerAction<Id, z.input<T>, Awaited<R>>;
76
+ /** The actions offered to `audience`, in order. Element types are kept, so a typed catalogue stays typed. */
77
+ export declare function forAudience<A extends ServerAction>(actions: readonly A[], audience: ServerActionAudience): A[];
33
78
  export declare class ServerActionRegistry {
34
79
  private readonly actions;
35
80
  register(action: ServerAction): void;
36
- registerAll(actions: ServerAction[]): void;
81
+ registerAll(actions: readonly ServerAction[]): void;
37
82
  /** In registration order. */
38
83
  list(): ServerAction[];
39
84
  get(name: string): ServerAction | undefined;