@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,220 @@
|
|
|
1
|
+
// server/http/create-handler.ts
|
|
2
|
+
//
|
|
3
|
+
// Projects server actions as a plain JSON-over-HTTP API. Deliberately outside
|
|
4
|
+
// `runtime/server/utils/` (like the MCP adapter) so the Nuxt module's auto-import
|
|
5
|
+
// of that directory never pulls it in, and written against `node:http` only:
|
|
6
|
+
// no H3, Vue or AI SDK import, so Express/Vite hosts can mount it too.
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { bearerOf } from "../../internal/bearer";
|
|
10
|
+
import type { ActionManifestEntry, ActionsManifest } from "../../internal/manifest";
|
|
11
|
+
import { invokeAction } from "../utils/invoke-action";
|
|
12
|
+
import { forAudience, type ServerAction } from "../utils/server-actions";
|
|
13
|
+
|
|
14
|
+
/** What the `authorize` callback receives and may change. */
|
|
15
|
+
export interface ActionsHttpAuthorizeDecision {
|
|
16
|
+
/** The host event (under Nitro an `H3Event`), or the Node request when the host passed none. */
|
|
17
|
+
event: unknown;
|
|
18
|
+
req: IncomingMessage;
|
|
19
|
+
/** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
|
|
20
|
+
bearer: string | null;
|
|
21
|
+
/** Defaults to `false`; both the manifest and every call answer 401 unless the callback sets it. */
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
/** What the actions see as `ctx.principal`. */
|
|
24
|
+
principal?: unknown;
|
|
25
|
+
/** What the actions see as `ctx.deps`, e.g. injected service clients. */
|
|
26
|
+
deps?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ActionsHttpHandlerOptions {
|
|
30
|
+
/** Narrowed to the `user` audience by the handler itself; the rest answer 404. */
|
|
31
|
+
actions: readonly ServerAction[];
|
|
32
|
+
/** Mount path, e.g. `/api/actions`. The manifest is `GET <route>`, a call `POST <route>/<id>`. */
|
|
33
|
+
route: string;
|
|
34
|
+
/**
|
|
35
|
+
* Asked once per request, before any action id is looked up or any body is
|
|
36
|
+
* read. Set `decision.allowed = true` to let it in. An error thrown here
|
|
37
|
+
* denies. A host that authenticates by cookie MUST also check the request's
|
|
38
|
+
* origin here before allowing a `POST`: this adapter performs no CSRF defence.
|
|
39
|
+
*/
|
|
40
|
+
authorize: (decision: ActionsHttpAuthorizeDecision) => void | Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per-request inputs a host may supply instead of the handler deriving them from `req`. */
|
|
44
|
+
export interface ActionsHttpRequestInput {
|
|
45
|
+
/** Passed to `authorize` and to the actions as `ctx.event`. Defaults to `req`. */
|
|
46
|
+
event?: unknown;
|
|
47
|
+
/** The raw request body, for hosts whose framework already consumed the stream. Called only after authorization. */
|
|
48
|
+
readBody?: () => Promise<string | undefined>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Bodies above this answer 413. */
|
|
52
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/** The body shape an H3 error serializes to, so Nuxt clients decode it like any other error. */
|
|
55
|
+
function errorBody(statusCode: number, message: string, data?: unknown) {
|
|
56
|
+
return {
|
|
57
|
+
statusCode,
|
|
58
|
+
statusMessage: message,
|
|
59
|
+
message,
|
|
60
|
+
...(data === undefined ? {} : { data }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function send(
|
|
65
|
+
res: ServerResponse,
|
|
66
|
+
status: number,
|
|
67
|
+
body: unknown,
|
|
68
|
+
headers: Record<string, string> = {},
|
|
69
|
+
) {
|
|
70
|
+
res.writeHead(status, {
|
|
71
|
+
"content-type": "application/json; charset=utf-8",
|
|
72
|
+
"cache-control": "no-store",
|
|
73
|
+
"x-content-type-options": "nosniff",
|
|
74
|
+
...headers,
|
|
75
|
+
});
|
|
76
|
+
res.end(JSON.stringify(body) ?? "null");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readStream(req: IncomingMessage): Promise<string> {
|
|
80
|
+
const chunks: Buffer[] = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
for await (const chunk of req) {
|
|
83
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer);
|
|
84
|
+
size += buffer.length;
|
|
85
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error("body too large"), { tooLarge: true });
|
|
86
|
+
chunks.push(buffer);
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where `req.url` points relative to `route`: the manifest (`null`), an action
|
|
93
|
+
* id (already decoded), or outside the API (`undefined`).
|
|
94
|
+
*/
|
|
95
|
+
function actionIdOf(url: string | undefined, route: string): string | null | undefined {
|
|
96
|
+
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
97
|
+
if (pathname !== route && !pathname.startsWith(`${route}/`)) return undefined;
|
|
98
|
+
const rest = pathname.slice(route.length).replace(/^\/+/, "");
|
|
99
|
+
if (rest === "") return null;
|
|
100
|
+
if (rest.includes("/")) return undefined;
|
|
101
|
+
try {
|
|
102
|
+
return decodeURIComponent(rest);
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function manifestOf(actions: readonly ServerAction[]): ActionsManifest {
|
|
109
|
+
return {
|
|
110
|
+
actions: actions.map(
|
|
111
|
+
(action): ActionManifestEntry => ({
|
|
112
|
+
id: action.id,
|
|
113
|
+
name: action.name,
|
|
114
|
+
description: action.description,
|
|
115
|
+
kind: action.kind,
|
|
116
|
+
inputSchema: action.inputSchema,
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A Node `(req, res, input?)` handler for the HTTP projection of server actions.
|
|
124
|
+
*
|
|
125
|
+
* - `GET <route>`: `{ actions: [{ id, name, description, kind, inputSchema }] }` of the
|
|
126
|
+
* `user`-audience actions, `cache-control: no-store`.
|
|
127
|
+
* - `POST <route>/<id>`: the JSON body is the action's input; the response is its raw
|
|
128
|
+
* result. A failure is an H3-shaped error body (`statusCode`, `statusMessage`, `message`,
|
|
129
|
+
* `data.issues` for schema errors) with the outcome's status.
|
|
130
|
+
*
|
|
131
|
+
* Both routes deny by default (401) until `authorize` sets `decision.allowed`.
|
|
132
|
+
* Unknown and non-`user` actions answer 404, malformed JSON 400. Input parsing and
|
|
133
|
+
* the action's own `authorize` happen inside `action.run`, once, via `invokeAction`.
|
|
134
|
+
*/
|
|
135
|
+
export function createActionsHttpHandler(
|
|
136
|
+
options: ActionsHttpHandlerOptions,
|
|
137
|
+
): (req: IncomingMessage, res: ServerResponse, input?: ActionsHttpRequestInput) => Promise<void> {
|
|
138
|
+
const route = `/${options.route.replace(/^\/+|\/+$/g, "")}`;
|
|
139
|
+
const actions = forAudience(options.actions, "user");
|
|
140
|
+
const byId = new Map<string, ServerAction>();
|
|
141
|
+
for (const action of actions) {
|
|
142
|
+
const other = byId.get(action.id);
|
|
143
|
+
if (other) throw new Error(`Duplicate action id: ${action.id} (${other.name}, ${action.name})`);
|
|
144
|
+
byId.set(action.id, action);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return async (req, res, input = {}) => {
|
|
148
|
+
const id = actionIdOf(req.url, route);
|
|
149
|
+
if (id === undefined) {
|
|
150
|
+
return send(res, 404, errorBody(404, "Not found"));
|
|
151
|
+
}
|
|
152
|
+
const method = req.method ?? "GET";
|
|
153
|
+
const allowedMethod = id === null ? "GET" : "POST";
|
|
154
|
+
if (method !== allowedMethod && !(id === null && method === "HEAD")) {
|
|
155
|
+
return send(
|
|
156
|
+
res,
|
|
157
|
+
405,
|
|
158
|
+
errorBody(405, `Use ${allowedMethod} here`),
|
|
159
|
+
{ allow: allowedMethod },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const event = input.event ?? req;
|
|
164
|
+
const decision: ActionsHttpAuthorizeDecision = {
|
|
165
|
+
event,
|
|
166
|
+
req,
|
|
167
|
+
bearer: bearerOf(req.headers.authorization),
|
|
168
|
+
allowed: false,
|
|
169
|
+
principal: undefined,
|
|
170
|
+
deps: undefined,
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
await options.authorize(decision);
|
|
174
|
+
} catch {
|
|
175
|
+
decision.allowed = false;
|
|
176
|
+
}
|
|
177
|
+
if (decision.allowed !== true) {
|
|
178
|
+
return send(res, 401, errorBody(401, "Invalid or missing credential"), {
|
|
179
|
+
"www-authenticate": "Bearer",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (id === null) return send(res, 200, manifestOf(actions));
|
|
184
|
+
|
|
185
|
+
const action = byId.get(id);
|
|
186
|
+
if (!action) return send(res, 404, errorBody(404, `Unknown action: ${id}`));
|
|
187
|
+
|
|
188
|
+
let raw: string | undefined;
|
|
189
|
+
try {
|
|
190
|
+
raw = await (input.readBody ? input.readBody() : readStream(req));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const tooLarge = (error as { tooLarge?: boolean } | null)?.tooLarge === true;
|
|
193
|
+
return send(res, tooLarge ? 413 : 400, errorBody(tooLarge ? 413 : 400, "Unreadable body"));
|
|
194
|
+
}
|
|
195
|
+
let body: unknown = {};
|
|
196
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
197
|
+
try {
|
|
198
|
+
body = JSON.parse(raw);
|
|
199
|
+
} catch {
|
|
200
|
+
return send(res, 400, errorBody(400, "Malformed JSON body"));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const outcome = await invokeAction(action, body, {
|
|
205
|
+
principal: decision.principal,
|
|
206
|
+
event,
|
|
207
|
+
deps: decision.deps,
|
|
208
|
+
});
|
|
209
|
+
if (outcome.ok) return send(res, 200, outcome.result);
|
|
210
|
+
return send(
|
|
211
|
+
res,
|
|
212
|
+
outcome.status,
|
|
213
|
+
errorBody(
|
|
214
|
+
outcome.status,
|
|
215
|
+
outcome.message,
|
|
216
|
+
outcome.issues === undefined ? undefined : { issues: outcome.issues },
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
|
|
7
|
+
export type { ActionManifestEntry, ActionsManifest } from "../../internal/manifest";
|
|
8
|
+
export * from "./create-handler";
|
|
9
|
+
export type { HttpAuthorizeDecision } from "./route";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// server/http/route.ts
|
|
2
|
+
//
|
|
3
|
+
// The Nitro handler `forgeAppActions.http` mounts. Not importable from app code;
|
|
4
|
+
// apps reach the same projection through `@skaile/forge-app-actions/http`.
|
|
5
|
+
|
|
6
|
+
import type { H3Event } from "h3";
|
|
7
|
+
import { defineEventHandler, readRawBody } from "h3";
|
|
8
|
+
import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
|
|
9
|
+
import { serverActionRegistry } from "../utils/server-actions";
|
|
10
|
+
import { createActionsHttpHandler } from "./create-handler";
|
|
11
|
+
|
|
12
|
+
/** What the `forge-app-actions:http:authorize` hook receives and may change. */
|
|
13
|
+
export interface HttpAuthorizeDecision {
|
|
14
|
+
event: H3Event;
|
|
15
|
+
/** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
|
|
16
|
+
bearer: string | null;
|
|
17
|
+
/** Defaults to `false`; manifest and calls answer 401 unless the hook sets it. */
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
/** What the actions see as `ctx.principal`. */
|
|
20
|
+
principal?: unknown;
|
|
21
|
+
/** What the actions see as `ctx.deps`, e.g. injected service clients. */
|
|
22
|
+
deps?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `GET <route>` (manifest) and `POST <route>/:id` over the registered server actions. */
|
|
26
|
+
export default defineEventHandler(async (event) => {
|
|
27
|
+
const { http } = useRuntimeConfig(event).forgeAppActions as { http: { route: string } };
|
|
28
|
+
// The handler writes the response itself; h3 sees `res.writableEnded` and sends nothing more.
|
|
29
|
+
await createActionsHttpHandler({
|
|
30
|
+
actions: serverActionRegistry.list(),
|
|
31
|
+
route: http.route,
|
|
32
|
+
authorize: async (inner) => {
|
|
33
|
+
const decision: HttpAuthorizeDecision = {
|
|
34
|
+
event,
|
|
35
|
+
bearer: inner.bearer,
|
|
36
|
+
allowed: false,
|
|
37
|
+
principal: undefined,
|
|
38
|
+
deps: undefined,
|
|
39
|
+
};
|
|
40
|
+
// The hook's typing comes from the module's type template, which is absent
|
|
41
|
+
// when this package is type-checked on its own.
|
|
42
|
+
const { hooks } = useNitroApp() as unknown as {
|
|
43
|
+
hooks: { callHook(name: string, decision: HttpAuthorizeDecision): Promise<void> };
|
|
44
|
+
};
|
|
45
|
+
await hooks.callHook("forge-app-actions:http:authorize", decision);
|
|
46
|
+
inner.allowed = decision.allowed === true;
|
|
47
|
+
inner.principal = decision.principal;
|
|
48
|
+
inner.deps = decision.deps;
|
|
49
|
+
},
|
|
50
|
+
})(event.node.req, event.node.res, {
|
|
51
|
+
event,
|
|
52
|
+
readBody: () => readRawBody(event, "utf8"),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// server/mcp/create-handler.ts
|
|
2
|
+
//
|
|
3
|
+
// Projects server actions as MCP tools. Deliberately outside `runtime/server/utils/`
|
|
4
|
+
// so the Nuxt module's auto-import of that directory never pulls the MCP SDK
|
|
5
|
+
// into apps that do not enable `forgeAppActions.mcp`.
|
|
6
|
+
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { toolNameOf } from "../../internal/tool-name";
|
|
12
|
+
import { invokeAction } from "../utils/invoke-action";
|
|
13
|
+
import type { ServerAction, ServerActionScope } from "../utils/server-actions";
|
|
14
|
+
|
|
15
|
+
export { toolNameOf };
|
|
16
|
+
|
|
17
|
+
export interface ActionsMcpServerOptions {
|
|
18
|
+
/** Served as given; narrow them with `forAudience(actions, "llm")` first to honor audiences. */
|
|
19
|
+
actions: readonly ServerAction[];
|
|
20
|
+
serverInfo: { name: string; version: string };
|
|
21
|
+
/** `principal`, `event` and `deps` handed to every action as `ctx`; its `name` is derived per action. */
|
|
22
|
+
context: ServerActionScope;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ToolInputSchema = { type: "object"; [key: string]: unknown };
|
|
26
|
+
|
|
27
|
+
const text = (value: string) => [{ type: "text" as const, text: value }];
|
|
28
|
+
|
|
29
|
+
const failure = (message: string) => ({ isError: true as const, content: text(message) });
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An MCP server whose tools are the given server actions, named
|
|
33
|
+
* `toolNameOf(action.name)`. Every call goes through `invokeAction` with `context`;
|
|
34
|
+
* a failed call (invalid input, denied by the action's `authorize`, handler error)
|
|
35
|
+
* is an `isError` result carrying the outcome's message, never an HTTP error.
|
|
36
|
+
*/
|
|
37
|
+
export function createActionsMcpServer(options: ActionsMcpServerOptions): Server {
|
|
38
|
+
const byTool = new Map<string, ServerAction>();
|
|
39
|
+
for (const action of options.actions) {
|
|
40
|
+
const toolName = toolNameOf(action.name);
|
|
41
|
+
const other = byTool.get(toolName);
|
|
42
|
+
if (other) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Tool name collision: ${other.name} and ${action.name} both map to ${toolName}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
byTool.set(toolName, action);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
|
|
51
|
+
|
|
52
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
53
|
+
tools: [...byTool].map(([name, action]) => ({
|
|
54
|
+
name,
|
|
55
|
+
description: action.description,
|
|
56
|
+
inputSchema: action.inputSchema as ToolInputSchema,
|
|
57
|
+
})),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
61
|
+
const action = byTool.get(params.name);
|
|
62
|
+
if (!action) return failure(`unknown tool: ${params.name}`);
|
|
63
|
+
const outcome = await invokeAction(action, params.arguments ?? {}, options.context);
|
|
64
|
+
if (!outcome.ok) return failure(outcome.message);
|
|
65
|
+
const { result } = outcome;
|
|
66
|
+
const content = text(JSON.stringify(result) ?? "null");
|
|
67
|
+
if (result !== null && typeof result === "object" && !Array.isArray(result)) {
|
|
68
|
+
return { content, structuredContent: result as Record<string, unknown> };
|
|
69
|
+
}
|
|
70
|
+
return { content };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return server;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ActionsMcpHttpHandlerOptions = ActionsMcpServerOptions;
|
|
77
|
+
|
|
78
|
+
const reply = (
|
|
79
|
+
res: ServerResponse,
|
|
80
|
+
status: number,
|
|
81
|
+
body: unknown,
|
|
82
|
+
headers: Record<string, string> = {},
|
|
83
|
+
) => {
|
|
84
|
+
res.writeHead(status, {
|
|
85
|
+
"content-type": "application/json; charset=utf-8",
|
|
86
|
+
"cache-control": "no-store",
|
|
87
|
+
"x-content-type-options": "nosniff",
|
|
88
|
+
...headers,
|
|
89
|
+
});
|
|
90
|
+
res.end(JSON.stringify(body));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const ACCEPT = "application/json, text/event-stream";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A Node `(req, res, parsedBody?)` handler for the Streamable HTTP MCP endpoint.
|
|
97
|
+
* Stateless: every POST serves one JSON-RPC exchange with a fresh server and
|
|
98
|
+
* ends, so no session outlives a request. Authorization is the caller's job
|
|
99
|
+
* (the Nitro route runs the `forge-app-actions:mcp:authorize` hook first);
|
|
100
|
+
* `GET`/`DELETE` answer 405.
|
|
101
|
+
*/
|
|
102
|
+
export function createActionsMcpHttpHandler(
|
|
103
|
+
options: ActionsMcpHttpHandlerOptions,
|
|
104
|
+
): (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void> {
|
|
105
|
+
return async (req, res, parsedBody) => {
|
|
106
|
+
if (req.method !== "POST") {
|
|
107
|
+
return reply(res, 405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
|
|
108
|
+
}
|
|
109
|
+
// The transport insists on both media types; plain `curl` clients that omit
|
|
110
|
+
// them are served anyway. The transport reads `rawHeaders`, so the header
|
|
111
|
+
// goes into both views of the request.
|
|
112
|
+
const accept = String(req.headers.accept ?? "");
|
|
113
|
+
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
|
|
114
|
+
req.headers.accept = ACCEPT;
|
|
115
|
+
req.rawHeaders.push("Accept", ACCEPT);
|
|
116
|
+
}
|
|
117
|
+
const server = createActionsMcpServer(options);
|
|
118
|
+
const transport = new StreamableHTTPServerTransport({
|
|
119
|
+
sessionIdGenerator: undefined,
|
|
120
|
+
enableJsonResponse: true,
|
|
121
|
+
});
|
|
122
|
+
res.on("close", () => {
|
|
123
|
+
void transport.close();
|
|
124
|
+
void server.close();
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
await server.connect(transport);
|
|
128
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
129
|
+
} catch {
|
|
130
|
+
if (!res.headersSent) reply(res, 500, { error: "Internal server error" });
|
|
131
|
+
else res.end();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
|
|
7
|
+
export * from "./create-handler";
|
|
8
|
+
export type { McpAuthorizeDecision } from "./route";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// server/mcp/route.ts
|
|
2
|
+
//
|
|
3
|
+
// The Nitro handler `forgeAppActions.mcp` mounts. Not importable from app code;
|
|
4
|
+
// apps reach the same projection through `@skaile/forge-app-actions/mcp`.
|
|
5
|
+
|
|
6
|
+
import type { H3Event } from "h3";
|
|
7
|
+
import {
|
|
8
|
+
defineEventHandler,
|
|
9
|
+
getRequestHeader,
|
|
10
|
+
readRawBody,
|
|
11
|
+
setResponseHeaders,
|
|
12
|
+
setResponseStatus,
|
|
13
|
+
} from "h3";
|
|
14
|
+
import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
|
|
15
|
+
import { bearerOf } from "../../internal/bearer";
|
|
16
|
+
import { forAudience, serverActionRegistry } from "../utils/server-actions";
|
|
17
|
+
import { createActionsMcpHttpHandler } from "./create-handler";
|
|
18
|
+
|
|
19
|
+
/** What the `forge-app-actions:mcp:authorize` hook receives and may change. */
|
|
20
|
+
export interface McpAuthorizeDecision {
|
|
21
|
+
event: H3Event;
|
|
22
|
+
/** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
|
|
23
|
+
bearer: string | null;
|
|
24
|
+
/** Defaults to `false`; the request is rejected with 401 unless the hook sets it. */
|
|
25
|
+
allowed: boolean;
|
|
26
|
+
/** What the actions see as `ctx.principal`. */
|
|
27
|
+
principal?: unknown;
|
|
28
|
+
/** What the actions see as `ctx.deps`, e.g. injected service clients. */
|
|
29
|
+
deps?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The Streamable HTTP MCP endpoint over the registered server actions. */
|
|
33
|
+
export default defineEventHandler(async (event) => {
|
|
34
|
+
const reply = (status: number, body: unknown, headers: Record<string, string> = {}) => {
|
|
35
|
+
setResponseStatus(event, status);
|
|
36
|
+
setResponseHeaders(event, { "cache-control": "no-store", ...headers });
|
|
37
|
+
return body;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (event.method !== "POST") {
|
|
41
|
+
return reply(405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const decision: McpAuthorizeDecision = {
|
|
45
|
+
event,
|
|
46
|
+
bearer: bearerOf(getRequestHeader(event, "authorization")),
|
|
47
|
+
allowed: false,
|
|
48
|
+
principal: undefined,
|
|
49
|
+
deps: undefined,
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
// The hook's typing comes from the module's type template, which is absent
|
|
53
|
+
// when this package is type-checked on its own.
|
|
54
|
+
const { hooks } = useNitroApp() as unknown as {
|
|
55
|
+
hooks: { callHook(name: string, decision: McpAuthorizeDecision): Promise<void> };
|
|
56
|
+
};
|
|
57
|
+
await hooks.callHook("forge-app-actions:mcp:authorize", decision);
|
|
58
|
+
} catch {
|
|
59
|
+
decision.allowed = false;
|
|
60
|
+
}
|
|
61
|
+
if (decision.allowed !== true) {
|
|
62
|
+
return reply(
|
|
63
|
+
401,
|
|
64
|
+
{ error: "Invalid or missing agent credential" },
|
|
65
|
+
{ "www-authenticate": "Bearer" },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
// Read here so the transport does not wait on a stream Nitro may already have consumed.
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse((await readRawBody(event, "utf8")) ?? "");
|
|
73
|
+
} catch {
|
|
74
|
+
return reply(400, {
|
|
75
|
+
jsonrpc: "2.0",
|
|
76
|
+
id: null,
|
|
77
|
+
error: { code: -32700, message: "Parse error" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { mcp } = useRuntimeConfig(event).forgeAppActions as {
|
|
82
|
+
mcp: { name: string; version: string };
|
|
83
|
+
};
|
|
84
|
+
// The handler writes the response itself; h3 sees `res.writableEnded` and sends nothing more.
|
|
85
|
+
await createActionsMcpHttpHandler({
|
|
86
|
+
actions: forAudience(serverActionRegistry.list(), "llm"),
|
|
87
|
+
serverInfo: { name: mcp.name, version: mcp.version },
|
|
88
|
+
context: { principal: decision.principal, event, deps: decision.deps },
|
|
89
|
+
})(event.node.req, event.node.res, parsed);
|
|
90
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// server/utils/invoke-action.ts
|
|
2
|
+
//
|
|
3
|
+
// Directly in `runtime/server/utils/` for the same reason as `server-actions.ts`.
|
|
4
|
+
// Transport-neutral: no H3, MCP or AI SDK import, so every projection (MCP, HTTP,
|
|
5
|
+
// chat tools) classifies failures the same way.
|
|
6
|
+
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import type { ServerAction, ServerActionScope } from "./server-actions";
|
|
9
|
+
|
|
10
|
+
/** What a projection maps to its own error shape: `status` is HTTP-style, whatever the transport. */
|
|
11
|
+
export type ActionOutcome =
|
|
12
|
+
| { ok: true; result: unknown }
|
|
13
|
+
| { ok: false; status: number; message: string; issues?: unknown[] };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A ZodError by shape, not `instanceof`: the schema belongs to the app, whose
|
|
17
|
+
* zod instance may differ from the one bundled with this package.
|
|
18
|
+
*/
|
|
19
|
+
function isZodError(error: unknown): error is z.core.$ZodError {
|
|
20
|
+
const candidate = error as { name?: unknown; issues?: unknown } | null;
|
|
21
|
+
return (
|
|
22
|
+
typeof candidate?.name === "string" &&
|
|
23
|
+
/^\$?ZodError$/.test(candidate.name) &&
|
|
24
|
+
Array.isArray(candidate.issues)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The message to show the caller: an H3 error carries it in `statusMessage`. */
|
|
29
|
+
function messageOf(error: unknown): string {
|
|
30
|
+
if (error && typeof error === "object") {
|
|
31
|
+
const { statusMessage, message } = error as { statusMessage?: unknown; message?: unknown };
|
|
32
|
+
if (typeof statusMessage === "string" && statusMessage) return statusMessage;
|
|
33
|
+
if (typeof message === "string") return message;
|
|
34
|
+
}
|
|
35
|
+
return String(error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An explicit HTTP-style status on the error (H3 errors carry `statusCode`), if it is a valid one. */
|
|
39
|
+
function statusOf(error: unknown): number | undefined {
|
|
40
|
+
const { statusCode } = (error ?? {}) as { statusCode?: unknown };
|
|
41
|
+
return typeof statusCode === "number" && Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600
|
|
42
|
+
? statusCode
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs `action` and classifies the result: schema error → 400 with
|
|
48
|
+
* `invalid input: …` and its `issues`; an error with a numeric `statusCode` →
|
|
49
|
+
* that status; anything else → 500. Calls `action.run` only: parsing and the
|
|
50
|
+
* action's `authorize` happen there, exactly once, so this never reparses or
|
|
51
|
+
* authorizes a second time.
|
|
52
|
+
*/
|
|
53
|
+
export async function invokeAction(
|
|
54
|
+
action: ServerAction,
|
|
55
|
+
input: unknown,
|
|
56
|
+
ctx: ServerActionScope,
|
|
57
|
+
): Promise<ActionOutcome> {
|
|
58
|
+
try {
|
|
59
|
+
return { ok: true, result: await action.run(input, ctx) };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (isZodError(error)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 400,
|
|
65
|
+
message: `invalid input: ${z.prettifyError(error)}`,
|
|
66
|
+
issues: error.issues,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { ok: false, status: statusOf(error) ?? 500, message: messageOf(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|