@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
package/README.md CHANGED
@@ -19,7 +19,7 @@ export default defineNuxtConfig({
19
19
  })
20
20
  ```
21
21
 
22
- Peers: `@nuxt/ui ^4.11`, `nuxt`, `vue`; `@skaile/workspaces ^6` is an optional peer (type-only imports from `@skaile/workspaces/sdk/types`).
22
+ Peers: `@nuxt/ui ^4.11`, `nuxt`, `vue`; `@skaile/workspaces ^6` is an optional peer (type-only imports from `@skaile/workspaces/sdk/types`); `ai ^7.0.124` is an optional peer, imported only by `./chat/ai-sdk`.
23
23
 
24
24
  `@skaile/forge-common` installs this module for you.
25
25
 
@@ -31,7 +31,11 @@ Peers: `@nuxt/ui ^4.11`, `nuxt`, `vue`; `@skaile/workspaces ^6` is an optional p
31
31
  | `./client-capabilities` | browser-side API, explicit-import entry for tests and non-auto-import contexts |
32
32
  | `./chat` | `useChatActions`, `toolNameOf` — forge actions as forge-chat client tools (browser) |
33
33
  | `./chat/server` | `clientToolsFromBody` — validates the `clientTools` request field (no Vue) |
34
- | `./server` | `clientCapabilityBridge`, `capabilityRegistry`, `routeCapabilityInvocation`, … |
34
+ | `./server` | `clientCapabilityBridge`, `capabilityRegistry`, `routeCapabilityInvocation`, `defineServerAction`, `createServerActionDefiner`, `forAudience`, `invokeAction`, … |
35
+ | `./mcp` | `createActionsMcpServer`, `createActionsMcpHttpHandler`, `toolNameOf` — the MCP projection of server actions |
36
+ | `./http` | `createActionsHttpHandler` — the HTTP projection (manifest + calls) of `user`-audience server actions |
37
+ | `./client` | `createActionsClient`, `ActionsClientError` — typed client of the HTTP projection (no Vue/H3/AI SDK) |
38
+ | `./chat/ai-sdk` | `serverActionToolSet` — server actions as AI SDK tools (the only entry that imports `ai`) |
35
39
  | `./types` | `ClientCapability*`, `ForgeAction*`, `Palette*` types |
36
40
 
37
41
  Moved here from `@skaile-ai/forge-common` 9.x (`./client-capabilities` and the capability parts of `./server` and `./types`).
@@ -60,3 +64,152 @@ Round trip: the model emits a tool call → the handler ends the turn with `turn
60
64
  `clientToolsFromBody` returns `undefined` when the body has no `clientTools`, and throws an error with `statusCode: 400` (`clientTools: <issues>`) for a bad tool name, an over-long description or a non-object schema — `createAiSdkChatHandler` answers such an error with that status.
61
65
 
62
66
  Peer: `@skaile/forge-chat ^0.2.0` (optional; type-only imports).
67
+
68
+ ## Server actions: audience and authorization
69
+
70
+ `defineServerAction` keeps the literal `id` and infers input (`z.input`) and output types, exposed as `ActionInput<A>` / `ActionOutput<A>`; a `const` tuple of actions keeps every element type through `forAudience` and the registry.
71
+
72
+ ```ts
73
+ const defineAppAction = createServerActionDefiner<AppPrincipal, AppDeps>() // binds only the app's types
74
+
75
+ export const trainings = [
76
+ defineAppAction({
77
+ id: 'trainings.delete', // canonical name `app.trainings.delete`
78
+ description: 'Delete a training.',
79
+ input: z.object({ id: z.string() }).strict(),
80
+ audience: ['user'], // not offered to models; default is both, [] throws
81
+ authorize: async (input, ctx) => { // after the one input parse, before the handler
82
+ await assertMayDelete(ctx.principal, ctx.name, input.id) // read current authority here
83
+ },
84
+ run: ({ id }, ctx) => ctx.deps.trainings.delete(id),
85
+ }),
86
+ ] as const
87
+
88
+ serverActionRegistry.registerAll(trainings)
89
+ const forModels = forAudience(trainings, 'llm')
90
+ ```
91
+
92
+ - `run` parses the input exactly once, derives `name = app.<id>` (a `name` in the caller's context is ignored), awaits `authorize(parsed, context)` and only then calls the handler. A throwing `authorize` denies the call on every path, direct `.run` included; a malformed input never reaches `authorize` or the handler. Without `authorize` nothing changes.
93
+ - The core has no principal, role or grant vocabulary: `ctx.principal` and `ctx.deps` are whatever the transport's authorize hook set (`P`/`D` default to `unknown`). Authorization is the app's; read the current identity and grants inside `authorize`, not from a value captured when the tool set was built.
94
+ - `audience` (`'user'`, `'llm'`) only controls where an action is offered; it is not authorization.
95
+ - `invokeAction(action, input, ctx)` returns an `ActionOutcome`, `{ ok: true, result }` or `{ ok: false, status, message, issues? }`: a schema error is 400 (`invalid input: …`, with `issues`), an error with a numeric `statusCode` keeps it, anything else is 500. It calls `action.run` only and never parses or authorizes itself.
96
+
97
+ ## Exposing server actions over MCP
98
+
99
+ Server actions are headless: they run in Nitro, have no palette row and no browser round trip. `defineServerAction` declares one, `serverActionRegistry` (from `@skaile/forge-app-actions/server`, auto-imported in a Nuxt app) collects them, and `forgeAppActions.mcp` serves the registry as a stateless Streamable HTTP MCP endpoint (POST only). Without `mcp` no route exists.
100
+
101
+ ```ts
102
+ // nuxt.config.ts
103
+ export default defineNuxtConfig({
104
+ modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
105
+ forgeAppActions: { mcp: { route: '/mcp', name: 'my-app', version: '1.0.0' } }, // route defaults to '/mcp'
106
+ })
107
+
108
+ // server/plugins/mcp.ts
109
+ import * as z from 'zod'
110
+
111
+ export default defineNitroPlugin((nitroApp) => {
112
+ serverActionRegistry.register(defineServerAction({
113
+ id: 'notes.create', // capability `app.notes.create`, tool `app_notes_create`
114
+ description: 'Create a note.',
115
+ input: z.object({ text: z.string().min(1) }).strict(),
116
+ run: (input, ctx) => createNote(ctx.event as H3Event, input.text),
117
+ }))
118
+
119
+ // Authorization is the app's job. Without a hook that sets `allowed`, the route answers 401.
120
+ nitroApp.hooks.hook('forge-app-actions:mcp:authorize', async (decision) => {
121
+ if (!decision.bearer) return
122
+ const user = await userForToken(decision.bearer)
123
+ if (!user) return
124
+ decision.allowed = true
125
+ decision.principal = user // reaches the actions as `ctx.principal`
126
+ })
127
+ })
128
+ ```
129
+
130
+ ```sh
131
+ curl -s -X POST http://localhost:3000/mcp \
132
+ -H 'authorization: Bearer <token>' -H 'content-type: application/json' \
133
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"app_notes_create","arguments":{"text":"hi"}}}'
134
+ ```
135
+
136
+ **Tools.** Every registered action whose `audience` includes `llm` is a tool named `toolNameOf(action.name)`; two actions that map to the same name make the server fail with `Tool name collision`. The description and the zod-derived input schema come from the action. A call validates the input first (invalid → `isError` with `invalid input: <issues>`); a denial by the action's `authorize` or an exception becomes `isError` with its message (an H3 error: its `statusMessage`); an unknown name gives `unknown tool: <name>`. A result is returned as JSON text, and for a non-array object also as `structuredContent`.
137
+
138
+ **Authorization.** The route reads `Authorization: Bearer …` and calls the Nitro hook `forge-app-actions:mcp:authorize` with `{ event, bearer, allowed: false, principal, deps }` (`bearer` is `null` without such a header). The hook sets `allowed` and, optionally, `principal` (`ctx.principal`) and `deps` (`ctx.deps`). A hook that throws or leaves `allowed` false gives 401 (`www-authenticate: Bearer`, `cache-control: no-store`). Non-POST requests give 405 (`allow: POST`); an unreadable body gives JSON-RPC `-32700`. The package ships no shared token on purpose: a token with a default is a token somebody forgets.
139
+
140
+ To serve actions from another HTTP stack, `@skaile/forge-app-actions/mcp` exports `createActionsMcpServer` and `createActionsMcpHttpHandler`.
141
+
142
+ ## Exposing server actions over HTTP
143
+
144
+ `forgeAppActions.http` mounts the actions that include the `user` audience as a small JSON API; without it no route exists. It is how browsers, scripts and the CLI reach the same operations MCP gives models, through the same `authorize` of each action.
145
+
146
+ ```ts
147
+ // nuxt.config.ts
148
+ export default defineNuxtConfig({
149
+ modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
150
+ forgeAppActions: { http: { route: '/api/actions' } }, // route defaults to '/api/actions'
151
+ })
152
+
153
+ // server/plugins/actions-http.ts
154
+ export default defineNitroPlugin((nitroApp) => {
155
+ nitroApp.hooks.hook('forge-app-actions:http:authorize', async (decision) => {
156
+ // Bearer clients (CLI, scripts): a credential the app issued.
157
+ if (decision.bearer) {
158
+ const user = await userForToken(decision.bearer)
159
+ if (!user) return
160
+ decision.allowed = true
161
+ decision.principal = user // reaches the actions as `ctx.principal`
162
+ return
163
+ }
164
+ // Cookie sessions: the browser sends the cookie on cross-site requests too, so a POST
165
+ // needs a same-origin check HERE. The adapter does no CSRF defence of its own.
166
+ const session = await sessionOf(decision.event)
167
+ if (!session || (isMutation(decision.event) && !isSameOrigin(decision.event))) return
168
+ decision.allowed = true
169
+ decision.principal = session.user
170
+ })
171
+ })
172
+ ```
173
+
174
+ - `GET <route>` answers `{ actions: [{ id, name, description, kind, inputSchema }] }` for the `user`-audience actions, with `cache-control: no-store`. `POST <route>/<id>` takes the JSON body as the action's input and answers its raw result.
175
+ - **Both routes deny by default.** The hook `forge-app-actions:http:authorize` receives `{ event, bearer, allowed: false, principal?, deps? }` once per request, before an id is looked up or a body is read, and answers 401 (`www-authenticate: Bearer`) unless it sets `allowed`. `principal` and `deps` become `ctx.principal` / `ctx.deps`. A bearer credential and a cookie session are different authentications: never infer one from the other's role.
176
+ - Failures use the H3 error body (`{ statusCode, statusMessage, message, data? }`): malformed JSON 400, a schema error 400 with `data.issues`, an unknown action or one without the `user` audience 404, a wrong method 405, a denial by the action's `authorize` its own `statusCode` (403 for a grant check), an unexpected error 500.
177
+ - To serve the same API from another stack (Express, plain `node:http`), `@skaile/forge-app-actions/http` exports `createActionsHttpHandler({ actions, route, authorize })`, a `(req, res, input?)` handler. `authorize` receives the same decision plus `req`; pass `input.readBody` when your framework already consumed the request stream.
178
+
179
+ ## Typed client
180
+
181
+ ```ts
182
+ import type { SERVERAKTIONEN } from '~~/server/utils/serveraktionen'
183
+ import { createActionsClient } from '@skaile/forge-app-actions/client'
184
+
185
+ const actions = createActionsClient<typeof SERVERAKTIONEN>({ fetch: $fetch }) // Nuxt: inject $fetch
186
+ const created = await actions.call('trainings.create', { title: 'Abend' }) // ids, inputs, outputs are typed
187
+ const { actions: offered } = await actions.manifest()
188
+ ```
189
+
190
+ Import the catalogue as a **type** only: the client entry imports no server code, Vue, H3 or AI SDK. Options: `baseUrl`, `route` (default `/api/actions`), `headers` (an object or a function read per request) and `fetch`. An injected `fetch` is `(url, init: { method: 'GET' | 'POST'; body?; headers? }) => Promise<decoded body>` that rejects on non-2xx, exactly like `$fetch`, so its errors reach you untouched. The default uses the global `fetch`, sends JSON, decodes the response and throws an `ActionsClientError` for non-2xx with `statusCode`, `statusMessage` and `data` (the decoded H3 error body, so schema issues are `error.data.data.issues`, as with `$fetch`). The action id is encoded as one path segment.
191
+
192
+ ## CLI
193
+
194
+ `forge-actions` (Node >= 20, no dependencies) calls a deployed app's `user` actions:
195
+
196
+ ```sh
197
+ export FORGE_ACTIONS_URL=https://app.example.com FORGE_ACTIONS_TOKEN=… # or --url / --token
198
+ npx forge-actions list # [{ id, kind, description }]
199
+ npx forge-actions trainings.create --help # the action's fields from its manifest
200
+ npx forge-actions trainings.create --title Abend --seats 12 # primitive flags, coerced by the schema type
201
+ npx forge-actions trainings.create --json '{"title":"Abend","tags":["a"]}'
202
+ ```
203
+
204
+ `--route` defaults to `/api/actions`; the token is sent as `Authorization: Bearer` and never printed (output is masked). Flags work for `string`, `number`, `integer` and `boolean` fields (a bare `--online` is `true`); object and array fields, and fields named `url`, `token`, `route`, `json` or `help`, go through `--json`, and a flag overrides the same field in `--json`. Exit codes: **0** success (JSON on stdout); **1** transport failure or a failed action (denied, not found, handler error), message on stderr; **2** invalid usage: unknown flag or action id, missing or uncoercible arguments, or input the server's schema rejected.
205
+
206
+ ## Using server actions as AI SDK tools
207
+
208
+ ```ts
209
+ import { serverActionToolSet } from '@skaile/forge-app-actions/chat/ai-sdk'
210
+
211
+ const tools = serverActionToolSet(forAudience(SERVERAKTIONEN, 'llm'), { principal, event, deps })
212
+ await streamText({ model, tools, … }) // or hand them to createAiSdkChatHandler
213
+ ```
214
+
215
+ Each action becomes a tool named `toolNameOf(action.name)` (as in MCP) with its description and JSON input schema, in a null-prototype map. Calls go through `invokeAction` with the scope you gave: input is parsed and the action's `authorize` runs inside `run` for every call, so a grant removed from your store between two calls of the same tool set denies the second. Do not rely on `scope` for fresh authority; it is captured. A failed call throws an `Error` with the outcome's message, which the AI SDK reports to the model as a tool error. Two different action names that map to one tool name throw at construction, naming both. Narrow with `forAudience(…, 'llm')` yourself. Peer: `ai ^7.0.124` (optional).
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ // forge-actions: calls the `user`-audience server actions of an app that serves
3
+ // them over HTTP (`forgeAppActions.http`). Dependency-free, Node >= 20.
4
+ //
5
+ // forge-actions [--url U] [--token T] [--route R] list
6
+ // forge-actions [--url U] [--token T] [--route R] <id> --help
7
+ // forge-actions [--url U] [--token T] [--route R] <id> [--json '<object>'] [--field value ...]
8
+ //
9
+ // Exit codes: 0 success; 1 transport or action failure; 2 invalid usage
10
+ // (unknown flag or action, bad or missing arguments, input the server's schema rejected).
11
+
12
+ const EXIT_FAILURE = 1;
13
+ const EXIT_USAGE = 2;
14
+
15
+ const USAGE = `Usage:
16
+ forge-actions [options] list
17
+ forge-actions [options] <id> --help
18
+ forge-actions [options] <id> [--json '<object>'] [--<field> <value> ...]
19
+
20
+ Options:
21
+ --url <url> Base URL of the app (or FORGE_ACTIONS_URL)
22
+ --token <token> Bearer token (or FORGE_ACTIONS_TOKEN)
23
+ --route <path> Where the app mounts the actions API (default /api/actions)
24
+ --json <object> The action input as a JSON object
25
+ --help Show this text, or an action's fields with <id>
26
+
27
+ Fields of type string, number, integer and boolean can be given as flags;
28
+ object and array fields need --json.`;
29
+
30
+ class UsageError extends Error {}
31
+ class FailureError extends Error {}
32
+
33
+ /** `--name value`, `--name=value` and bare `--name` flags, plus positionals; `-h` is `--help`. */
34
+ function parseArgs(argv) {
35
+ const positionals = [];
36
+ const flags = new Map();
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const arg = argv[i];
39
+ if (arg === "-h") {
40
+ flags.set("help", true);
41
+ } else if (arg.startsWith("--") && arg.length > 2) {
42
+ const eq = arg.indexOf("=");
43
+ if (eq > 0) {
44
+ flags.set(arg.slice(2, eq), arg.slice(eq + 1));
45
+ continue;
46
+ }
47
+ const name = arg.slice(2);
48
+ const next = argv[i + 1];
49
+ // A following token that is not itself a flag is this flag's value (`--help` takes none).
50
+ if (name !== "help" && next !== undefined && !next.startsWith("--")) {
51
+ flags.set(name, next);
52
+ i++;
53
+ } else {
54
+ flags.set(name, true);
55
+ }
56
+ } else {
57
+ positionals.push(arg);
58
+ }
59
+ }
60
+ return { positionals, flags };
61
+ }
62
+
63
+ /** The one primitive type a property schema admits (nulls ignored), or null when it needs --json. */
64
+ function primitiveTypeOf(schema) {
65
+ const types = new Set();
66
+ const collect = (node) => {
67
+ if (!node || typeof node !== "object") return;
68
+ for (const type of Array.isArray(node.type) ? node.type : node.type ? [node.type] : []) {
69
+ types.add(type);
70
+ }
71
+ if (Array.isArray(node.enum)) {
72
+ for (const value of node.enum) types.add(value === null ? "null" : typeof value);
73
+ }
74
+ if ("const" in node) types.add(node.const === null ? "null" : typeof node.const);
75
+ for (const branch of [...(node.anyOf ?? []), ...(node.oneOf ?? [])]) collect(branch);
76
+ };
77
+ collect(schema);
78
+ types.delete("null");
79
+ if (types.size === 2 && types.has("number") && types.has("integer")) types.delete("integer");
80
+ if (types.size !== 1) return null;
81
+ const [type] = types;
82
+ return ["string", "number", "integer", "boolean"].includes(type) ? type : null;
83
+ }
84
+
85
+ function coerce(field, type, raw) {
86
+ if (type === "string") {
87
+ if (raw === true) throw new UsageError(`--${field} needs a value`);
88
+ return raw;
89
+ }
90
+ if (type === "boolean") {
91
+ if (raw === true || raw === "true") return true;
92
+ if (raw === "false") return false;
93
+ throw new UsageError(`--${field} must be true or false, got "${raw}"`);
94
+ }
95
+ if (raw === true || String(raw).trim() === "" || Number.isNaN(Number(raw))) {
96
+ throw new UsageError(`--${field} must be a number, got "${raw === true ? "" : raw}"`);
97
+ }
98
+ const value = Number(raw);
99
+ if (type === "integer" && !Number.isInteger(value)) {
100
+ throw new UsageError(`--${field} must be an integer, got "${raw}"`);
101
+ }
102
+ return value;
103
+ }
104
+
105
+ function describeField(name, schema, required) {
106
+ const type = primitiveTypeOf(schema) ?? (schema?.type ? [].concat(schema.type).join("|") : "json");
107
+ const flag = primitiveTypeOf(schema) ? `--${name} <${type}>` : `--json (${name}: ${type})`;
108
+ const notes = [required ? "required" : "optional"];
109
+ if (Array.isArray(schema?.enum)) notes.push(`one of ${schema.enum.map(String).join(", ")}`);
110
+ if (schema?.description) notes.push(schema.description);
111
+ return ` ${flag} ${notes.join("; ")}`;
112
+ }
113
+
114
+ async function main(argv, env, out) {
115
+ const { positionals, flags } = parseArgs(argv);
116
+ const want = (name) => (typeof flags.get(name) === "string" ? flags.get(name) : undefined);
117
+
118
+ const baseUrl = want("url") ?? env.FORGE_ACTIONS_URL;
119
+ const token = want("token") ?? env.FORGE_ACTIONS_TOKEN;
120
+ const route = `/${(want("route") ?? "/api/actions").replace(/^\/+|\/+$/g, "")}`;
121
+ out.secret = token;
122
+
123
+ const [command, ...extra] = positionals;
124
+ if (!command) {
125
+ if (flags.has("help")) return out.stdout(USAGE);
126
+ throw new UsageError("missing command");
127
+ }
128
+ if (extra.length > 0) throw new UsageError(`unexpected argument: ${extra[0]}`);
129
+ if (command === "list" && flags.has("help")) return out.stdout(USAGE);
130
+ if (!baseUrl) throw new UsageError("missing --url (or FORGE_ACTIONS_URL)");
131
+
132
+ let base;
133
+ try {
134
+ base = `${new URL(baseUrl).href.replace(/\/+$/, "")}${route}`;
135
+ } catch {
136
+ throw new UsageError("--url is not a valid URL");
137
+ }
138
+
139
+ const request = async (method, url, body) => {
140
+ let response;
141
+ try {
142
+ response = await fetch(url, {
143
+ method,
144
+ headers: {
145
+ accept: "application/json",
146
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
147
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
148
+ },
149
+ body: body === undefined ? undefined : JSON.stringify(body),
150
+ });
151
+ } catch (error) {
152
+ const cause = error?.cause?.code ?? error?.cause?.message ?? error?.message ?? "request failed";
153
+ throw new FailureError(`cannot reach ${new URL(url).origin}: ${cause}`);
154
+ }
155
+ const text = await response.text();
156
+ let data;
157
+ try {
158
+ data = text === "" ? undefined : JSON.parse(text);
159
+ } catch {
160
+ data = text;
161
+ }
162
+ if (!response.ok) {
163
+ const message =
164
+ (typeof data === "object" && data !== null && (data.statusMessage || data.message)) ||
165
+ response.statusText ||
166
+ "request failed";
167
+ const issues = data?.data?.issues;
168
+ const error = new FailureError(`${response.status} ${message}`);
169
+ // The server's schema rejected the input: that is invalid arguments, not a failed action.
170
+ error.usage = response.status === 400 && Array.isArray(issues);
171
+ throw error;
172
+ }
173
+ return data;
174
+ };
175
+
176
+ const manifest = await request("GET", base);
177
+ const actions = Array.isArray(manifest?.actions) ? manifest.actions : [];
178
+
179
+ if (command === "list") {
180
+ return out.stdout(
181
+ JSON.stringify(
182
+ actions.map(({ id, kind, description }) => ({ id, kind, description })),
183
+ null,
184
+ 2,
185
+ ),
186
+ );
187
+ }
188
+
189
+ const action = actions.find((entry) => entry.id === command);
190
+ if (!action) throw new UsageError(`unknown action: ${command} (try: forge-actions list)`);
191
+ const schema = action.inputSchema ?? {};
192
+ const properties = schema.properties ?? {};
193
+ const required = new Set(schema.required ?? []);
194
+
195
+ if (flags.has("help")) {
196
+ const fields = Object.entries(properties).map(([name, property]) =>
197
+ describeField(name, property, required.has(name)),
198
+ );
199
+ return out.stdout(
200
+ [
201
+ `${action.id} (${action.kind}): ${action.description}`,
202
+ "",
203
+ fields.length ? "Fields:" : "No input fields.",
204
+ ...fields,
205
+ ].join("\n"),
206
+ );
207
+ }
208
+
209
+ let input = {};
210
+ const json = flags.get("json");
211
+ if (json !== undefined) {
212
+ if (typeof json !== "string") throw new UsageError("--json needs a value");
213
+ let parsed;
214
+ try {
215
+ parsed = JSON.parse(json);
216
+ } catch {
217
+ throw new UsageError("--json is not valid JSON");
218
+ }
219
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
220
+ throw new UsageError("--json must be a JSON object");
221
+ }
222
+ input = parsed;
223
+ }
224
+
225
+ for (const [name, raw] of flags) {
226
+ if (["url", "token", "route", "json", "help"].includes(name)) continue;
227
+ if (!Object.hasOwn(properties, name)) {
228
+ throw new UsageError(`unknown flag --${name} for ${action.id} (see: forge-actions ${action.id} --help)`);
229
+ }
230
+ const type = primitiveTypeOf(properties[name]);
231
+ if (!type) throw new UsageError(`--${name} is not a primitive field: pass it through --json`);
232
+ input[name] = coerce(name, type, raw);
233
+ }
234
+
235
+ for (const name of required) {
236
+ if (!(name in input)) throw new UsageError(`missing required field: ${name}`);
237
+ }
238
+
239
+ const result = await request("POST", `${base}/${encodeURIComponent(action.id)}`, input);
240
+ return out.stdout(JSON.stringify(result ?? null, null, 2));
241
+ }
242
+
243
+ /** Writes with every occurrence of the bearer token masked: it must never reach the terminal. */
244
+ function createOutput() {
245
+ const out = {
246
+ secret: undefined,
247
+ mask(text) {
248
+ return out.secret ? text.split(out.secret).join("***") : text;
249
+ },
250
+ stdout(text) {
251
+ process.stdout.write(`${out.mask(text)}\n`);
252
+ },
253
+ stderr(text) {
254
+ process.stderr.write(`${out.mask(text)}\n`);
255
+ },
256
+ };
257
+ return out;
258
+ }
259
+
260
+ const out = createOutput();
261
+ try {
262
+ await main(process.argv.slice(2), process.env, out);
263
+ } catch (error) {
264
+ if (error instanceof UsageError) {
265
+ out.stderr(`forge-actions: ${error.message}`);
266
+ process.exitCode = EXIT_USAGE;
267
+ } else if (error instanceof FailureError) {
268
+ out.stderr(`forge-actions: ${error.message}`);
269
+ process.exitCode = error.usage ? EXIT_USAGE : EXIT_FAILURE;
270
+ } else {
271
+ out.stderr(`forge-actions: ${error?.message ?? "unexpected error"}`);
272
+ process.exitCode = EXIT_FAILURE;
273
+ }
274
+ }
package/dist/module.d.mts CHANGED
@@ -7,6 +7,30 @@ interface ForgeAppActionsOptions {
7
7
  componentAutoRegister?: boolean;
8
8
  /** Disable composable auto-imports. */
9
9
  composableAutoImport?: boolean;
10
+ /**
11
+ * Serve the registered server actions (`defineServerAction`) as an MCP
12
+ * endpoint (Streamable HTTP, stateless, POST only). Absent: no route.
13
+ * Requests are rejected with 401 unless a Nitro hook on
14
+ * `forge-app-actions:mcp:authorize` sets `decision.allowed`.
15
+ */
16
+ mcp?: {
17
+ /** Route to mount. Defaults to `/mcp`. */
18
+ route?: string;
19
+ /** MCP `serverInfo.name`. Defaults to `forge-app`. */
20
+ name?: string;
21
+ /** MCP `serverInfo.version`. Defaults to `0.0.0`. */
22
+ version?: string;
23
+ };
24
+ /**
25
+ * Serve the `user`-audience server actions as JSON over HTTP: `GET <route>` is
26
+ * the manifest, `POST <route>/<id>` calls an action. Absent: no route.
27
+ * Both answer 401 unless a Nitro hook on `forge-app-actions:http:authorize`
28
+ * sets `decision.allowed`. The hook also owns CSRF defence for cookie sessions.
29
+ */
30
+ http?: {
31
+ /** Route to mount. Defaults to `/api/actions`. */
32
+ route?: string;
33
+ };
10
34
  }
11
35
  /**
12
36
  * Nuxt module for browser-declared app actions: `defineForgeAction` /
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skaile/forge-app-actions",
3
3
  "configKey": "forgeAppActions",
4
- "version": "0.2.1",
4
+ "version": "0.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineNuxtModule, createResolver, hasNuxtModule, addServerImportsDir, addComponentsDir, addImportsDir } from '@nuxt/kit';
1
+ import { defineNuxtModule, createResolver, hasNuxtModule, addServerImportsDir, addComponentsDir, addImportsDir, addServerHandler, addTypeTemplate } from '@nuxt/kit';
2
2
 
3
3
  const forgeAppActionsModule = defineNuxtModule({
4
4
  meta: {
@@ -31,6 +31,83 @@ const forgeAppActionsModule = defineNuxtModule({
31
31
  if (options.composableAutoImport) {
32
32
  addImportsDir(resolve("./runtime/composables"));
33
33
  }
34
+ if (options.mcp || options.http) {
35
+ const mcp = options.mcp ? {
36
+ route: options.mcp.route ?? "/mcp",
37
+ name: options.mcp.name ?? "forge-app",
38
+ version: options.mcp.version ?? "0.0.0"
39
+ } : void 0;
40
+ const http = options.http ? { route: `/${(options.http.route ?? "/api/actions").replace(/^\/+|\/+$/g, "")}` } : void 0;
41
+ nuxt.options.runtimeConfig.forgeAppActions = {
42
+ ...mcp ? { mcp: { name: mcp.name, version: mcp.version } } : {},
43
+ ...http ? { http: { route: http.route } } : {}
44
+ };
45
+ if (mcp) {
46
+ addServerHandler({ route: mcp.route, handler: resolve("./runtime/server/mcp/route") });
47
+ }
48
+ if (http) {
49
+ const handler = resolve("./runtime/server/http/route");
50
+ addServerHandler({ route: http.route, handler });
51
+ addServerHandler({ route: `${http.route}/**`, handler });
52
+ }
53
+ const hooks = [
54
+ mcp ? ` /**
55
+ * Asked once per MCP request. Set \`decision.allowed = true\` to let the
56
+ * request in (the route answers 401 otherwise) and \`decision.principal\`
57
+ * to what the server actions see as \`ctx.principal\` (and \`decision.deps\`
58
+ * as \`ctx.deps\`). \`decision.bearer\` is the \`Authorization: Bearer\`
59
+ * credential, or \`null\`.
60
+ */
61
+ 'forge-app-actions:mcp:authorize': (decision: {
62
+ event: H3Event
63
+ bearer: string | null
64
+ allowed: boolean
65
+ principal?: unknown
66
+ deps?: unknown
67
+ }) => void | Promise<void>` : "",
68
+ http ? ` /**
69
+ * Asked once per HTTP action request (manifest and calls alike). Set
70
+ * \`decision.allowed = true\` to let the request in (401 otherwise),
71
+ * \`decision.principal\` / \`decision.deps\` to what the actions see as
72
+ * \`ctx.principal\` / \`ctx.deps\`. \`decision.bearer\` is the
73
+ * \`Authorization: Bearer\` credential, or \`null\`. A host that authenticates
74
+ * by cookie MUST check the request origin here before allowing a POST.
75
+ */
76
+ 'forge-app-actions:http:authorize': (decision: {
77
+ event: H3Event
78
+ bearer: string | null
79
+ allowed: boolean
80
+ principal?: unknown
81
+ deps?: unknown
82
+ }) => void | Promise<void>` : ""
83
+ ].filter(Boolean);
84
+ const runtimeConfig = [
85
+ mcp ? "mcp: { name: string, version: string }" : "",
86
+ http ? "http: { route: string }" : ""
87
+ ].filter(Boolean).join("; ");
88
+ addTypeTemplate(
89
+ {
90
+ filename: "types/forge-app-actions.d.ts",
91
+ getContents: () => `import type { H3Event } from 'h3'
92
+
93
+ declare module 'nitropack/types' {
94
+ interface NitroRuntimeHooks {
95
+ ${hooks.join("\n")}
96
+ }
97
+ }
98
+
99
+ declare module 'nuxt/schema' {
100
+ interface RuntimeConfig {
101
+ forgeAppActions: { ${runtimeConfig} }
102
+ }
103
+ }
104
+
105
+ export {}
106
+ `
107
+ },
108
+ { nuxt: true, nitro: true }
109
+ );
110
+ }
34
111
  }
35
112
  });
36
113
 
@@ -0,0 +1,23 @@
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
+ import { type ToolSet } from "ai";
8
+ import type { ServerAction, ServerActionScope } from "../server/utils/server-actions.js";
9
+ /**
10
+ * The given server actions as AI SDK tools, named `toolNameOf(action.name)` like
11
+ * their MCP counterparts, with the action's description and JSON input schema.
12
+ * Narrow with `forAudience(actions, "llm")` first to honor audiences.
13
+ *
14
+ * Every execution goes through `invokeAction` with `scope`: input is parsed and the
15
+ * action's own `authorize` runs inside `action.run`, once, at call time. Fresh
16
+ * authority therefore comes from that callback reading the app's current store,
17
+ * never from `scope`, which is captured when the tool set is built. A failed call
18
+ * (invalid input, denied, handler error) throws an `Error` carrying the outcome's
19
+ * message, which the AI SDK reports to the model as a tool error.
20
+ *
21
+ * @throws when two different action names map to one tool name.
22
+ */
23
+ export declare function serverActionToolSet(actions: readonly ServerAction[], scope: ServerActionScope): ToolSet;
@@ -0,0 +1,25 @@
1
+ import { jsonSchema, tool } from "ai";
2
+ import { toolNameOf } from "../internal/tool-name.js";
3
+ import { invokeAction } from "../server/utils/invoke-action.js";
4
+ export function serverActionToolSet(actions, scope) {
5
+ const tools = /* @__PURE__ */ Object.create(null);
6
+ const owners = /* @__PURE__ */ new Map();
7
+ for (const action of actions) {
8
+ const toolName = toolNameOf(action.name);
9
+ const other = owners.get(toolName);
10
+ if (other !== void 0) {
11
+ throw new Error(`Tool name collision: ${other} and ${action.name} both map to ${toolName}`);
12
+ }
13
+ owners.set(toolName, action.name);
14
+ tools[toolName] = tool({
15
+ description: action.description,
16
+ inputSchema: jsonSchema(action.inputSchema),
17
+ execute: async (input) => {
18
+ const outcome = await invokeAction(action, input, scope);
19
+ if (!outcome.ok) throw new Error(outcome.message);
20
+ return outcome.result ?? null;
21
+ }
22
+ });
23
+ }
24
+ return tools;
25
+ }
@@ -16,6 +16,7 @@
16
16
  * {@link toolNameOf}(capability name) — `app.<id>` becomes `app_<id>` — because
17
17
  * model providers reject dots in tool names.
18
18
  */
19
+ import { toolNameOf } from "../internal/tool-name.js";
19
20
  import type { ClientCapabilityLogger } from "../types/client-capability.js";
20
21
  import type { ForgeAction } from "../types/forge-action.js";
21
22
  import type { ClientToolDecls, ClientToolHandlers } from "./types.js";
@@ -43,10 +44,5 @@ export interface UseChatActionsReturn {
43
44
  /** Tool handlers for `createChatSession({ tools })`, resolved against `actions()` at call time. */
44
45
  handlers: ClientToolHandlers;
45
46
  }
46
- /**
47
- * The provider-safe tool name of a capability name: every character outside
48
- * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
49
- * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
50
- */
51
- export declare function toolNameOf(capabilityName: string): string;
47
+ export { toolNameOf };
52
48
  export declare function useChatActions(options: UseChatActionsOptions): UseChatActionsReturn;
@@ -3,15 +3,13 @@ import {
3
3
  useCapabilityApproval
4
4
  } from "../composables/useCapabilityApproval.js";
5
5
  import { describeError } from "../internal/describe-error.js";
6
+ import { toolNameOf } from "../internal/tool-name.js";
6
7
  const defaultLog = {
7
8
  info: (message, ...args) => console.log(message, ...args),
8
9
  warn: (message, ...args) => console.warn(message, ...args),
9
10
  error: (message, ...args) => console.error(message, ...args)
10
11
  };
11
- const TOOL_NAME_MAX = 64;
12
- export function toolNameOf(capabilityName) {
13
- return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
14
- }
12
+ export { toolNameOf };
15
13
  function withoutSchemaKey(schema) {
16
14
  const { $schema: _draft, ...rest } = schema;
17
15
  return rest;