@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.
- package/README.md +155 -2
- package/bin/forge-actions.mjs +274 -0
- package/dist/module.d.mts +24 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +78 -1
- package/dist/runtime/chat/ai-sdk.d.ts +23 -0
- package/dist/runtime/chat/ai-sdk.js +25 -0
- package/dist/runtime/chat/index.d.ts +2 -6
- package/dist/runtime/chat/index.js +2 -4
- 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/internal/tool-name.d.ts +6 -0
- package/dist/runtime/internal/tool-name.js +4 -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 +2 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/mcp/create-handler.d.ts +31 -0
- package/dist/runtime/server/mcp/create-handler.js +80 -0
- package/dist/runtime/server/mcp/index.d.ts +7 -0
- package/dist/runtime/server/mcp/index.js +1 -0
- package/dist/runtime/server/mcp/route.d.ts +16 -0
- package/dist/runtime/server/mcp/route.js +57 -0
- 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 +87 -0
- package/dist/runtime/server/utils/server-actions.js +75 -0
- package/package.json +33 -2
- package/src/runtime/chat/ai-sdk.ts +53 -0
- package/src/runtime/chat/index.ts +2 -10
- 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/internal/tool-name.ts +10 -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 +2 -0
- package/src/runtime/server/mcp/create-handler.ts +134 -0
- package/src/runtime/server/mcp/index.ts +8 -0
- package/src/runtime/server/mcp/route.ts +90 -0
- package/src/runtime/server/utils/invoke-action.ts +71 -0
- package/src/runtime/server/utils/server-actions.ts +198 -0
|
@@ -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>;
|
|
@@ -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,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider-safe tool name of a capability name: every character outside
|
|
3
|
+
* `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
|
|
4
|
+
* to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
|
|
5
|
+
*/
|
|
6
|
+
export declare function toolNameOf(capabilityName: string): string;
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { toolNameOf } from "../../internal/tool-name.js";
|
|
4
|
+
import type { ServerAction, ServerActionScope } from "../utils/server-actions.js";
|
|
5
|
+
export { toolNameOf };
|
|
6
|
+
export interface ActionsMcpServerOptions {
|
|
7
|
+
/** Served as given; narrow them with `forAudience(actions, "llm")` first to honor audiences. */
|
|
8
|
+
actions: readonly ServerAction[];
|
|
9
|
+
serverInfo: {
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
};
|
|
13
|
+
/** `principal`, `event` and `deps` handed to every action as `ctx`; its `name` is derived per action. */
|
|
14
|
+
context: ServerActionScope;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* An MCP server whose tools are the given server actions, named
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createActionsMcpServer(options: ActionsMcpServerOptions): Server;
|
|
23
|
+
export type ActionsMcpHttpHandlerOptions = ActionsMcpServerOptions;
|
|
24
|
+
/**
|
|
25
|
+
* A Node `(req, res, parsedBody?)` handler for the Streamable HTTP MCP endpoint.
|
|
26
|
+
* Stateless: every POST serves one JSON-RPC exchange with a fresh server and
|
|
27
|
+
* ends, so no session outlives a request. Authorization is the caller's job
|
|
28
|
+
* (the Nitro route runs the `forge-app-actions:mcp:authorize` hook first);
|
|
29
|
+
* `GET`/`DELETE` answer 405.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createActionsMcpHttpHandler(options: ActionsMcpHttpHandlerOptions): (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { toolNameOf } from "../../internal/tool-name.js";
|
|
5
|
+
import { invokeAction } from "../utils/invoke-action.js";
|
|
6
|
+
export { toolNameOf };
|
|
7
|
+
const text = (value) => [{ type: "text", text: value }];
|
|
8
|
+
const failure = (message) => ({ isError: true, content: text(message) });
|
|
9
|
+
export function createActionsMcpServer(options) {
|
|
10
|
+
const byTool = /* @__PURE__ */ new Map();
|
|
11
|
+
for (const action of options.actions) {
|
|
12
|
+
const toolName = toolNameOf(action.name);
|
|
13
|
+
const other = byTool.get(toolName);
|
|
14
|
+
if (other) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Tool name collision: ${other.name} and ${action.name} both map to ${toolName}`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
byTool.set(toolName, action);
|
|
20
|
+
}
|
|
21
|
+
const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
|
|
22
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
23
|
+
tools: [...byTool].map(([name, action]) => ({
|
|
24
|
+
name,
|
|
25
|
+
description: action.description,
|
|
26
|
+
inputSchema: action.inputSchema
|
|
27
|
+
}))
|
|
28
|
+
}));
|
|
29
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
30
|
+
const action = byTool.get(params.name);
|
|
31
|
+
if (!action) return failure(`unknown tool: ${params.name}`);
|
|
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 };
|
|
38
|
+
}
|
|
39
|
+
return { content };
|
|
40
|
+
});
|
|
41
|
+
return server;
|
|
42
|
+
}
|
|
43
|
+
const reply = (res, status, body, headers = {}) => {
|
|
44
|
+
res.writeHead(status, {
|
|
45
|
+
"content-type": "application/json; charset=utf-8",
|
|
46
|
+
"cache-control": "no-store",
|
|
47
|
+
"x-content-type-options": "nosniff",
|
|
48
|
+
...headers
|
|
49
|
+
});
|
|
50
|
+
res.end(JSON.stringify(body));
|
|
51
|
+
};
|
|
52
|
+
const ACCEPT = "application/json, text/event-stream";
|
|
53
|
+
export function createActionsMcpHttpHandler(options) {
|
|
54
|
+
return async (req, res, parsedBody) => {
|
|
55
|
+
if (req.method !== "POST") {
|
|
56
|
+
return reply(res, 405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
|
|
57
|
+
}
|
|
58
|
+
const accept = String(req.headers.accept ?? "");
|
|
59
|
+
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
|
|
60
|
+
req.headers.accept = ACCEPT;
|
|
61
|
+
req.rawHeaders.push("Accept", ACCEPT);
|
|
62
|
+
}
|
|
63
|
+
const server = createActionsMcpServer(options);
|
|
64
|
+
const transport = new StreamableHTTPServerTransport({
|
|
65
|
+
sessionIdGenerator: void 0,
|
|
66
|
+
enableJsonResponse: true
|
|
67
|
+
});
|
|
68
|
+
res.on("close", () => {
|
|
69
|
+
void transport.close();
|
|
70
|
+
void server.close();
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
await server.connect(transport);
|
|
74
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
75
|
+
} catch {
|
|
76
|
+
if (!res.headersSent) reply(res, 500, { error: "Internal server error" });
|
|
77
|
+
else res.end();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit entry point for the MCP projection of server actions, via the
|
|
3
|
+
* `./mcp` subpath. The Nuxt module mounts the route itself when
|
|
4
|
+
* `forgeAppActions.mcp` is set; use these to serve actions from elsewhere.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./create-handler.js";
|
|
7
|
+
export type { McpAuthorizeDecision } 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:mcp:authorize` hook receives and may change. */
|
|
3
|
+
export interface McpAuthorizeDecision {
|
|
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`; the request is rejected with 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
|
+
/** The Streamable HTTP MCP endpoint over the registered server actions. */
|
|
15
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<unknown>>;
|
|
16
|
+
export default _default;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEventHandler,
|
|
3
|
+
getRequestHeader,
|
|
4
|
+
readRawBody,
|
|
5
|
+
setResponseHeaders,
|
|
6
|
+
setResponseStatus
|
|
7
|
+
} from "h3";
|
|
8
|
+
import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
|
|
9
|
+
import { bearerOf } from "../../internal/bearer.js";
|
|
10
|
+
import { forAudience, serverActionRegistry } from "../utils/server-actions.js";
|
|
11
|
+
import { createActionsMcpHttpHandler } from "./create-handler.js";
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
const reply = (status, body, headers = {}) => {
|
|
14
|
+
setResponseStatus(event, status);
|
|
15
|
+
setResponseHeaders(event, { "cache-control": "no-store", ...headers });
|
|
16
|
+
return body;
|
|
17
|
+
};
|
|
18
|
+
if (event.method !== "POST") {
|
|
19
|
+
return reply(405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
|
|
20
|
+
}
|
|
21
|
+
const decision = {
|
|
22
|
+
event,
|
|
23
|
+
bearer: bearerOf(getRequestHeader(event, "authorization")),
|
|
24
|
+
allowed: false,
|
|
25
|
+
principal: void 0,
|
|
26
|
+
deps: void 0
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
const { hooks } = useNitroApp();
|
|
30
|
+
await hooks.callHook("forge-app-actions:mcp:authorize", decision);
|
|
31
|
+
} catch {
|
|
32
|
+
decision.allowed = false;
|
|
33
|
+
}
|
|
34
|
+
if (decision.allowed !== true) {
|
|
35
|
+
return reply(
|
|
36
|
+
401,
|
|
37
|
+
{ error: "Invalid or missing agent credential" },
|
|
38
|
+
{ "www-authenticate": "Bearer" }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(await readRawBody(event, "utf8") ?? "");
|
|
44
|
+
} catch {
|
|
45
|
+
return reply(400, {
|
|
46
|
+
jsonrpc: "2.0",
|
|
47
|
+
id: null,
|
|
48
|
+
error: { code: -32700, message: "Parse error" }
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const { mcp } = useRuntimeConfig(event).forgeAppActions;
|
|
52
|
+
await createActionsMcpHttpHandler({
|
|
53
|
+
actions: forAudience(serverActionRegistry.list(), "llm"),
|
|
54
|
+
serverInfo: { name: mcp.name, version: mcp.version },
|
|
55
|
+
context: { principal: decision.principal, event, deps: decision.deps }
|
|
56
|
+
})(event.node.req, event.node.res, parsed);
|
|
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>;
|