@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
@@ -8,17 +8,18 @@ import type { IncomingMessage, ServerResponse } from "node:http";
8
8
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
- import * as z from "zod";
12
11
  import { toolNameOf } from "../../internal/tool-name";
13
- import type { ServerAction, ServerActionContext } from "../utils/server-actions";
12
+ import { invokeAction } from "../utils/invoke-action";
13
+ import type { ServerAction, ServerActionScope } from "../utils/server-actions";
14
14
 
15
15
  export { toolNameOf };
16
16
 
17
17
  export interface ActionsMcpServerOptions {
18
- actions: ServerAction[];
18
+ /** Served as given; narrow them with `forAudience(actions, "llm")` first to honor audiences. */
19
+ actions: readonly ServerAction[];
19
20
  serverInfo: { name: string; version: string };
20
- /** `principal` and `event` handed to every action as `ctx`; `name` is set per call. */
21
- context: Omit<ServerActionContext, "name">;
21
+ /** `principal`, `event` and `deps` handed to every action as `ctx`; its `name` is derived per action. */
22
+ context: ServerActionScope;
22
23
  }
23
24
 
24
25
  type ToolInputSchema = { type: "object"; [key: string]: unknown };
@@ -27,33 +28,11 @@ const text = (value: string) => [{ type: "text" as const, text: value }];
27
28
 
28
29
  const failure = (message: string) => ({ isError: true as const, content: text(message) });
29
30
 
30
- /**
31
- * A ZodError by shape, not `instanceof`: the schema belongs to the app, whose
32
- * zod instance may differ from the one bundled with this package.
33
- */
34
- function isZodError(error: unknown): error is z.core.$ZodError {
35
- const candidate = error as { name?: unknown; issues?: unknown } | null;
36
- return (
37
- typeof candidate?.name === "string" &&
38
- /^\$?ZodError$/.test(candidate.name) &&
39
- Array.isArray(candidate.issues)
40
- );
41
- }
42
-
43
- /** The message to show the model: an H3 error carries it in `statusMessage`. */
44
- function messageOf(error: unknown): string {
45
- if (error && typeof error === "object") {
46
- const { statusMessage, message } = error as { statusMessage?: unknown; message?: unknown };
47
- if (typeof statusMessage === "string" && statusMessage) return statusMessage;
48
- if (typeof message === "string") return message;
49
- }
50
- return String(error);
51
- }
52
-
53
31
  /**
54
32
  * An MCP server whose tools are the given server actions, named
55
- * `toolNameOf(action.name)`. Every call runs the action with
56
- * `{ ...context, name: action.name }`.
33
+ * `toolNameOf(action.name)`. Every call goes through `invokeAction` with `context`;
34
+ * a failed call (invalid input, denied by the action's `authorize`, handler error)
35
+ * is an `isError` result carrying the outcome's message, never an HTTP error.
57
36
  */
58
37
  export function createActionsMcpServer(options: ActionsMcpServerOptions): Server {
59
38
  const byTool = new Map<string, ServerAction>();
@@ -81,20 +60,14 @@ export function createActionsMcpServer(options: ActionsMcpServerOptions): Server
81
60
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
82
61
  const action = byTool.get(params.name);
83
62
  if (!action) return failure(`unknown tool: ${params.name}`);
84
- try {
85
- const result = await action.run(params.arguments ?? {}, {
86
- ...options.context,
87
- name: action.name,
88
- });
89
- const content = text(JSON.stringify(result) ?? "null");
90
- if (result !== null && typeof result === "object" && !Array.isArray(result)) {
91
- return { content, structuredContent: result as Record<string, unknown> };
92
- }
93
- return { content };
94
- } catch (error) {
95
- if (isZodError(error)) return failure(`invalid input: ${z.prettifyError(error)}`);
96
- return failure(messageOf(error));
63
+ const outcome = await invokeAction(action, params.arguments ?? {}, options.context);
64
+ if (!outcome.ok) return failure(outcome.message);
65
+ const { result } = outcome;
66
+ const content = text(JSON.stringify(result) ?? "null");
67
+ if (result !== null && typeof result === "object" && !Array.isArray(result)) {
68
+ return { content, structuredContent: result as Record<string, unknown> };
97
69
  }
70
+ return { content };
98
71
  });
99
72
 
100
73
  return server;
@@ -12,7 +12,8 @@ import {
12
12
  setResponseStatus,
13
13
  } from "h3";
14
14
  import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
15
- import { serverActionRegistry } from "../utils/server-actions";
15
+ import { bearerOf } from "../../internal/bearer";
16
+ import { forAudience, serverActionRegistry } from "../utils/server-actions";
16
17
  import { createActionsMcpHttpHandler } from "./create-handler";
17
18
 
18
19
  /** What the `forge-app-actions:mcp:authorize` hook receives and may change. */
@@ -24,13 +25,8 @@ export interface McpAuthorizeDecision {
24
25
  allowed: boolean;
25
26
  /** What the actions see as `ctx.principal`. */
26
27
  principal?: unknown;
27
- }
28
-
29
- const BEARER = /^Bearer\s+(\S.*)$/i;
30
-
31
- function bearerOf(event: H3Event): string | null {
32
- const header = getRequestHeader(event, "authorization");
33
- return header?.match(BEARER)?.[1]?.trim() || null;
28
+ /** What the actions see as `ctx.deps`, e.g. injected service clients. */
29
+ deps?: unknown;
34
30
  }
35
31
 
36
32
  /** The Streamable HTTP MCP endpoint over the registered server actions. */
@@ -47,9 +43,10 @@ export default defineEventHandler(async (event) => {
47
43
 
48
44
  const decision: McpAuthorizeDecision = {
49
45
  event,
50
- bearer: bearerOf(event),
46
+ bearer: bearerOf(getRequestHeader(event, "authorization")),
51
47
  allowed: false,
52
48
  principal: undefined,
49
+ deps: undefined,
53
50
  };
54
51
  try {
55
52
  // The hook's typing comes from the module's type template, which is absent
@@ -86,8 +83,8 @@ export default defineEventHandler(async (event) => {
86
83
  };
87
84
  // The handler writes the response itself; h3 sees `res.writableEnded` and sends nothing more.
88
85
  await createActionsMcpHttpHandler({
89
- actions: serverActionRegistry.list(),
86
+ actions: forAudience(serverActionRegistry.list(), "llm"),
90
87
  serverInfo: { name: mcp.name, version: mcp.version },
91
- context: { principal: decision.principal, event },
88
+ context: { principal: decision.principal, event, deps: decision.deps },
92
89
  })(event.node.req, event.node.res, parsed);
93
90
  });
@@ -0,0 +1,71 @@
1
+ // server/utils/invoke-action.ts
2
+ //
3
+ // Directly in `runtime/server/utils/` for the same reason as `server-actions.ts`.
4
+ // Transport-neutral: no H3, MCP or AI SDK import, so every projection (MCP, HTTP,
5
+ // chat tools) classifies failures the same way.
6
+
7
+ import * as z from "zod";
8
+ import type { ServerAction, ServerActionScope } from "./server-actions";
9
+
10
+ /** What a projection maps to its own error shape: `status` is HTTP-style, whatever the transport. */
11
+ export type ActionOutcome =
12
+ | { ok: true; result: unknown }
13
+ | { ok: false; status: number; message: string; issues?: unknown[] };
14
+
15
+ /**
16
+ * A ZodError by shape, not `instanceof`: the schema belongs to the app, whose
17
+ * zod instance may differ from the one bundled with this package.
18
+ */
19
+ function isZodError(error: unknown): error is z.core.$ZodError {
20
+ const candidate = error as { name?: unknown; issues?: unknown } | null;
21
+ return (
22
+ typeof candidate?.name === "string" &&
23
+ /^\$?ZodError$/.test(candidate.name) &&
24
+ Array.isArray(candidate.issues)
25
+ );
26
+ }
27
+
28
+ /** The message to show the caller: an H3 error carries it in `statusMessage`. */
29
+ function messageOf(error: unknown): string {
30
+ if (error && typeof error === "object") {
31
+ const { statusMessage, message } = error as { statusMessage?: unknown; message?: unknown };
32
+ if (typeof statusMessage === "string" && statusMessage) return statusMessage;
33
+ if (typeof message === "string") return message;
34
+ }
35
+ return String(error);
36
+ }
37
+
38
+ /** An explicit HTTP-style status on the error (H3 errors carry `statusCode`), if it is a valid one. */
39
+ function statusOf(error: unknown): number | undefined {
40
+ const { statusCode } = (error ?? {}) as { statusCode?: unknown };
41
+ return typeof statusCode === "number" && Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600
42
+ ? statusCode
43
+ : undefined;
44
+ }
45
+
46
+ /**
47
+ * Runs `action` and classifies the result: schema error → 400 with
48
+ * `invalid input: …` and its `issues`; an error with a numeric `statusCode` →
49
+ * that status; anything else → 500. Calls `action.run` only: parsing and the
50
+ * action's `authorize` happen there, exactly once, so this never reparses or
51
+ * authorizes a second time.
52
+ */
53
+ export async function invokeAction(
54
+ action: ServerAction,
55
+ input: unknown,
56
+ ctx: ServerActionScope,
57
+ ): Promise<ActionOutcome> {
58
+ try {
59
+ return { ok: true, result: await action.run(input, ctx) };
60
+ } catch (error) {
61
+ if (isZodError(error)) {
62
+ return {
63
+ ok: false,
64
+ status: 400,
65
+ message: `invalid input: ${z.prettifyError(error)}`,
66
+ issues: error.issues,
67
+ };
68
+ }
69
+ return { ok: false, status: statusOf(error) ?? 500, message: messageOf(error) };
70
+ }
71
+ }
@@ -11,39 +11,84 @@
11
11
 
12
12
  import * as z from "zod";
13
13
 
14
- export interface ServerActionContext<P = unknown> {
15
- /** Capability name as invoked, e.g. `app.trainings.create`. */
14
+ /** Who may be offered an action: people/CLIs (`user`) and/or models (`llm`). Exposure control, not authorization. */
15
+ export type ServerActionAudience = "user" | "llm";
16
+
17
+ const AUDIENCES: readonly ServerActionAudience[] = ["user", "llm"];
18
+
19
+ /**
20
+ * What an action sees. The core fixes no principal shape: `P` is whatever the
21
+ * app's transport hook resolved, `D` whatever dependencies it injects.
22
+ */
23
+ export interface ServerActionContext<P = unknown, D = unknown> {
24
+ /** Canonical capability name `app.<id>`, derived by `run`; never taken from the caller. */
16
25
  name: string;
17
- /** What the `forge-app-actions:mcp:authorize` hook set as `principal`. */
26
+ /** What the transport's authorize hook set as `principal` (MCP: `forge-app-actions:mcp:authorize`). */
18
27
  principal: P;
19
28
  /** Host event (under Nitro an `H3Event`); apps pass it on to their server utils. */
20
29
  event: unknown;
30
+ /** What the transport's authorize hook set as `deps`, e.g. injected service clients. */
31
+ deps: D;
21
32
  }
22
33
 
23
- export interface DefineServerActionArgs<T extends z.ZodType, P = unknown> {
34
+ /**
35
+ * The caller-supplied part of a context. `run` derives `name` itself and
36
+ * ignores one passed here.
37
+ */
38
+ export type ServerActionScope<P = unknown, D = unknown> = Omit<ServerActionContext<P, D>, "name">;
39
+
40
+ export interface DefineServerActionArgs<
41
+ Id extends string,
42
+ T extends z.ZodType,
43
+ P = unknown,
44
+ D = unknown,
45
+ R = unknown,
46
+ > {
24
47
  /** Without the `app.` prefix, e.g. `trainings.create`; the name becomes `app.<id>`. An id starting with `app.` throws. */
25
- id: string;
48
+ id: Id;
26
49
  /** Description the model reads. Required; empty throws. */
27
50
  description: string;
28
51
  /** Zod schema of the input. Defaults to `z.object({})`. */
29
52
  input?: T;
30
53
  /** Documentation only, as with `defineForgeAction`. Defaults to `"effect"`. */
31
54
  kind?: "effect" | "query";
32
- run: (input: z.infer<T>, ctx: ServerActionContext<P>) => unknown | Promise<unknown>;
55
+ /** Who is offered the action. Defaults to both; an empty array throws. */
56
+ audience?: readonly ServerActionAudience[];
57
+ /**
58
+ * Runs after the input parsed and before the handler, with the canonical
59
+ * context. Throw to deny (an error with a numeric `statusCode` keeps it);
60
+ * the handler then does not run. Read the current authority here, not from a
61
+ * value captured earlier.
62
+ */
63
+ authorize?: (input: z.infer<T>, ctx: ServerActionContext<P, D>) => void | Promise<void>;
64
+ run: (input: z.infer<T>, ctx: ServerActionContext<P, D>) => R;
33
65
  }
34
66
 
35
- export interface ServerAction {
36
- id: string;
67
+ export interface ServerAction<Id extends string = string, I = unknown, O = unknown> {
68
+ id: Id;
37
69
  name: string;
38
70
  description: string;
39
71
  kind: "effect" | "query";
72
+ readonly audience: readonly ServerActionAudience[];
40
73
  readonly inputZod: z.ZodType;
41
74
  /** `z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })` without `$schema`. */
42
75
  inputSchema: Record<string, unknown>;
43
- /** Parses `input` through `inputZod` (a `ZodError` propagates), then calls the handler. */
44
- run(input: unknown, ctx: ServerActionContext): Promise<unknown>;
76
+ /** Type-level only: never set at runtime. Read it through `ActionInput` / `ActionOutput`. */
77
+ readonly types?: { input: I; output: O };
78
+ /**
79
+ * Parses `input` through `inputZod` once (a `ZodError` propagates), awaits
80
+ * `authorize` (a rejection propagates and nothing ran), then calls the handler.
81
+ * The context's `name` is always `app.<id>`; a `name` in `ctx` is ignored.
82
+ */
83
+ run(input: unknown, ctx: ServerActionScope & { name?: string }): Promise<unknown>;
45
84
  }
46
85
 
86
+ /** The input type an action accepts (`z.input` of its schema). */
87
+ export type ActionInput<A> = A extends ServerAction<string, infer I, unknown> ? I : never;
88
+
89
+ /** The output type an action resolves to. */
90
+ export type ActionOutput<A> = A extends ServerAction<string, unknown, infer O> ? O : never;
91
+
47
92
  /** The derived JSON Schema without its top-level `$schema` draft marker; everything else stays. */
48
93
  function withoutSchemaKey(schema: Record<string, unknown>): Record<string, unknown> {
49
94
  const { $schema: _draft, ...rest } = schema;
@@ -51,9 +96,12 @@ function withoutSchemaKey(schema: Record<string, unknown>): Record<string, unkno
51
96
  }
52
97
 
53
98
  export function defineServerAction<
99
+ const Id extends string,
54
100
  T extends z.ZodType = z.ZodObject<Record<string, never>>,
55
101
  P = unknown,
56
- >(args: DefineServerActionArgs<T, P>): ServerAction {
102
+ D = unknown,
103
+ R = unknown,
104
+ >(args: DefineServerActionArgs<Id, T, P, D, R>): ServerAction<Id, z.input<T>, Awaited<R>> {
57
105
  const { id } = args;
58
106
  if (!id || id.trim().length === 0) {
59
107
  throw new Error("defineServerAction: an action needs a non-empty id.");
@@ -69,26 +117,59 @@ export function defineServerAction<
69
117
  `defineServerAction("${id}"): a server action must declare a description — the model reads it.`,
70
118
  );
71
119
  }
120
+ const requested = args.audience ?? AUDIENCES;
121
+ if (requested.length === 0 || requested.some((entry) => !AUDIENCES.includes(entry))) {
122
+ throw new Error(
123
+ `defineServerAction("${id}"): audience must be a non-empty subset of ${JSON.stringify(AUDIENCES)}.`,
124
+ );
125
+ }
126
+ const audience = Object.freeze([...new Set(requested)]);
72
127
 
73
128
  const inputZod: z.ZodType = args.input ?? z.object({});
74
129
  const inputSchema = withoutSchemaKey(
75
130
  z.toJSONSchema(inputZod, { target: "draft-07", io: "input" }) as Record<string, unknown>,
76
131
  );
132
+ const name = `app.${id}`;
77
133
 
78
134
  return {
79
135
  id,
80
- name: `app.${id}`,
136
+ name,
81
137
  description: args.description,
82
138
  kind: args.kind ?? "effect",
139
+ audience,
83
140
  inputZod,
84
141
  inputSchema,
85
142
  async run(input, ctx) {
86
143
  const parsed = inputZod.parse(input) as z.infer<T>;
87
- return await args.run(parsed, ctx as ServerActionContext<P>);
144
+ const context = { ...ctx, name } as ServerActionContext<P, D>;
145
+ await args.authorize?.(parsed, context);
146
+ return await args.run(parsed, context);
88
147
  },
89
148
  };
90
149
  }
91
150
 
151
+ /**
152
+ * `defineServerAction` with the app's principal and deps types bound once, so
153
+ * handlers and `authorize` callbacks are typed without annotating every definition.
154
+ */
155
+ export function createServerActionDefiner<P = unknown, D = unknown>() {
156
+ return <
157
+ const Id extends string,
158
+ T extends z.ZodType = z.ZodObject<Record<string, never>>,
159
+ R = unknown,
160
+ >(
161
+ args: DefineServerActionArgs<Id, T, P, D, R>,
162
+ ): ServerAction<Id, z.input<T>, Awaited<R>> => defineServerAction<Id, T, P, D, R>(args);
163
+ }
164
+
165
+ /** The actions offered to `audience`, in order. Element types are kept, so a typed catalogue stays typed. */
166
+ export function forAudience<A extends ServerAction>(
167
+ actions: readonly A[],
168
+ audience: ServerActionAudience,
169
+ ): A[] {
170
+ return actions.filter((action) => action.audience.includes(audience));
171
+ }
172
+
92
173
  export class ServerActionRegistry {
93
174
  private readonly actions = new Map<string, ServerAction>();
94
175
 
@@ -99,7 +180,7 @@ export class ServerActionRegistry {
99
180
  this.actions.set(action.name, action);
100
181
  }
101
182
 
102
- registerAll(actions: ServerAction[]): void {
183
+ registerAll(actions: readonly ServerAction[]): void {
103
184
  for (const action of actions) this.register(action);
104
185
  }
105
186