@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.
- package/README.md +111 -4
- package/bin/forge-actions.mjs +274 -0
- package/dist/module.d.mts +10 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +55 -16
- package/dist/runtime/chat/ai-sdk.d.ts +23 -0
- package/dist/runtime/chat/ai-sdk.js +25 -0
- package/dist/runtime/client/index.d.ts +57 -0
- package/dist/runtime/client/index.js +60 -0
- package/dist/runtime/internal/bearer.d.ts +6 -0
- package/dist/runtime/internal/bearer.js +5 -0
- package/dist/runtime/internal/manifest.d.ts +17 -0
- package/dist/runtime/internal/manifest.js +0 -0
- package/dist/runtime/server/http/create-handler.d.ts +50 -0
- package/dist/runtime/server/http/create-handler.js +135 -0
- package/dist/runtime/server/http/index.d.ts +8 -0
- package/dist/runtime/server/http/index.js +1 -0
- package/dist/runtime/server/http/route.d.ts +16 -0
- package/dist/runtime/server/http/route.js +28 -0
- package/dist/runtime/server/index.d.ts +1 -0
- package/dist/runtime/server/index.js +1 -0
- package/dist/runtime/server/mcp/create-handler.d.ts +8 -6
- package/dist/runtime/server/mcp/create-handler.js +8 -26
- package/dist/runtime/server/mcp/route.d.ts +2 -0
- package/dist/runtime/server/mcp/route.js +7 -10
- package/dist/runtime/server/utils/invoke-action.d.ts +19 -0
- package/dist/runtime/server/utils/invoke-action.js +32 -0
- package/dist/runtime/server/utils/server-actions.d.ts +57 -12
- package/dist/runtime/server/utils/server-actions.js +20 -2
- package/package.json +26 -2
- package/src/runtime/chat/ai-sdk.ts +53 -0
- package/src/runtime/client/index.ts +131 -0
- package/src/runtime/internal/bearer.ts +11 -0
- package/src/runtime/internal/manifest.ts +19 -0
- package/src/runtime/server/http/create-handler.ts +220 -0
- package/src/runtime/server/http/index.ts +9 -0
- package/src/runtime/server/http/route.ts +54 -0
- package/src/runtime/server/index.ts +1 -0
- package/src/runtime/server/mcp/create-handler.ts +16 -43
- package/src/runtime/server/mcp/route.ts +8 -11
- package/src/runtime/server/utils/invoke-action.ts +71 -0
- package/src/runtime/server/utils/server-actions.ts +95 -14
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,8 +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
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`) |
|
|
36
39
|
| `./types` | `ClientCapability*`, `ForgeAction*`, `Palette*` types |
|
|
37
40
|
|
|
38
41
|
Moved here from `@skaile-ai/forge-common` 9.x (`./client-capabilities` and the capability parts of `./server` and `./types`).
|
|
@@ -62,6 +65,35 @@ Round trip: the model emits a tool call → the handler ends the turn with `turn
|
|
|
62
65
|
|
|
63
66
|
Peer: `@skaile/forge-chat ^0.2.0` (optional; type-only imports).
|
|
64
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
|
+
|
|
65
97
|
## Exposing server actions over MCP
|
|
66
98
|
|
|
67
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.
|
|
@@ -101,8 +133,83 @@ curl -s -X POST http://localhost:3000/mcp \
|
|
|
101
133
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"app_notes_create","arguments":{"text":"hi"}}}'
|
|
102
134
|
```
|
|
103
135
|
|
|
104
|
-
**Tools.** Every registered action 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>`); 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`.
|
|
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`.
|
|
105
137
|
|
|
106
|
-
**Authorization.** The route reads `Authorization: Bearer …` and calls the Nitro hook `forge-app-actions:mcp:authorize` with `{ event, bearer, allowed: false, principal }` (`bearer` is `null` without such a header). The hook sets `allowed` and, optionally, `principal
|
|
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.
|
|
107
139
|
|
|
108
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
|
@@ -21,6 +21,16 @@ interface ForgeAppActionsOptions {
|
|
|
21
21
|
/** MCP `serverInfo.version`. Defaults to `0.0.0`. */
|
|
22
22
|
version?: string;
|
|
23
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
|
+
};
|
|
24
34
|
}
|
|
25
35
|
/**
|
|
26
36
|
* Nuxt module for browser-declared app actions: `defineForgeAction` /
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -31,35 +31,74 @@ const forgeAppActionsModule = defineNuxtModule({
|
|
|
31
31
|
if (options.composableAutoImport) {
|
|
32
32
|
addImportsDir(resolve("./runtime/composables"));
|
|
33
33
|
}
|
|
34
|
-
if (options.mcp) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 ? ` /**
|
|
46
55
|
* Asked once per MCP request. Set \`decision.allowed = true\` to let the
|
|
47
56
|
* request in (the route answers 401 otherwise) and \`decision.principal\`
|
|
48
|
-
* to what the server actions see as \`ctx.principal
|
|
49
|
-
* is the \`Authorization: Bearer\`
|
|
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\`.
|
|
50
60
|
*/
|
|
51
61
|
'forge-app-actions:mcp:authorize': (decision: {
|
|
52
62
|
event: H3Event
|
|
53
63
|
bearer: string | null
|
|
54
64
|
allowed: boolean
|
|
55
65
|
principal?: unknown
|
|
56
|
-
|
|
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")}
|
|
57
96
|
}
|
|
58
97
|
}
|
|
59
98
|
|
|
60
99
|
declare module 'nuxt/schema' {
|
|
61
100
|
interface RuntimeConfig {
|
|
62
|
-
forgeAppActions: {
|
|
101
|
+
forgeAppActions: { ${runtimeConfig} }
|
|
63
102
|
}
|
|
64
103
|
}
|
|
65
104
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ActionManifestEntry, ActionsManifest } from "../internal/manifest.js";
|
|
2
|
+
import type { ActionInput, ActionOutput, ServerAction } from "../server/utils/server-actions.js";
|
|
3
|
+
export type { ActionManifestEntry, ActionsManifest };
|
|
4
|
+
/**
|
|
5
|
+
* The transport. It resolves to the already decoded response body and rejects
|
|
6
|
+
* on a non-2xx status, exactly as Nuxt's `$fetch` does, so `$fetch` can be
|
|
7
|
+
* injected as is. Declared through a method type so its parameters are compared
|
|
8
|
+
* bivariantly: `$fetch` accepts a narrower `body` type than `unknown`.
|
|
9
|
+
*/
|
|
10
|
+
export type ActionsFetch = {
|
|
11
|
+
bivarianceHack(url: string, init: {
|
|
12
|
+
method: "GET" | "POST";
|
|
13
|
+
body?: unknown;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
}): Promise<unknown>;
|
|
16
|
+
}["bivarianceHack"];
|
|
17
|
+
export interface ActionsClientOptions {
|
|
18
|
+
/** Prefix of `route`, e.g. `https://app.example.com`. Defaults to none (same origin). */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
/** Where the host mounted the HTTP adapter. Defaults to `/api/actions`. */
|
|
21
|
+
route?: string;
|
|
22
|
+
/** Defaults to a `fetch`-based transport; Nuxt callers pass `$fetch`. */
|
|
23
|
+
fetch?: ActionsFetch;
|
|
24
|
+
/** Sent with every request (e.g. `Authorization`); a function is read per request. */
|
|
25
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* What the default transport throws for a non-2xx response. Carries the fields
|
|
29
|
+
* Nuxt's `$fetch` errors do: `statusCode`, `statusMessage` and `data`, the
|
|
30
|
+
* decoded response body (for an action failure the H3 error body, whose own
|
|
31
|
+
* `data.issues` lists schema errors).
|
|
32
|
+
*/
|
|
33
|
+
export declare class ActionsClientError extends Error {
|
|
34
|
+
readonly statusCode: number;
|
|
35
|
+
readonly statusMessage: string;
|
|
36
|
+
readonly data: unknown;
|
|
37
|
+
constructor(statusCode: number, statusMessage: string, message: string, data: unknown);
|
|
38
|
+
}
|
|
39
|
+
export interface ActionsClient<R extends readonly ServerAction[]> {
|
|
40
|
+
/**
|
|
41
|
+
* Calls the action and resolves to its result. A failure rejects with the
|
|
42
|
+
* transport's error, which carries `statusCode`, `statusMessage` and `data`.
|
|
43
|
+
*/
|
|
44
|
+
call<K extends R[number]["id"]>(id: K, input: ActionInput<Extract<R[number], {
|
|
45
|
+
id: K;
|
|
46
|
+
}>>): Promise<ActionOutput<Extract<R[number], {
|
|
47
|
+
id: K;
|
|
48
|
+
}>>>;
|
|
49
|
+
/** The `user`-audience actions the server offers, with their JSON input schemas. */
|
|
50
|
+
manifest(): Promise<ActionsManifest<R[number]["id"]>>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A typed client of the HTTP adapter. `R` is the app's action catalogue
|
|
54
|
+
* (`typeof SERVERAKTIONEN`), so `call` checks ids and inputs and returns the
|
|
55
|
+
* handler's output type; import the catalogue as a type only.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createActionsClient<R extends readonly ServerAction[]>(options?: ActionsClientOptions): ActionsClient<R>;
|