@skaile/forge-app-actions 0.2.1 → 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 (51) hide show
  1. package/README.md +155 -2
  2. package/bin/forge-actions.mjs +274 -0
  3. package/dist/module.d.mts +24 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +78 -1
  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/chat/index.d.ts +2 -6
  9. package/dist/runtime/chat/index.js +2 -4
  10. package/dist/runtime/client/index.d.ts +57 -0
  11. package/dist/runtime/client/index.js +60 -0
  12. package/dist/runtime/internal/bearer.d.ts +6 -0
  13. package/dist/runtime/internal/bearer.js +5 -0
  14. package/dist/runtime/internal/manifest.d.ts +17 -0
  15. package/dist/runtime/internal/manifest.js +0 -0
  16. package/dist/runtime/internal/tool-name.d.ts +6 -0
  17. package/dist/runtime/internal/tool-name.js +4 -0
  18. package/dist/runtime/server/http/create-handler.d.ts +50 -0
  19. package/dist/runtime/server/http/create-handler.js +135 -0
  20. package/dist/runtime/server/http/index.d.ts +8 -0
  21. package/dist/runtime/server/http/index.js +1 -0
  22. package/dist/runtime/server/http/route.d.ts +16 -0
  23. package/dist/runtime/server/http/route.js +28 -0
  24. package/dist/runtime/server/index.d.ts +2 -0
  25. package/dist/runtime/server/index.js +2 -0
  26. package/dist/runtime/server/mcp/create-handler.d.ts +31 -0
  27. package/dist/runtime/server/mcp/create-handler.js +80 -0
  28. package/dist/runtime/server/mcp/index.d.ts +7 -0
  29. package/dist/runtime/server/mcp/index.js +1 -0
  30. package/dist/runtime/server/mcp/route.d.ts +16 -0
  31. package/dist/runtime/server/mcp/route.js +57 -0
  32. package/dist/runtime/server/utils/invoke-action.d.ts +19 -0
  33. package/dist/runtime/server/utils/invoke-action.js +32 -0
  34. package/dist/runtime/server/utils/server-actions.d.ts +87 -0
  35. package/dist/runtime/server/utils/server-actions.js +75 -0
  36. package/package.json +33 -2
  37. package/src/runtime/chat/ai-sdk.ts +53 -0
  38. package/src/runtime/chat/index.ts +2 -10
  39. package/src/runtime/client/index.ts +131 -0
  40. package/src/runtime/internal/bearer.ts +11 -0
  41. package/src/runtime/internal/manifest.ts +19 -0
  42. package/src/runtime/internal/tool-name.ts +10 -0
  43. package/src/runtime/server/http/create-handler.ts +220 -0
  44. package/src/runtime/server/http/index.ts +9 -0
  45. package/src/runtime/server/http/route.ts +54 -0
  46. package/src/runtime/server/index.ts +2 -0
  47. package/src/runtime/server/mcp/create-handler.ts +134 -0
  48. package/src/runtime/server/mcp/index.ts +8 -0
  49. package/src/runtime/server/mcp/route.ts +90 -0
  50. package/src/runtime/server/utils/invoke-action.ts +71 -0
  51. package/src/runtime/server/utils/server-actions.ts +198 -0
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ import * as z from "zod";
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. */
10
+ name: string;
11
+ /** What the transport's authorize hook set as `principal` (MCP: `forge-app-actions:mcp:authorize`). */
12
+ principal: P;
13
+ /** Host event (under Nitro an `H3Event`); apps pass it on to their server utils. */
14
+ event: unknown;
15
+ /** What the transport's authorize hook set as `deps`, e.g. injected service clients. */
16
+ deps: D;
17
+ }
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> {
24
+ /** Without the `app.` prefix, e.g. `trainings.create`; the name becomes `app.<id>`. An id starting with `app.` throws. */
25
+ id: Id;
26
+ /** Description the model reads. Required; empty throws. */
27
+ description: string;
28
+ /** Zod schema of the input. Defaults to `z.object({})`. */
29
+ input?: T;
30
+ /** Documentation only, as with `defineForgeAction`. Defaults to `"effect"`. */
31
+ kind?: "effect" | "query";
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;
42
+ }
43
+ export interface ServerAction<Id extends string = string, I = unknown, O = unknown> {
44
+ id: Id;
45
+ name: string;
46
+ description: string;
47
+ kind: "effect" | "query";
48
+ readonly audience: readonly ServerActionAudience[];
49
+ readonly inputZod: z.ZodType;
50
+ /** `z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })` without `$schema`. */
51
+ inputSchema: Record<string, 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>;
65
+ }
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[];
78
+ export declare class ServerActionRegistry {
79
+ private readonly actions;
80
+ register(action: ServerAction): void;
81
+ registerAll(actions: readonly ServerAction[]): void;
82
+ /** In registration order. */
83
+ list(): ServerAction[];
84
+ get(name: string): ServerAction | undefined;
85
+ }
86
+ /** Process-wide singleton, like `capabilityRegistry`. */
87
+ export declare const serverActionRegistry: ServerActionRegistry;
@@ -0,0 +1,75 @@
1
+ import * as z from "zod";
2
+ const AUDIENCES = ["user", "llm"];
3
+ function withoutSchemaKey(schema) {
4
+ const { $schema: _draft, ...rest } = schema;
5
+ return rest;
6
+ }
7
+ export function defineServerAction(args) {
8
+ const { id } = args;
9
+ if (!id || id.trim().length === 0) {
10
+ throw new Error("defineServerAction: an action needs a non-empty id.");
11
+ }
12
+ if (id.startsWith("app.")) {
13
+ throw new Error(
14
+ `defineServerAction("${id}"): the id must not carry the \`app.\` prefix \u2014 it is added automatically to derive the action name. Use "${id.slice(4)}".`
15
+ );
16
+ }
17
+ if (!args.description || args.description.trim().length === 0) {
18
+ throw new Error(
19
+ `defineServerAction("${id}"): a server action must declare a description \u2014 the model reads it.`
20
+ );
21
+ }
22
+ const requested = args.audience ?? AUDIENCES;
23
+ if (requested.length === 0 || requested.some((entry) => !AUDIENCES.includes(entry))) {
24
+ throw new Error(
25
+ `defineServerAction("${id}"): audience must be a non-empty subset of ${JSON.stringify(AUDIENCES)}.`
26
+ );
27
+ }
28
+ const audience = Object.freeze([...new Set(requested)]);
29
+ const inputZod = args.input ?? z.object({});
30
+ const inputSchema = withoutSchemaKey(
31
+ z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })
32
+ );
33
+ const name = `app.${id}`;
34
+ return {
35
+ id,
36
+ name,
37
+ description: args.description,
38
+ kind: args.kind ?? "effect",
39
+ audience,
40
+ inputZod,
41
+ inputSchema,
42
+ async run(input, ctx) {
43
+ const parsed = inputZod.parse(input);
44
+ const context = { ...ctx, name };
45
+ await args.authorize?.(parsed, context);
46
+ return await args.run(parsed, context);
47
+ }
48
+ };
49
+ }
50
+ export function createServerActionDefiner() {
51
+ return (args) => defineServerAction(args);
52
+ }
53
+ export function forAudience(actions, audience) {
54
+ return actions.filter((action) => action.audience.includes(audience));
55
+ }
56
+ export class ServerActionRegistry {
57
+ actions = /* @__PURE__ */ new Map();
58
+ register(action) {
59
+ if (this.actions.has(action.name)) {
60
+ throw new Error(`Server action already registered: ${action.name}`);
61
+ }
62
+ this.actions.set(action.name, action);
63
+ }
64
+ registerAll(actions) {
65
+ for (const action of actions) this.register(action);
66
+ }
67
+ /** In registration order. */
68
+ list() {
69
+ return [...this.actions.values()];
70
+ }
71
+ get(name) {
72
+ return this.actions.get(name);
73
+ }
74
+ }
75
+ export const serverActionRegistry = new ServerActionRegistry();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-app-actions",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Browser-declared app actions for Skaile forge apps: defineForgeAction / defineClientCapability, the agent capability bridge, command palette and approval dialog, as a Nuxt module.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -23,18 +23,38 @@
23
23
  "types": "./dist/runtime/chat/server.d.ts",
24
24
  "import": "./dist/runtime/chat/server.js"
25
25
  },
26
+ "./chat/ai-sdk": {
27
+ "types": "./dist/runtime/chat/ai-sdk.d.ts",
28
+ "import": "./dist/runtime/chat/ai-sdk.js"
29
+ },
26
30
  "./server": {
27
31
  "types": "./dist/runtime/server/index.d.ts",
28
32
  "import": "./dist/runtime/server/index.js"
29
33
  },
34
+ "./mcp": {
35
+ "types": "./dist/runtime/server/mcp/index.d.ts",
36
+ "import": "./dist/runtime/server/mcp/index.js"
37
+ },
38
+ "./http": {
39
+ "types": "./dist/runtime/server/http/index.d.ts",
40
+ "import": "./dist/runtime/server/http/index.js"
41
+ },
42
+ "./client": {
43
+ "types": "./dist/runtime/client/index.d.ts",
44
+ "import": "./dist/runtime/client/index.js"
45
+ },
30
46
  "./types": {
31
47
  "types": "./dist/runtime/types/index.d.ts",
32
48
  "import": "./dist/runtime/types/index.js"
33
49
  }
34
50
  },
51
+ "bin": {
52
+ "forge-actions": "./bin/forge-actions.mjs"
53
+ },
35
54
  "files": [
36
55
  "dist",
37
56
  "src/runtime",
57
+ "bin",
38
58
  "README.md"
39
59
  ],
40
60
  "repository": {
@@ -60,7 +80,8 @@
60
80
  "@skaile/forge-chat": "^0.2.0",
61
81
  "@skaile/workspaces": "^6.0.0",
62
82
  "nuxt": ">=3.0.0",
63
- "vue": ">=3.0.0"
83
+ "vue": ">=3.0.0",
84
+ "ai": "^7.0.124"
64
85
  },
65
86
  "peerDependenciesMeta": {
66
87
  "@skaile/forge-chat": {
@@ -68,9 +89,13 @@
68
89
  },
69
90
  "@skaile/workspaces": {
70
91
  "optional": true
92
+ },
93
+ "ai": {
94
+ "optional": true
71
95
  }
72
96
  },
73
97
  "dependencies": {
98
+ "@modelcontextprotocol/sdk": "^1.32.1",
74
99
  "@nuxt/kit": "^4.0.0",
75
100
  "zod": "^4.4.3"
76
101
  },
@@ -84,13 +109,19 @@
84
109
  "@types/bun": "^1.2.0",
85
110
  "@types/node": "^22.0.0",
86
111
  "@vitejs/plugin-vue": "^6.0.5",
112
+ "ai": "^7.0.155",
87
113
  "bun-types": "^1.3.11",
114
+ "h3": "^1.15.11",
88
115
  "happy-dom": "^20.9.0",
116
+ "nitropack": "^2.13.4",
89
117
  "nuxt": "^4.0.0",
90
118
  "typescript": "^5.0.0",
91
119
  "unbuild": "^3.6.0",
92
120
  "vitest": "^3.2.0",
93
121
  "vue": "^3.5.28",
94
122
  "vue-tsc": "^2.1.0"
123
+ },
124
+ "engines": {
125
+ "node": ">=20"
95
126
  }
96
127
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * AI SDK projection of server actions (`@skaile/forge-app-actions/chat/ai-sdk`).
3
+ *
4
+ * The only module of this package that imports `ai`, which is an optional peer:
5
+ * hosts that never serve actions to an AI SDK chat never load it.
6
+ */
7
+
8
+ import { jsonSchema, type ToolSet, tool } from "ai";
9
+ import { toolNameOf } from "../internal/tool-name";
10
+ import { invokeAction } from "../server/utils/invoke-action";
11
+ import type { ServerAction, ServerActionScope } from "../server/utils/server-actions";
12
+
13
+ /**
14
+ * The given server actions as AI SDK tools, named `toolNameOf(action.name)` like
15
+ * their MCP counterparts, with the action's description and JSON input schema.
16
+ * Narrow with `forAudience(actions, "llm")` first to honor audiences.
17
+ *
18
+ * Every execution goes through `invokeAction` with `scope`: input is parsed and the
19
+ * action's own `authorize` runs inside `action.run`, once, at call time. Fresh
20
+ * authority therefore comes from that callback reading the app's current store,
21
+ * never from `scope`, which is captured when the tool set is built. A failed call
22
+ * (invalid input, denied, handler error) throws an `Error` carrying the outcome's
23
+ * message, which the AI SDK reports to the model as a tool error.
24
+ *
25
+ * @throws when two different action names map to one tool name.
26
+ */
27
+ export function serverActionToolSet(
28
+ actions: readonly ServerAction[],
29
+ scope: ServerActionScope,
30
+ ): ToolSet {
31
+ const tools: ToolSet = Object.create(null);
32
+ const owners = new Map<string, string>();
33
+
34
+ for (const action of actions) {
35
+ const toolName = toolNameOf(action.name);
36
+ const other = owners.get(toolName);
37
+ if (other !== undefined) {
38
+ throw new Error(`Tool name collision: ${other} and ${action.name} both map to ${toolName}`);
39
+ }
40
+ owners.set(toolName, action.name);
41
+
42
+ tools[toolName] = tool({
43
+ description: action.description,
44
+ inputSchema: jsonSchema(action.inputSchema as Parameters<typeof jsonSchema>[0]),
45
+ execute: async (input: unknown) => {
46
+ const outcome = await invokeAction(action, input, scope);
47
+ if (!outcome.ok) throw new Error(outcome.message);
48
+ return outcome.result ?? null;
49
+ },
50
+ });
51
+ }
52
+ return tools;
53
+ }
@@ -22,6 +22,7 @@ import {
22
22
  useCapabilityApproval,
23
23
  } from "../composables/useCapabilityApproval";
24
24
  import { describeError } from "../internal/describe-error";
25
+ import { toolNameOf } from "../internal/tool-name";
25
26
  import type {
26
27
  ClientCapabilityContext,
27
28
  ClientCapabilityLogger,
@@ -65,16 +66,7 @@ const defaultLog: ClientCapabilityLogger = {
65
66
 
66
67
  type ToolHandler = ClientToolHandlers[string];
67
68
 
68
- const TOOL_NAME_MAX = 64;
69
-
70
- /**
71
- * The provider-safe tool name of a capability name: every character outside
72
- * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
73
- * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
74
- */
75
- export function toolNameOf(capabilityName: string): string {
76
- return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
77
- }
69
+ export { toolNameOf };
78
70
 
79
71
  /** The derived JSON Schema without its top-level `$schema` draft marker; everything else stays. */
80
72
  function withoutSchemaKey(schema: Record<string, unknown>): ClientToolDecls[string]["inputSchema"] {
@@ -0,0 +1,131 @@
1
+ // client/index.ts
2
+ //
3
+ // Browser/Node client of the HTTP projection (`forgeAppActions.http`). Imports
4
+ // types only: no Vue, H3, AI SDK or server code ends up in a client bundle.
5
+
6
+ import type { ActionManifestEntry, ActionsManifest } from "../internal/manifest";
7
+ import type { ActionInput, ActionOutput, ServerAction } from "../server/utils/server-actions";
8
+
9
+ export type { ActionManifestEntry, ActionsManifest };
10
+
11
+ /**
12
+ * The transport. It resolves to the already decoded response body and rejects
13
+ * on a non-2xx status, exactly as Nuxt's `$fetch` does, so `$fetch` can be
14
+ * injected as is. Declared through a method type so its parameters are compared
15
+ * bivariantly: `$fetch` accepts a narrower `body` type than `unknown`.
16
+ */
17
+ export type ActionsFetch = {
18
+ bivarianceHack(
19
+ url: string,
20
+ init: { method: "GET" | "POST"; body?: unknown; headers?: Record<string, string> },
21
+ ): Promise<unknown>;
22
+ }["bivarianceHack"];
23
+
24
+ export interface ActionsClientOptions {
25
+ /** Prefix of `route`, e.g. `https://app.example.com`. Defaults to none (same origin). */
26
+ baseUrl?: string;
27
+ /** Where the host mounted the HTTP adapter. Defaults to `/api/actions`. */
28
+ route?: string;
29
+ /** Defaults to a `fetch`-based transport; Nuxt callers pass `$fetch`. */
30
+ fetch?: ActionsFetch;
31
+ /** Sent with every request (e.g. `Authorization`); a function is read per request. */
32
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
33
+ }
34
+
35
+ /**
36
+ * What the default transport throws for a non-2xx response. Carries the fields
37
+ * Nuxt's `$fetch` errors do: `statusCode`, `statusMessage` and `data`, the
38
+ * decoded response body (for an action failure the H3 error body, whose own
39
+ * `data.issues` lists schema errors).
40
+ */
41
+ export class ActionsClientError extends Error {
42
+ readonly statusCode: number;
43
+ readonly statusMessage: string;
44
+ readonly data: unknown;
45
+
46
+ constructor(statusCode: number, statusMessage: string, message: string, data: unknown) {
47
+ super(message);
48
+ this.name = "ActionsClientError";
49
+ this.statusCode = statusCode;
50
+ this.statusMessage = statusMessage;
51
+ this.data = data;
52
+ }
53
+ }
54
+
55
+ function decode(text: string): unknown {
56
+ if (text === "") return undefined;
57
+ try {
58
+ return JSON.parse(text);
59
+ } catch {
60
+ return text;
61
+ }
62
+ }
63
+
64
+ const nativeFetch: ActionsFetch = async (url, init) => {
65
+ const hasBody = init.body !== undefined;
66
+ const response = await globalThis.fetch(url, {
67
+ method: init.method,
68
+ headers: {
69
+ accept: "application/json",
70
+ ...(hasBody ? { "content-type": "application/json" } : {}),
71
+ ...init.headers,
72
+ },
73
+ body: hasBody ? JSON.stringify(init.body) : undefined,
74
+ });
75
+ const data = decode(await response.text());
76
+ if (!response.ok) {
77
+ const body = (typeof data === "object" && data !== null ? data : {}) as {
78
+ statusMessage?: unknown;
79
+ message?: unknown;
80
+ };
81
+ const statusMessage =
82
+ typeof body.statusMessage === "string" ? body.statusMessage : response.statusText;
83
+ const message = typeof body.message === "string" ? body.message : statusMessage;
84
+ throw new ActionsClientError(response.status, statusMessage, message, data);
85
+ }
86
+ return data;
87
+ };
88
+
89
+ export interface ActionsClient<R extends readonly ServerAction[]> {
90
+ /**
91
+ * Calls the action and resolves to its result. A failure rejects with the
92
+ * transport's error, which carries `statusCode`, `statusMessage` and `data`.
93
+ */
94
+ call<K extends R[number]["id"]>(
95
+ id: K,
96
+ input: ActionInput<Extract<R[number], { id: K }>>,
97
+ ): Promise<ActionOutput<Extract<R[number], { id: K }>>>;
98
+ /** The `user`-audience actions the server offers, with their JSON input schemas. */
99
+ manifest(): Promise<ActionsManifest<R[number]["id"]>>;
100
+ }
101
+
102
+ /**
103
+ * A typed client of the HTTP adapter. `R` is the app's action catalogue
104
+ * (`typeof SERVERAKTIONEN`), so `call` checks ids and inputs and returns the
105
+ * handler's output type; import the catalogue as a type only.
106
+ */
107
+ export function createActionsClient<R extends readonly ServerAction[]>(
108
+ options: ActionsClientOptions = {},
109
+ ): ActionsClient<R> {
110
+ const base = `${(options.baseUrl ?? "").replace(/\/+$/, "")}/${(options.route ?? "/api/actions").replace(/^\/+|\/+$/g, "")}`;
111
+ const transport = options.fetch ?? nativeFetch;
112
+
113
+ const headersOf = async () => {
114
+ const headers = options.headers;
115
+ return typeof headers === "function" ? await headers() : headers;
116
+ };
117
+ const send = async (method: "GET" | "POST", url: string, body?: unknown) => {
118
+ const headers = await headersOf();
119
+ return transport(url, {
120
+ method,
121
+ ...(body === undefined ? {} : { body }),
122
+ ...(headers ? { headers } : {}),
123
+ });
124
+ };
125
+
126
+ return {
127
+ call: (id, input) =>
128
+ send("POST", `${base}/${encodeURIComponent(id)}`, input ?? {}) as Promise<never>,
129
+ manifest: () => send("GET", base) as Promise<ActionsManifest<R[number]["id"]>>,
130
+ };
131
+ }
@@ -0,0 +1,11 @@
1
+ const BEARER = /^Bearer\s+(\S.*)$/i;
2
+
3
+ /**
4
+ * The credential of an `Authorization: Bearer …` header value, or `null` when
5
+ * the header is absent, not a bearer scheme, or empty. Shared by the MCP and
6
+ * HTTP adapters so both read credentials the same way.
7
+ */
8
+ export function bearerOf(header: string | string[] | null | undefined): string | null {
9
+ const value = Array.isArray(header) ? header[0] : header;
10
+ return value?.match(BEARER)?.[1]?.trim() || null;
11
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Wire types of the HTTP action manifest. Types only: the client imports them
3
+ * without pulling any server code.
4
+ */
5
+
6
+ /** One action as `GET <route>` lists it. */
7
+ export interface ActionManifestEntry<Id extends string = string> {
8
+ id: Id;
9
+ /** Canonical name `app.<id>`. */
10
+ name: string;
11
+ description: string;
12
+ kind: "effect" | "query";
13
+ /** JSON Schema (draft-07, input side) of the action's input. */
14
+ inputSchema: Record<string, unknown>;
15
+ }
16
+
17
+ export interface ActionsManifest<Id extends string = string> {
18
+ actions: ActionManifestEntry<Id>[];
19
+ }
@@ -0,0 +1,10 @@
1
+ const TOOL_NAME_MAX = 64;
2
+
3
+ /**
4
+ * The provider-safe tool name of a capability name: every character outside
5
+ * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
6
+ * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
7
+ */
8
+ export function toolNameOf(capabilityName: string): string {
9
+ return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
10
+ }