@skaile/forge-app-actions 0.2.0 → 0.3.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 CHANGED
@@ -32,6 +32,7 @@ Peers: `@nuxt/ui ^4.11`, `nuxt`, `vue`; `@skaile/workspaces ^6` is an optional p
32
32
  | `./chat` | `useChatActions`, `toolNameOf` — forge actions as forge-chat client tools (browser) |
33
33
  | `./chat/server` | `clientToolsFromBody` — validates the `clientTools` request field (no Vue) |
34
34
  | `./server` | `clientCapabilityBridge`, `capabilityRegistry`, `routeCapabilityInvocation`, … |
35
+ | `./mcp` | `createActionsMcpServer`, `createActionsMcpHttpHandler`, `toolNameOf` — the MCP projection of server actions |
35
36
  | `./types` | `ClientCapability*`, `ForgeAction*`, `Palette*` types |
36
37
 
37
38
  Moved here from `@skaile-ai/forge-common` 9.x (`./client-capabilities` and the capability parts of `./server` and `./types`).
@@ -60,3 +61,48 @@ Round trip: the model emits a tool call → the handler ends the turn with `turn
60
61
  `clientToolsFromBody` returns `undefined` when the body has no `clientTools`, and throws an error with `statusCode: 400` (`clientTools: <issues>`) for a bad tool name, an over-long description or a non-object schema — `createAiSdkChatHandler` answers such an error with that status.
61
62
 
62
63
  Peer: `@skaile/forge-chat ^0.2.0` (optional; type-only imports).
64
+
65
+ ## Exposing server actions over MCP
66
+
67
+ Server actions are headless: they run in Nitro, have no palette row and no browser round trip. `defineServerAction` declares one, `serverActionRegistry` (from `@skaile/forge-app-actions/server`, auto-imported in a Nuxt app) collects them, and `forgeAppActions.mcp` serves the registry as a stateless Streamable HTTP MCP endpoint (POST only). Without `mcp` no route exists.
68
+
69
+ ```ts
70
+ // nuxt.config.ts
71
+ export default defineNuxtConfig({
72
+ modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
73
+ forgeAppActions: { mcp: { route: '/mcp', name: 'my-app', version: '1.0.0' } }, // route defaults to '/mcp'
74
+ })
75
+
76
+ // server/plugins/mcp.ts
77
+ import * as z from 'zod'
78
+
79
+ export default defineNitroPlugin((nitroApp) => {
80
+ serverActionRegistry.register(defineServerAction({
81
+ id: 'notes.create', // capability `app.notes.create`, tool `app_notes_create`
82
+ description: 'Create a note.',
83
+ input: z.object({ text: z.string().min(1) }).strict(),
84
+ run: (input, ctx) => createNote(ctx.event as H3Event, input.text),
85
+ }))
86
+
87
+ // Authorization is the app's job. Without a hook that sets `allowed`, the route answers 401.
88
+ nitroApp.hooks.hook('forge-app-actions:mcp:authorize', async (decision) => {
89
+ if (!decision.bearer) return
90
+ const user = await userForToken(decision.bearer)
91
+ if (!user) return
92
+ decision.allowed = true
93
+ decision.principal = user // reaches the actions as `ctx.principal`
94
+ })
95
+ })
96
+ ```
97
+
98
+ ```sh
99
+ curl -s -X POST http://localhost:3000/mcp \
100
+ -H 'authorization: Bearer <token>' -H 'content-type: application/json' \
101
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"app_notes_create","arguments":{"text":"hi"}}}'
102
+ ```
103
+
104
+ **Tools.** Every registered action is a tool named `toolNameOf(action.name)`; two actions that map to the same name make the server fail with `Tool name collision`. The description and the zod-derived input schema come from the action. A call validates the input first (invalid → `isError` with `invalid input: <issues>`); an exception becomes `isError` with its message (an H3 error: its `statusMessage`); an unknown name gives `unknown tool: <name>`. A result is returned as JSON text, and for a non-array object also as `structuredContent`.
105
+
106
+ **Authorization.** The route reads `Authorization: Bearer …` and calls the Nitro hook `forge-app-actions:mcp:authorize` with `{ event, bearer, allowed: false, principal }` (`bearer` is `null` without such a header). The hook sets `allowed` and, optionally, `principal`. A hook that throws or leaves `allowed` false gives 401 (`www-authenticate: Bearer`, `cache-control: no-store`). Non-POST requests give 405 (`allow: POST`); an unreadable body gives JSON-RPC `-32700`. The package ships no shared token on purpose: a token with a default is a token somebody forgets.
107
+
108
+ To serve actions from another HTTP stack, `@skaile/forge-app-actions/mcp` exports `createActionsMcpServer` and `createActionsMcpHttpHandler`.
package/dist/module.d.mts CHANGED
@@ -7,6 +7,20 @@ interface ForgeAppActionsOptions {
7
7
  componentAutoRegister?: boolean;
8
8
  /** Disable composable auto-imports. */
9
9
  composableAutoImport?: boolean;
10
+ /**
11
+ * Serve the registered server actions (`defineServerAction`) as an MCP
12
+ * endpoint (Streamable HTTP, stateless, POST only). Absent: no route.
13
+ * Requests are rejected with 401 unless a Nitro hook on
14
+ * `forge-app-actions:mcp:authorize` sets `decision.allowed`.
15
+ */
16
+ mcp?: {
17
+ /** Route to mount. Defaults to `/mcp`. */
18
+ route?: string;
19
+ /** MCP `serverInfo.name`. Defaults to `forge-app`. */
20
+ name?: string;
21
+ /** MCP `serverInfo.version`. Defaults to `0.0.0`. */
22
+ version?: string;
23
+ };
10
24
  }
11
25
  /**
12
26
  * Nuxt module for browser-declared app actions: `defineForgeAction` /
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skaile/forge-app-actions",
3
3
  "configKey": "forgeAppActions",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineNuxtModule, createResolver, hasNuxtModule, addServerImportsDir, addComponentsDir, addImportsDir } from '@nuxt/kit';
1
+ import { defineNuxtModule, createResolver, hasNuxtModule, addServerImportsDir, addComponentsDir, addImportsDir, addServerHandler, addTypeTemplate } from '@nuxt/kit';
2
2
 
3
3
  const forgeAppActionsModule = defineNuxtModule({
4
4
  meta: {
@@ -31,6 +31,44 @@ const forgeAppActionsModule = defineNuxtModule({
31
31
  if (options.composableAutoImport) {
32
32
  addImportsDir(resolve("./runtime/composables"));
33
33
  }
34
+ if (options.mcp) {
35
+ const { route = "/mcp", name = "forge-app", version = "0.0.0" } = options.mcp;
36
+ nuxt.options.runtimeConfig.forgeAppActions = { mcp: { name, version } };
37
+ addServerHandler({ route, handler: resolve("./runtime/server/mcp/route") });
38
+ addTypeTemplate(
39
+ {
40
+ filename: "types/forge-app-actions-mcp.d.ts",
41
+ getContents: () => `import type { H3Event } from 'h3'
42
+
43
+ declare module 'nitropack/types' {
44
+ interface NitroRuntimeHooks {
45
+ /**
46
+ * Asked once per MCP request. Set \`decision.allowed = true\` to let the
47
+ * request in (the route answers 401 otherwise) and \`decision.principal\`
48
+ * to what the server actions see as \`ctx.principal\`. \`decision.bearer\`
49
+ * is the \`Authorization: Bearer\` credential, or \`null\`.
50
+ */
51
+ 'forge-app-actions:mcp:authorize': (decision: {
52
+ event: H3Event
53
+ bearer: string | null
54
+ allowed: boolean
55
+ principal?: unknown
56
+ }) => void | Promise<void>
57
+ }
58
+ }
59
+
60
+ declare module 'nuxt/schema' {
61
+ interface RuntimeConfig {
62
+ forgeAppActions: { mcp: { name: string, version: string } }
63
+ }
64
+ }
65
+
66
+ export {}
67
+ `
68
+ },
69
+ { nuxt: true, nitro: true }
70
+ );
71
+ }
34
72
  }
35
73
  });
36
74
 
@@ -16,6 +16,7 @@
16
16
  * {@link toolNameOf}(capability name) — `app.<id>` becomes `app_<id>` — because
17
17
  * model providers reject dots in tool names.
18
18
  */
19
+ import { toolNameOf } from "../internal/tool-name.js";
19
20
  import type { ClientCapabilityLogger } from "../types/client-capability.js";
20
21
  import type { ForgeAction } from "../types/forge-action.js";
21
22
  import type { ClientToolDecls, ClientToolHandlers } from "./types.js";
@@ -43,10 +44,5 @@ export interface UseChatActionsReturn {
43
44
  /** Tool handlers for `createChatSession({ tools })`, resolved against `actions()` at call time. */
44
45
  handlers: ClientToolHandlers;
45
46
  }
46
- /**
47
- * The provider-safe tool name of a capability name: every character outside
48
- * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
49
- * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
50
- */
51
- export declare function toolNameOf(capabilityName: string): string;
47
+ export { toolNameOf };
52
48
  export declare function useChatActions(options: UseChatActionsOptions): UseChatActionsReturn;
@@ -3,15 +3,13 @@ import {
3
3
  useCapabilityApproval
4
4
  } from "../composables/useCapabilityApproval.js";
5
5
  import { describeError } from "../internal/describe-error.js";
6
+ import { toolNameOf } from "../internal/tool-name.js";
6
7
  const defaultLog = {
7
8
  info: (message, ...args) => console.log(message, ...args),
8
9
  warn: (message, ...args) => console.warn(message, ...args),
9
10
  error: (message, ...args) => console.error(message, ...args)
10
11
  };
11
- const TOOL_NAME_MAX = 64;
12
- export function toolNameOf(capabilityName) {
13
- return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
14
- }
12
+ export { toolNameOf };
15
13
  function withoutSchemaKey(schema) {
16
14
  const { $schema: _draft, ...rest } = schema;
17
15
  return rest;
@@ -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,4 @@
1
+ const TOOL_NAME_MAX = 64;
2
+ export function toolNameOf(capabilityName) {
3
+ return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
4
+ }
@@ -5,3 +5,4 @@
5
5
  */
6
6
  export * from "./utils/capability-registry.js";
7
7
  export * from "./utils/client-capability-bridge.js";
8
+ export * from "./utils/server-actions.js";
@@ -1,2 +1,3 @@
1
1
  export * from "./utils/capability-registry.js";
2
2
  export * from "./utils/client-capability-bridge.js";
3
+ export * from "./utils/server-actions.js";
@@ -0,0 +1,29 @@
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, ServerActionContext } from "../utils/server-actions.js";
5
+ export { toolNameOf };
6
+ export interface ActionsMcpServerOptions {
7
+ actions: ServerAction[];
8
+ serverInfo: {
9
+ name: string;
10
+ version: string;
11
+ };
12
+ /** `principal` and `event` handed to every action as `ctx`; `name` is set per call. */
13
+ context: Omit<ServerActionContext, "name">;
14
+ }
15
+ /**
16
+ * An MCP server whose tools are the given server actions, named
17
+ * `toolNameOf(action.name)`. Every call runs the action with
18
+ * `{ ...context, name: action.name }`.
19
+ */
20
+ export declare function createActionsMcpServer(options: ActionsMcpServerOptions): Server;
21
+ export type ActionsMcpHttpHandlerOptions = ActionsMcpServerOptions;
22
+ /**
23
+ * A Node `(req, res, parsedBody?)` handler for the Streamable HTTP MCP endpoint.
24
+ * Stateless: every POST serves one JSON-RPC exchange with a fresh server and
25
+ * ends, so no session outlives a request. Authorization is the caller's job
26
+ * (the Nitro route runs the `forge-app-actions:mcp:authorize` hook first);
27
+ * `GET`/`DELETE` answer 405.
28
+ */
29
+ export declare function createActionsMcpHttpHandler(options: ActionsMcpHttpHandlerOptions): (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void>;
@@ -0,0 +1,98 @@
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 * as z from "zod";
5
+ import { toolNameOf } from "../../internal/tool-name.js";
6
+ export { toolNameOf };
7
+ const text = (value) => [{ type: "text", text: value }];
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
+ export function createActionsMcpServer(options) {
22
+ const byTool = /* @__PURE__ */ new Map();
23
+ for (const action of options.actions) {
24
+ const toolName = toolNameOf(action.name);
25
+ const other = byTool.get(toolName);
26
+ if (other) {
27
+ throw new Error(
28
+ `Tool name collision: ${other.name} and ${action.name} both map to ${toolName}`
29
+ );
30
+ }
31
+ byTool.set(toolName, action);
32
+ }
33
+ const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
34
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
35
+ tools: [...byTool].map(([name, action]) => ({
36
+ name,
37
+ description: action.description,
38
+ inputSchema: action.inputSchema
39
+ }))
40
+ }));
41
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
42
+ const action = byTool.get(params.name);
43
+ if (!action) return failure(`unknown tool: ${params.name}`);
44
+ try {
45
+ const result = await action.run(params.arguments ?? {}, {
46
+ ...options.context,
47
+ name: action.name
48
+ });
49
+ const content = text(JSON.stringify(result) ?? "null");
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));
57
+ }
58
+ });
59
+ return server;
60
+ }
61
+ const reply = (res, status, body, headers = {}) => {
62
+ res.writeHead(status, {
63
+ "content-type": "application/json; charset=utf-8",
64
+ "cache-control": "no-store",
65
+ "x-content-type-options": "nosniff",
66
+ ...headers
67
+ });
68
+ res.end(JSON.stringify(body));
69
+ };
70
+ const ACCEPT = "application/json, text/event-stream";
71
+ export function createActionsMcpHttpHandler(options) {
72
+ return async (req, res, parsedBody) => {
73
+ if (req.method !== "POST") {
74
+ return reply(res, 405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
75
+ }
76
+ const accept = String(req.headers.accept ?? "");
77
+ if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
78
+ req.headers.accept = ACCEPT;
79
+ req.rawHeaders.push("Accept", ACCEPT);
80
+ }
81
+ const server = createActionsMcpServer(options);
82
+ const transport = new StreamableHTTPServerTransport({
83
+ sessionIdGenerator: void 0,
84
+ enableJsonResponse: true
85
+ });
86
+ res.on("close", () => {
87
+ void transport.close();
88
+ void server.close();
89
+ });
90
+ try {
91
+ await server.connect(transport);
92
+ await transport.handleRequest(req, res, parsedBody);
93
+ } catch {
94
+ if (!res.headersSent) reply(res, 500, { error: "Internal server error" });
95
+ else res.end();
96
+ }
97
+ };
98
+ }
@@ -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,14 @@
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
+ }
12
+ /** The Streamable HTTP MCP endpoint over the registered server actions. */
13
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<unknown>>;
14
+ export default _default;
@@ -0,0 +1,60 @@
1
+ import {
2
+ defineEventHandler,
3
+ getRequestHeader,
4
+ readRawBody,
5
+ setResponseHeaders,
6
+ setResponseStatus
7
+ } from "h3";
8
+ import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
9
+ import { serverActionRegistry } from "../utils/server-actions.js";
10
+ 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
+ export default defineEventHandler(async (event) => {
17
+ const reply = (status, body, headers = {}) => {
18
+ setResponseStatus(event, status);
19
+ setResponseHeaders(event, { "cache-control": "no-store", ...headers });
20
+ return body;
21
+ };
22
+ if (event.method !== "POST") {
23
+ return reply(405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
24
+ }
25
+ const decision = {
26
+ event,
27
+ bearer: bearerOf(event),
28
+ allowed: false,
29
+ principal: void 0
30
+ };
31
+ try {
32
+ const { hooks } = useNitroApp();
33
+ await hooks.callHook("forge-app-actions:mcp:authorize", decision);
34
+ } catch {
35
+ decision.allowed = false;
36
+ }
37
+ if (decision.allowed !== true) {
38
+ return reply(
39
+ 401,
40
+ { error: "Invalid or missing agent credential" },
41
+ { "www-authenticate": "Bearer" }
42
+ );
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(await readRawBody(event, "utf8") ?? "");
47
+ } catch {
48
+ return reply(400, {
49
+ jsonrpc: "2.0",
50
+ id: null,
51
+ error: { code: -32700, message: "Parse error" }
52
+ });
53
+ }
54
+ const { mcp } = useRuntimeConfig(event).forgeAppActions;
55
+ await createActionsMcpHttpHandler({
56
+ actions: serverActionRegistry.list(),
57
+ serverInfo: { name: mcp.name, version: mcp.version },
58
+ context: { principal: decision.principal, event }
59
+ })(event.node.req, event.node.res, parsed);
60
+ });
@@ -0,0 +1,42 @@
1
+ import * as z from "zod";
2
+ export interface ServerActionContext<P = unknown> {
3
+ /** Capability name as invoked, e.g. `app.trainings.create`. */
4
+ name: string;
5
+ /** What the `forge-app-actions:mcp:authorize` hook set as `principal`. */
6
+ principal: P;
7
+ /** Host event (under Nitro an `H3Event`); apps pass it on to their server utils. */
8
+ event: unknown;
9
+ }
10
+ export interface DefineServerActionArgs<T extends z.ZodType, P = unknown> {
11
+ /** Without the `app.` prefix, e.g. `trainings.create`; the name becomes `app.<id>`. An id starting with `app.` throws. */
12
+ id: string;
13
+ /** Description the model reads. Required; empty throws. */
14
+ description: string;
15
+ /** Zod schema of the input. Defaults to `z.object({})`. */
16
+ input?: T;
17
+ /** Documentation only, as with `defineForgeAction`. Defaults to `"effect"`. */
18
+ kind?: "effect" | "query";
19
+ run: (input: z.infer<T>, ctx: ServerActionContext<P>) => unknown | Promise<unknown>;
20
+ }
21
+ export interface ServerAction {
22
+ id: string;
23
+ name: string;
24
+ description: string;
25
+ kind: "effect" | "query";
26
+ readonly inputZod: z.ZodType;
27
+ /** `z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })` without `$schema`. */
28
+ inputSchema: Record<string, unknown>;
29
+ /** Parses `input` through `inputZod` (a `ZodError` propagates), then calls the handler. */
30
+ run(input: unknown, ctx: ServerActionContext): Promise<unknown>;
31
+ }
32
+ export declare function defineServerAction<T extends z.ZodType = z.ZodObject<Record<string, never>>, P = unknown>(args: DefineServerActionArgs<T, P>): ServerAction;
33
+ export declare class ServerActionRegistry {
34
+ private readonly actions;
35
+ register(action: ServerAction): void;
36
+ registerAll(actions: ServerAction[]): void;
37
+ /** In registration order. */
38
+ list(): ServerAction[];
39
+ get(name: string): ServerAction | undefined;
40
+ }
41
+ /** Process-wide singleton, like `capabilityRegistry`. */
42
+ export declare const serverActionRegistry: ServerActionRegistry;
@@ -0,0 +1,57 @@
1
+ import * as z from "zod";
2
+ function withoutSchemaKey(schema) {
3
+ const { $schema: _draft, ...rest } = schema;
4
+ return rest;
5
+ }
6
+ export function defineServerAction(args) {
7
+ const { id } = args;
8
+ if (!id || id.trim().length === 0) {
9
+ throw new Error("defineServerAction: an action needs a non-empty id.");
10
+ }
11
+ if (id.startsWith("app.")) {
12
+ throw new Error(
13
+ `defineServerAction("${id}"): the id must not carry the \`app.\` prefix \u2014 it is added automatically to derive the action name. Use "${id.slice(4)}".`
14
+ );
15
+ }
16
+ if (!args.description || args.description.trim().length === 0) {
17
+ throw new Error(
18
+ `defineServerAction("${id}"): a server action must declare a description \u2014 the model reads it.`
19
+ );
20
+ }
21
+ const inputZod = args.input ?? z.object({});
22
+ const inputSchema = withoutSchemaKey(
23
+ z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })
24
+ );
25
+ return {
26
+ id,
27
+ name: `app.${id}`,
28
+ description: args.description,
29
+ kind: args.kind ?? "effect",
30
+ inputZod,
31
+ inputSchema,
32
+ async run(input, ctx) {
33
+ const parsed = inputZod.parse(input);
34
+ return await args.run(parsed, ctx);
35
+ }
36
+ };
37
+ }
38
+ export class ServerActionRegistry {
39
+ actions = /* @__PURE__ */ new Map();
40
+ register(action) {
41
+ if (this.actions.has(action.name)) {
42
+ throw new Error(`Server action already registered: ${action.name}`);
43
+ }
44
+ this.actions.set(action.name, action);
45
+ }
46
+ registerAll(actions) {
47
+ for (const action of actions) this.register(action);
48
+ }
49
+ /** In registration order. */
50
+ list() {
51
+ return [...this.actions.values()];
52
+ }
53
+ get(name) {
54
+ return this.actions.get(name);
55
+ }
56
+ }
57
+ export const serverActionRegistry = new ServerActionRegistry();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-app-actions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Browser-declared app actions for Skaile forge apps: defineForgeAction / defineClientCapability, the agent capability bridge, command palette and approval dialog, as a Nuxt module.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -27,6 +27,10 @@
27
27
  "types": "./dist/runtime/server/index.d.ts",
28
28
  "import": "./dist/runtime/server/index.js"
29
29
  },
30
+ "./mcp": {
31
+ "types": "./dist/runtime/server/mcp/index.d.ts",
32
+ "import": "./dist/runtime/server/mcp/index.js"
33
+ },
30
34
  "./types": {
31
35
  "types": "./dist/runtime/types/index.d.ts",
32
36
  "import": "./dist/runtime/types/index.js"
@@ -71,6 +75,7 @@
71
75
  }
72
76
  },
73
77
  "dependencies": {
78
+ "@modelcontextprotocol/sdk": "^1.32.1",
74
79
  "@nuxt/kit": "^4.0.0",
75
80
  "zod": "^4.4.3"
76
81
  },
@@ -85,7 +90,9 @@
85
90
  "@types/node": "^22.0.0",
86
91
  "@vitejs/plugin-vue": "^6.0.5",
87
92
  "bun-types": "^1.3.11",
93
+ "h3": "^1.15.11",
88
94
  "happy-dom": "^20.9.0",
95
+ "nitropack": "^2.13.4",
89
96
  "nuxt": "^4.0.0",
90
97
  "typescript": "^5.0.0",
91
98
  "unbuild": "^3.6.0",
@@ -22,6 +22,7 @@ import {
22
22
  useCapabilityApproval,
23
23
  } from "../composables/useCapabilityApproval";
24
24
  import { describeError } from "../internal/describe-error";
25
+ import { toolNameOf } from "../internal/tool-name";
25
26
  import type {
26
27
  ClientCapabilityContext,
27
28
  ClientCapabilityLogger,
@@ -65,16 +66,7 @@ const defaultLog: ClientCapabilityLogger = {
65
66
 
66
67
  type ToolHandler = ClientToolHandlers[string];
67
68
 
68
- const TOOL_NAME_MAX = 64;
69
-
70
- /**
71
- * The provider-safe tool name of a capability name: every character outside
72
- * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
73
- * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
74
- */
75
- export function toolNameOf(capabilityName: string): string {
76
- return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
77
- }
69
+ export { toolNameOf };
78
70
 
79
71
  /** The derived JSON Schema without its top-level `$schema` draft marker; everything else stays. */
80
72
  function withoutSchemaKey(schema: Record<string, unknown>): ClientToolDecls[string]["inputSchema"] {
@@ -0,0 +1,10 @@
1
+ const TOOL_NAME_MAX = 64;
2
+
3
+ /**
4
+ * The provider-safe tool name of a capability name: every character outside
5
+ * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
6
+ * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
7
+ */
8
+ export function toolNameOf(capabilityName: string): string {
9
+ return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
10
+ }
@@ -6,3 +6,4 @@
6
6
 
7
7
  export * from "./utils/capability-registry";
8
8
  export * from "./utils/client-capability-bridge";
9
+ export * from "./utils/server-actions";
@@ -0,0 +1,161 @@
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 * as z from "zod";
12
+ import { toolNameOf } from "../../internal/tool-name";
13
+ import type { ServerAction, ServerActionContext } from "../utils/server-actions";
14
+
15
+ export { toolNameOf };
16
+
17
+ export interface ActionsMcpServerOptions {
18
+ actions: ServerAction[];
19
+ serverInfo: { name: string; version: string };
20
+ /** `principal` and `event` handed to every action as `ctx`; `name` is set per call. */
21
+ context: Omit<ServerActionContext, "name">;
22
+ }
23
+
24
+ type ToolInputSchema = { type: "object"; [key: string]: unknown };
25
+
26
+ const text = (value: string) => [{ type: "text" as const, text: value }];
27
+
28
+ const failure = (message: string) => ({ isError: true as const, content: text(message) });
29
+
30
+ /**
31
+ * A ZodError by shape, not `instanceof`: the schema belongs to the app, whose
32
+ * zod instance may differ from the one bundled with this package.
33
+ */
34
+ function isZodError(error: unknown): error is z.core.$ZodError {
35
+ const candidate = error as { name?: unknown; issues?: unknown } | null;
36
+ return (
37
+ typeof candidate?.name === "string" &&
38
+ /^\$?ZodError$/.test(candidate.name) &&
39
+ Array.isArray(candidate.issues)
40
+ );
41
+ }
42
+
43
+ /** The message to show the model: an H3 error carries it in `statusMessage`. */
44
+ function messageOf(error: unknown): string {
45
+ if (error && typeof error === "object") {
46
+ const { statusMessage, message } = error as { statusMessage?: unknown; message?: unknown };
47
+ if (typeof statusMessage === "string" && statusMessage) return statusMessage;
48
+ if (typeof message === "string") return message;
49
+ }
50
+ return String(error);
51
+ }
52
+
53
+ /**
54
+ * An MCP server whose tools are the given server actions, named
55
+ * `toolNameOf(action.name)`. Every call runs the action with
56
+ * `{ ...context, name: action.name }`.
57
+ */
58
+ export function createActionsMcpServer(options: ActionsMcpServerOptions): Server {
59
+ const byTool = new Map<string, ServerAction>();
60
+ for (const action of options.actions) {
61
+ const toolName = toolNameOf(action.name);
62
+ const other = byTool.get(toolName);
63
+ if (other) {
64
+ throw new Error(
65
+ `Tool name collision: ${other.name} and ${action.name} both map to ${toolName}`,
66
+ );
67
+ }
68
+ byTool.set(toolName, action);
69
+ }
70
+
71
+ const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
72
+
73
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
74
+ tools: [...byTool].map(([name, action]) => ({
75
+ name,
76
+ description: action.description,
77
+ inputSchema: action.inputSchema as ToolInputSchema,
78
+ })),
79
+ }));
80
+
81
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
82
+ const action = byTool.get(params.name);
83
+ if (!action) return failure(`unknown tool: ${params.name}`);
84
+ try {
85
+ const result = await action.run(params.arguments ?? {}, {
86
+ ...options.context,
87
+ name: action.name,
88
+ });
89
+ const content = text(JSON.stringify(result) ?? "null");
90
+ if (result !== null && typeof result === "object" && !Array.isArray(result)) {
91
+ return { content, structuredContent: result as Record<string, unknown> };
92
+ }
93
+ return { content };
94
+ } catch (error) {
95
+ if (isZodError(error)) return failure(`invalid input: ${z.prettifyError(error)}`);
96
+ return failure(messageOf(error));
97
+ }
98
+ });
99
+
100
+ return server;
101
+ }
102
+
103
+ export type ActionsMcpHttpHandlerOptions = ActionsMcpServerOptions;
104
+
105
+ const reply = (
106
+ res: ServerResponse,
107
+ status: number,
108
+ body: unknown,
109
+ headers: Record<string, string> = {},
110
+ ) => {
111
+ res.writeHead(status, {
112
+ "content-type": "application/json; charset=utf-8",
113
+ "cache-control": "no-store",
114
+ "x-content-type-options": "nosniff",
115
+ ...headers,
116
+ });
117
+ res.end(JSON.stringify(body));
118
+ };
119
+
120
+ const ACCEPT = "application/json, text/event-stream";
121
+
122
+ /**
123
+ * A Node `(req, res, parsedBody?)` handler for the Streamable HTTP MCP endpoint.
124
+ * Stateless: every POST serves one JSON-RPC exchange with a fresh server and
125
+ * ends, so no session outlives a request. Authorization is the caller's job
126
+ * (the Nitro route runs the `forge-app-actions:mcp:authorize` hook first);
127
+ * `GET`/`DELETE` answer 405.
128
+ */
129
+ export function createActionsMcpHttpHandler(
130
+ options: ActionsMcpHttpHandlerOptions,
131
+ ): (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void> {
132
+ return async (req, res, parsedBody) => {
133
+ if (req.method !== "POST") {
134
+ return reply(res, 405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
135
+ }
136
+ // The transport insists on both media types; plain `curl` clients that omit
137
+ // them are served anyway. The transport reads `rawHeaders`, so the header
138
+ // goes into both views of the request.
139
+ const accept = String(req.headers.accept ?? "");
140
+ if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
141
+ req.headers.accept = ACCEPT;
142
+ req.rawHeaders.push("Accept", ACCEPT);
143
+ }
144
+ const server = createActionsMcpServer(options);
145
+ const transport = new StreamableHTTPServerTransport({
146
+ sessionIdGenerator: undefined,
147
+ enableJsonResponse: true,
148
+ });
149
+ res.on("close", () => {
150
+ void transport.close();
151
+ void server.close();
152
+ });
153
+ try {
154
+ await server.connect(transport);
155
+ await transport.handleRequest(req, res, parsedBody);
156
+ } catch {
157
+ if (!res.headersSent) reply(res, 500, { error: "Internal server error" });
158
+ else res.end();
159
+ }
160
+ };
161
+ }
@@ -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,93 @@
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 { serverActionRegistry } from "../utils/server-actions";
16
+ import { createActionsMcpHttpHandler } from "./create-handler";
17
+
18
+ /** What the `forge-app-actions:mcp:authorize` hook receives and may change. */
19
+ export interface McpAuthorizeDecision {
20
+ event: H3Event;
21
+ /** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
22
+ bearer: string | null;
23
+ /** Defaults to `false`; the request is rejected with 401 unless the hook sets it. */
24
+ allowed: boolean;
25
+ /** What the actions see as `ctx.principal`. */
26
+ principal?: unknown;
27
+ }
28
+
29
+ const BEARER = /^Bearer\s+(\S.*)$/i;
30
+
31
+ function bearerOf(event: H3Event): string | null {
32
+ const header = getRequestHeader(event, "authorization");
33
+ return header?.match(BEARER)?.[1]?.trim() || null;
34
+ }
35
+
36
+ /** The Streamable HTTP MCP endpoint over the registered server actions. */
37
+ export default defineEventHandler(async (event) => {
38
+ const reply = (status: number, body: unknown, headers: Record<string, string> = {}) => {
39
+ setResponseStatus(event, status);
40
+ setResponseHeaders(event, { "cache-control": "no-store", ...headers });
41
+ return body;
42
+ };
43
+
44
+ if (event.method !== "POST") {
45
+ return reply(405, { error: "Use POST for Streamable HTTP MCP" }, { allow: "POST" });
46
+ }
47
+
48
+ const decision: McpAuthorizeDecision = {
49
+ event,
50
+ bearer: bearerOf(event),
51
+ allowed: false,
52
+ principal: undefined,
53
+ };
54
+ try {
55
+ // The hook's typing comes from the module's type template, which is absent
56
+ // when this package is type-checked on its own.
57
+ const { hooks } = useNitroApp() as unknown as {
58
+ hooks: { callHook(name: string, decision: McpAuthorizeDecision): Promise<void> };
59
+ };
60
+ await hooks.callHook("forge-app-actions:mcp:authorize", decision);
61
+ } catch {
62
+ decision.allowed = false;
63
+ }
64
+ if (decision.allowed !== true) {
65
+ return reply(
66
+ 401,
67
+ { error: "Invalid or missing agent credential" },
68
+ { "www-authenticate": "Bearer" },
69
+ );
70
+ }
71
+
72
+ let parsed: unknown;
73
+ // Read here so the transport does not wait on a stream Nitro may already have consumed.
74
+ try {
75
+ parsed = JSON.parse((await readRawBody(event, "utf8")) ?? "");
76
+ } catch {
77
+ return reply(400, {
78
+ jsonrpc: "2.0",
79
+ id: null,
80
+ error: { code: -32700, message: "Parse error" },
81
+ });
82
+ }
83
+
84
+ const { mcp } = useRuntimeConfig(event).forgeAppActions as {
85
+ mcp: { name: string; version: string };
86
+ };
87
+ // The handler writes the response itself; h3 sees `res.writableEnded` and sends nothing more.
88
+ await createActionsMcpHttpHandler({
89
+ actions: serverActionRegistry.list(),
90
+ serverInfo: { name: mcp.name, version: mcp.version },
91
+ context: { principal: decision.principal, event },
92
+ })(event.node.req, event.node.res, parsed);
93
+ });
@@ -0,0 +1,117 @@
1
+ // server/utils/server-actions.ts
2
+ //
3
+ // NOTE: this file must live *directly* in `runtime/server/utils/`. The Nuxt
4
+ // module auto-imports that directory NON-recursively (see
5
+ // `client-capability-bridge.ts`), so a subdirectory would silently never reach
6
+ // consumers.
7
+ //
8
+ // Server actions are headless: they run in Nitro, have no palette row and no
9
+ // browser round-trip. They are the unit the `./mcp` projection exposes as MCP
10
+ // tools. Browser actions (`defineForgeAction`) are unaffected.
11
+
12
+ import * as z from "zod";
13
+
14
+ export interface ServerActionContext<P = unknown> {
15
+ /** Capability name as invoked, e.g. `app.trainings.create`. */
16
+ name: string;
17
+ /** What the `forge-app-actions:mcp:authorize` hook set as `principal`. */
18
+ principal: P;
19
+ /** Host event (under Nitro an `H3Event`); apps pass it on to their server utils. */
20
+ event: unknown;
21
+ }
22
+
23
+ export interface DefineServerActionArgs<T extends z.ZodType, P = unknown> {
24
+ /** Without the `app.` prefix, e.g. `trainings.create`; the name becomes `app.<id>`. An id starting with `app.` throws. */
25
+ id: string;
26
+ /** Description the model reads. Required; empty throws. */
27
+ description: string;
28
+ /** Zod schema of the input. Defaults to `z.object({})`. */
29
+ input?: T;
30
+ /** Documentation only, as with `defineForgeAction`. Defaults to `"effect"`. */
31
+ kind?: "effect" | "query";
32
+ run: (input: z.infer<T>, ctx: ServerActionContext<P>) => unknown | Promise<unknown>;
33
+ }
34
+
35
+ export interface ServerAction {
36
+ id: string;
37
+ name: string;
38
+ description: string;
39
+ kind: "effect" | "query";
40
+ readonly inputZod: z.ZodType;
41
+ /** `z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })` without `$schema`. */
42
+ inputSchema: Record<string, unknown>;
43
+ /** Parses `input` through `inputZod` (a `ZodError` propagates), then calls the handler. */
44
+ run(input: unknown, ctx: ServerActionContext): Promise<unknown>;
45
+ }
46
+
47
+ /** The derived JSON Schema without its top-level `$schema` draft marker; everything else stays. */
48
+ function withoutSchemaKey(schema: Record<string, unknown>): Record<string, unknown> {
49
+ const { $schema: _draft, ...rest } = schema;
50
+ return rest;
51
+ }
52
+
53
+ export function defineServerAction<
54
+ T extends z.ZodType = z.ZodObject<Record<string, never>>,
55
+ P = unknown,
56
+ >(args: DefineServerActionArgs<T, P>): ServerAction {
57
+ const { id } = args;
58
+ if (!id || id.trim().length === 0) {
59
+ throw new Error("defineServerAction: an action needs a non-empty id.");
60
+ }
61
+ if (id.startsWith("app.")) {
62
+ throw new Error(
63
+ `defineServerAction("${id}"): the id must not carry the \`app.\` prefix — it is added ` +
64
+ `automatically to derive the action name. Use "${id.slice(4)}".`,
65
+ );
66
+ }
67
+ if (!args.description || args.description.trim().length === 0) {
68
+ throw new Error(
69
+ `defineServerAction("${id}"): a server action must declare a description — the model reads it.`,
70
+ );
71
+ }
72
+
73
+ const inputZod: z.ZodType = args.input ?? z.object({});
74
+ const inputSchema = withoutSchemaKey(
75
+ z.toJSONSchema(inputZod, { target: "draft-07", io: "input" }) as Record<string, unknown>,
76
+ );
77
+
78
+ return {
79
+ id,
80
+ name: `app.${id}`,
81
+ description: args.description,
82
+ kind: args.kind ?? "effect",
83
+ inputZod,
84
+ inputSchema,
85
+ async run(input, ctx) {
86
+ const parsed = inputZod.parse(input) as z.infer<T>;
87
+ return await args.run(parsed, ctx as ServerActionContext<P>);
88
+ },
89
+ };
90
+ }
91
+
92
+ export class ServerActionRegistry {
93
+ private readonly actions = new Map<string, ServerAction>();
94
+
95
+ register(action: ServerAction): void {
96
+ if (this.actions.has(action.name)) {
97
+ throw new Error(`Server action already registered: ${action.name}`);
98
+ }
99
+ this.actions.set(action.name, action);
100
+ }
101
+
102
+ registerAll(actions: ServerAction[]): void {
103
+ for (const action of actions) this.register(action);
104
+ }
105
+
106
+ /** In registration order. */
107
+ list(): ServerAction[] {
108
+ return [...this.actions.values()];
109
+ }
110
+
111
+ get(name: string): ServerAction | undefined {
112
+ return this.actions.get(name);
113
+ }
114
+ }
115
+
116
+ /** Process-wide singleton, like `capabilityRegistry`. */
117
+ export const serverActionRegistry = new ServerActionRegistry();