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