@blokjs/trigger-mcp 0.6.18 → 0.6.20

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 ADDED
@@ -0,0 +1,131 @@
1
+ # @blokjs/trigger-mcp
2
+
3
+ Model Context Protocol (MCP) trigger for [Blok](https://github.com/well-prado/blok)
4
+ workflows. Expose a workflow as an MCP **tool** or **resource** so AI
5
+ clients — Claude Code, Cursor, Claude Desktop, or your own agent — can
6
+ discover and call it. Serves both transports (Streamable-HTTP + legacy
7
+ SSE) on the shared Hono HTTP port.
8
+
9
+ > **Full reference:** [Blok docs → Triggers → MCP](https://github.com/well-prado/blok/blob/main/docs/d/triggers/mcp.mdx)
10
+
11
+ ## What it does
12
+
13
+ - Scans the workflow registry for workflows with a `trigger.mcp` block and
14
+ exposes each one to MCP clients.
15
+ - Generates each tool's JSON `inputSchema` from the workflow's Zod `input`
16
+ (via `zod-to-json-schema`).
17
+ - Runs `tools/call` / `resources/read` through the normal Blok runner — so
18
+ retries, idempotency, middleware, cancellation, and Studio tracing all
19
+ apply.
20
+ - Mounts on the HTTP trigger's Hono app (same port, same process). No
21
+ separate listener.
22
+
23
+ ## Authoring a tool
24
+
25
+ A `trigger.mcp` workflow is a normal v2 workflow. The `input` schema is the
26
+ tool contract; the final step's output is the tool result.
27
+
28
+ ```ts
29
+ import { workflow } from "@blokjs/helper";
30
+ import { z } from "zod";
31
+
32
+ export default workflow({
33
+ name: "mcp-greeter",
34
+ version: "1.0.0",
35
+ input: z.object({
36
+ name: z.string().min(1).describe("Name of the person to greet"),
37
+ excited: z.boolean().default(false),
38
+ }),
39
+ trigger: {
40
+ mcp: {
41
+ path: "/mcp",
42
+ serverName: "blok-examples",
43
+ tool: { name: "greet", description: "Greet a person by name." },
44
+ },
45
+ },
46
+ steps: [
47
+ {
48
+ id: "greet",
49
+ use: "@blokjs/expr",
50
+ inputs: {
51
+ expression:
52
+ '({ greeting: "Hello, " + (ctx.request.body.name || "there") + (ctx.request.body.excited ? "!" : ".") })',
53
+ },
54
+ },
55
+ ],
56
+ });
57
+ ```
58
+
59
+ Scaffold a project with this exact example:
60
+
61
+ ```bash
62
+ npx blokctl@latest create project --triggers http,mcp --examples
63
+ ```
64
+
65
+ ## Transports & routes
66
+
67
+ Both default on; override per workflow with `transports: [...]`.
68
+
69
+ | Transport | Route(s) | State |
70
+ | --- | --- | --- |
71
+ | Streamable-HTTP | `ALL <path>` (e.g. `POST /mcp`) | Stateless |
72
+ | SSE (legacy) | `GET <path>/sse` + `POST <path>/messages?sessionId=…` | Stateful |
73
+
74
+ On start the trigger logs each mounted server and route:
75
+
76
+ ```
77
+ [blok][mcp] server "blok-examples" at /mcp — 1 tool(s), 0 resource(s), transports=[sse,streamable-http]
78
+ [blok][mcp] GET /mcp/sse POST /mcp/messages (sse)
79
+ [blok][mcp] ALL /mcp (streamable-http)
80
+ ```
81
+
82
+ ## Connecting a client
83
+
84
+ Give the client the URL `http://localhost:4000/mcp` (Streamable-HTTP) or
85
+ `http://localhost:4000/mcp/sse` (SSE).
86
+
87
+ ```bash
88
+ # Claude Code
89
+ claude mcp add --transport http blok http://localhost:4000/mcp
90
+
91
+ # Interactive debugging
92
+ npx @modelcontextprotocol/inspector # → connect to http://localhost:4000/mcp
93
+ ```
94
+
95
+ ```json
96
+ // Cursor — .cursor/mcp.json
97
+ { "mcpServers": { "blok": { "url": "http://localhost:4000/mcp" } } }
98
+ ```
99
+
100
+ Raw JSON-RPC (send `Accept: application/json, text/event-stream`):
101
+
102
+ ```bash
103
+ curl -sS http://localhost:4000/mcp \
104
+ -H 'Content-Type: application/json' \
105
+ -H 'Accept: application/json, text/event-stream' \
106
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"greet","arguments":{"name":"Ada","excited":true}}}'
107
+ ```
108
+
109
+ ## Config reference
110
+
111
+ | Field | Type | Default | Notes |
112
+ | --- | --- | --- | --- |
113
+ | `path` | `string` | `"/mcp"` | Base path; workflows sharing a path aggregate into one server. |
114
+ | `serverName` | `string` | `"blok-mcp"` | Advertised to clients — set something project-specific. |
115
+ | `serverVersion` | `string` | `"1.0.0"` | Advertised server version. |
116
+ | `transports` | `("sse" \| "streamable-http")[]` | both | At least one required. |
117
+ | `tool` | `{ name?, description? }` | — | Tool mode (default). `name` defaults to the workflow name. |
118
+ | `resource` | `{ uri, name?, description?, mimeType? }` | — | Resource mode. `uri` required; `mimeType` defaults to `application/json`. |
119
+ | `middleware` | `string[]` | — | Trigger-level middleware chain. |
120
+
121
+ ## Identity is not authorization
122
+
123
+ An `x-user-context` header (base64 `{ userId, email }`) or `?user_context=`
124
+ query param is parsed and exposed at `ctx._mcp.userContext`. This is
125
+ **credential injection only** — the trigger does not verify it or scope
126
+ access. Enforce authorization yourself via trigger middleware or an auth
127
+ proxy in front of the endpoint.
128
+
129
+ ## License
130
+
131
+ Part of the Blok framework. See the repository root for license details.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@blokjs/trigger-mcp",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
+ "files": ["dist"],
4
5
  "description": "Model Context Protocol (MCP) trigger for Blok workflows — expose workflows as MCP tools over SSE + Streamable-HTTP on the shared Hono port.",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
@@ -14,9 +15,9 @@
14
15
  "author": "Deskree Technologies Inc.",
15
16
  "license": "Apache-2.0",
16
17
  "dependencies": {
17
- "@blokjs/helper": "^0.6.18",
18
- "@blokjs/runner": "^0.6.18",
19
- "@blokjs/shared": "^0.6.18",
18
+ "@blokjs/helper": "^0.6.20",
19
+ "@blokjs/runner": "^0.6.20",
20
+ "@blokjs/shared": "^0.6.20",
20
21
  "@modelcontextprotocol/sdk": "^1.29.0",
21
22
  "@opentelemetry/api": "^1.9.0",
22
23
  "hono": "^4.11.7",
@@ -1,277 +0,0 @@
1
- /**
2
- * MCP trigger end-to-end integration test.
3
- *
4
- * Spins up a real Hono app + `@hono/node-server` with a real McpTrigger, then
5
- * drives it with the official MCP SDK **client** over BOTH transports:
6
- * - SSE (GET /mcp/sse + POST /mcp/messages)
7
- * - Streamable-HTTP (POST /mcp)
8
- *
9
- * Asserts the client can list tools (with an inputSchema generated from the
10
- * workflow's Zod `input`) and call a tool that runs through the runner and
11
- * returns the workflow's `ctx.response.data`.
12
- */
13
-
14
- import type { Server } from "node:http";
15
- import { NodeMap, WorkflowRegistry, defineNode } from "@blokjs/runner";
16
- import { serve } from "@hono/node-server";
17
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
19
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
20
- import { Hono } from "hono";
21
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
- import { z } from "zod";
23
-
24
- vi.mock("@opentelemetry/api", () => ({
25
- trace: {
26
- getTracer: () => ({
27
- startActiveSpan: (_name: string, fn: (span: unknown) => unknown) =>
28
- fn({
29
- setAttribute: vi.fn(),
30
- setStatus: vi.fn(),
31
- recordException: vi.fn(),
32
- end: vi.fn(),
33
- }),
34
- }),
35
- },
36
- metrics: {
37
- getMeter: () => ({
38
- createCounter: () => ({ add: vi.fn() }),
39
- createHistogram: () => ({ record: vi.fn() }),
40
- createGauge: () => ({ record: vi.fn() }),
41
- createObservableGauge: () => ({ addCallback: vi.fn() }),
42
- }),
43
- },
44
- SpanStatusCode: { OK: 0, ERROR: 1 },
45
- }));
46
-
47
- import McpTriggerClass, { _setActiveMcpTrigger } from "./McpTrigger";
48
-
49
- // Unique port per test — avoids same-port sequential-teardown (ECONNRESET /
50
- // TIME_WAIT) races when several real HTTP servers start/stop in a row.
51
- let nextPort = 4913;
52
- let BASE = `http://localhost:${nextPort}`;
53
-
54
- /** Inline tool node — echoes the input back so we can assert the round-trip. */
55
- const echoNode = defineNode({
56
- name: "echo-node",
57
- description: "test fixture — echo the input",
58
- input: z.object({ msg: z.string() }),
59
- output: z.object({ echoed: z.string(), upper: z.string() }),
60
- async execute(_ctx, input) {
61
- return { echoed: input.msg, upper: input.msg.toUpperCase() };
62
- },
63
- });
64
-
65
- /** Returns the caller identity from the parsed x-user-context (credential injection). */
66
- const whoamiNode = defineNode({
67
- name: "whoami-node",
68
- description: "test fixture — echo the caller identity",
69
- input: z.object({}).passthrough(),
70
- output: z.object({ userId: z.string().optional(), email: z.string().optional() }),
71
- async execute(ctx) {
72
- const raw = (ctx.request?.headers as Record<string, string> | undefined)?.["x-user-context"];
73
- if (!raw) return {};
74
- const d = JSON.parse(Buffer.from(raw, "base64").toString("utf-8")) as { userId?: string; email?: string };
75
- return { userId: d.userId, email: d.email };
76
- },
77
- });
78
-
79
- /** Resource body provider. */
80
- const agentsNode = defineNode({
81
- name: "agents-node",
82
- description: "test fixture — resource body",
83
- input: z.object({}).passthrough(),
84
- output: z.object({ agents: z.array(z.string()) }),
85
- async execute() {
86
- return { agents: ["codebase", "infra"] };
87
- },
88
- });
89
-
90
- function registerWorkflows(): void {
91
- const reg = WorkflowRegistry.getInstance();
92
- reg.register({
93
- name: "echo_tool",
94
- source: "/test/echo.ts",
95
- workflow: {
96
- name: "echo_tool",
97
- version: "1.0.0",
98
- trigger: {
99
- mcp: {
100
- path: "/mcp",
101
- serverName: "test-mcp",
102
- serverVersion: "1.0.0",
103
- transports: ["sse", "streamable-http"],
104
- tool: { description: "Echo the input back" },
105
- },
106
- },
107
- input: z.object({ msg: z.string().describe("Message to echo") }),
108
- steps: [{ id: "echo", node: "echo-node", type: "module", inputs: { msg: "js/ctx.request.body.msg" } }],
109
- nodes: { echo: { inputs: { msg: "js/ctx.request.body.msg" } } },
110
- },
111
- });
112
- reg.register({
113
- name: "whoami",
114
- source: "/test/whoami.ts",
115
- workflow: {
116
- name: "whoami",
117
- version: "1.0.0",
118
- trigger: { mcp: { path: "/mcp", serverName: "test-mcp", tool: { description: "Who am I" } } },
119
- steps: [{ id: "who", node: "whoami-node", type: "module", inputs: {} }],
120
- nodes: { who: { inputs: {} } },
121
- },
122
- });
123
- reg.register({
124
- name: "agents_resource",
125
- source: "/test/agents.ts",
126
- workflow: {
127
- name: "agents_resource",
128
- version: "1.0.0",
129
- trigger: {
130
- mcp: {
131
- path: "/mcp",
132
- serverName: "test-mcp",
133
- resource: { uri: "test://agents", name: "Agents", mimeType: "application/json" },
134
- },
135
- },
136
- steps: [{ id: "a", node: "agents-node", type: "module", inputs: {} }],
137
- nodes: { a: { inputs: {} } },
138
- },
139
- });
140
- }
141
-
142
- describe("McpTrigger — integration (real MCP SDK client over SSE + Streamable-HTTP)", () => {
143
- let app: Hono;
144
- let trigger: InstanceType<typeof McpTriggerClass>;
145
- let httpServer: Server | null = null;
146
-
147
- beforeEach(async () => {
148
- WorkflowRegistry.resetInstance();
149
- _setActiveMcpTrigger(null);
150
- app = new Hono();
151
-
152
- const nodes = new NodeMap();
153
- nodes.addNode("echo-node", echoNode);
154
- nodes.addNode("whoami-node", whoamiNode);
155
- nodes.addNode("agents-node", agentsNode);
156
- registerWorkflows();
157
-
158
- trigger = new McpTriggerClass(app);
159
- trigger.setNodeMap({ nodes });
160
- await trigger.listen();
161
-
162
- const port = nextPort++;
163
- BASE = `http://localhost:${port}`;
164
- await new Promise<void>((resolve) => {
165
- httpServer = serve({ fetch: app.fetch, port }, () => resolve()) as Server;
166
- });
167
- });
168
-
169
- afterEach(
170
- () =>
171
- new Promise<void>((resolve) => {
172
- if (trigger) void trigger.stop();
173
- if (httpServer) {
174
- httpServer.close(() => {
175
- httpServer = null;
176
- WorkflowRegistry.resetInstance();
177
- _setActiveMcpTrigger(null);
178
- resolve();
179
- });
180
- } else {
181
- WorkflowRegistry.resetInstance();
182
- _setActiveMcpTrigger(null);
183
- resolve();
184
- }
185
- }),
186
- );
187
-
188
- it("lists + calls a tool over Streamable-HTTP", async () => {
189
- const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
190
- const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`));
191
- await client.connect(transport);
192
-
193
- const tools = await client.listTools();
194
- expect(tools.tools.map((t) => t.name)).toContain("echo_tool");
195
- const echo = tools.tools.find((t) => t.name === "echo_tool");
196
- expect(echo?.description).toBe("Echo the input back");
197
- // inputSchema generated from the workflow's Zod input
198
- expect(echo?.inputSchema?.type).toBe("object");
199
- expect(Object.keys((echo?.inputSchema?.properties ?? {}) as object)).toContain("msg");
200
-
201
- const result = (await client.callTool({ name: "echo_tool", arguments: { msg: "hello" } })) as {
202
- content: Array<{ type: string; text: string }>;
203
- isError?: boolean;
204
- };
205
- expect(result.isError).toBeFalsy();
206
- const payload = JSON.parse(result.content[0].text) as { echoed: string; upper: string };
207
- expect(payload).toEqual({ echoed: "hello", upper: "HELLO" });
208
-
209
- await client.close();
210
- }, 20_000);
211
-
212
- it("lists + calls a tool over SSE (GET /mcp/sse + POST /mcp/messages)", async () => {
213
- const client = new Client({ name: "test-client-sse", version: "1.0.0" }, { capabilities: {} });
214
- const transport = new SSEClientTransport(new URL(`${BASE}/mcp/sse`));
215
- await client.connect(transport);
216
-
217
- const tools = await client.listTools();
218
- expect(tools.tools.map((t) => t.name)).toContain("echo_tool");
219
-
220
- const result = (await client.callTool({ name: "echo_tool", arguments: { msg: "world" } })) as {
221
- content: Array<{ type: string; text: string }>;
222
- isError?: boolean;
223
- };
224
- expect(result.isError).toBeFalsy();
225
- const payload = JSON.parse(result.content[0].text) as { echoed: string; upper: string };
226
- expect(payload).toEqual({ echoed: "world", upper: "WORLD" });
227
-
228
- await client.close();
229
- }, 20_000);
230
-
231
- it("returns an MCP tool error (not a transport crash) for an unknown tool", async () => {
232
- const client = new Client({ name: "test-client-err", version: "1.0.0" }, { capabilities: {} });
233
- const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`));
234
- await client.connect(transport);
235
-
236
- const result = (await client.callTool({ name: "does_not_exist", arguments: {} })) as {
237
- content: Array<{ type: string; text: string }>;
238
- isError?: boolean;
239
- };
240
- expect(result.isError).toBe(true);
241
- expect(result.content[0].text).toMatch(/unknown tool/i);
242
-
243
- await client.close();
244
- }, 20_000);
245
-
246
- it("lists + reads an MCP resource", async () => {
247
- const client = new Client({ name: "test-client-res", version: "1.0.0" }, { capabilities: {} });
248
- const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`));
249
- await client.connect(transport);
250
-
251
- const resources = await client.listResources();
252
- expect(resources.resources.map((r) => r.uri)).toContain("test://agents");
253
-
254
- const read = await client.readResource({ uri: "test://agents" });
255
- const payload = JSON.parse(read.contents[0].text as string) as { agents: string[] };
256
- expect(payload.agents).toEqual(["codebase", "infra"]);
257
-
258
- await client.close();
259
- }, 20_000);
260
-
261
- it("parses x-user-context and passes it to the workflow ctx", async () => {
262
- const userCtx = Buffer.from(JSON.stringify({ userId: "u-1", email: "dev@tetrix.io" }), "utf-8").toString("base64");
263
- const client = new Client({ name: "test-client-ctx", version: "1.0.0" }, { capabilities: {} });
264
- const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), {
265
- requestInit: { headers: { "x-user-context": userCtx } },
266
- });
267
- await client.connect(transport);
268
-
269
- const result = (await client.callTool({ name: "whoami", arguments: {} })) as {
270
- content: Array<{ type: string; text: string }>;
271
- };
272
- const payload = JSON.parse(result.content[0].text) as { userId?: string; email?: string };
273
- expect(payload).toEqual({ userId: "u-1", email: "dev@tetrix.io" });
274
-
275
- await client.close();
276
- }, 20_000);
277
- });
package/src/McpTrigger.ts DELETED
@@ -1,555 +0,0 @@
1
- /**
2
- * McpTrigger — Model Context Protocol trigger. Exposes Blok workflows as
3
- * MCP **tools** (and **resources**) to external clients (Cursor, Claude
4
- * Code, …) over two transports multiplexed on the shared Hono port:
5
- *
6
- * - **SSE (legacy 2024-11-05)** — `GET <path>/sse` opens the stream and
7
- * announces `POST <path>/messages?sessionId=…` for JSON-RPC. This is the
8
- * 2-endpoint shape existing IDE configs expect (drop-in parity).
9
- * - **Streamable-HTTP** — a single `<path>` endpoint (the current official
10
- * remote transport), served statelessly via the SDK's web-standard
11
- * transport directly off `c.req.raw`.
12
- *
13
- * **Authoring surface** — a workflow opts in with `trigger.mcp`:
14
- *
15
- * ```ts
16
- * export default workflow({
17
- * name: "search_code",
18
- * version: "1.0.0",
19
- * input: z.object({ query: z.string(), limit: z.number().optional() }),
20
- * trigger: { mcp: { path: "/mcp", serverName: "tetrix-platform",
21
- * tool: { description: "Full-text search the indexed code" } } },
22
- * steps: [ { id: "search", use: "@tetrix/meili-search", inputs: { query: $.req.body.query } } ],
23
- * });
24
- * ```
25
- *
26
- * Every workflow sharing the same `path` + `serverName` is aggregated into one
27
- * MCP server. Each tool's `inputSchema` is generated from the workflow's `input`
28
- * Zod schema (via zod-to-json-schema). On `tools/call` the trigger runs the
29
- * mapped workflow through the runner — so every call is a Blok Studio run with
30
- * tracing / retries / idempotency for free.
31
- *
32
- * **Hono integration:** identical to `SSETrigger`/`WebhookTrigger` — accepts the
33
- * shared `Hono` app + an optional `HttpTriggerLike` exposing `addPreCatchAllHook`
34
- * so routes register AFTER the workflow registry is populated but BEFORE the
35
- * legacy `/:workflow{.+}` catch-all.
36
- *
37
- * **Identity:** an `x-user-context` header (or `?user_context=`) carrying base64
38
- * `{userId,email}` is parsed per connection and passed to the workflow ctx for
39
- * credential injection. It is NOT access control — there is no scoping here
40
- * (that's the app's call). Tokens are never logged (the global sanitizer middleware
41
- * handles redaction).
42
- */
43
-
44
- import type { IncomingMessage, ServerResponse } from "node:http";
45
- import {
46
- DefaultLogger,
47
- type GlobalOptions as RunnerGlobalOptions,
48
- TriggerBase,
49
- WorkflowRegistry,
50
- } from "@blokjs/runner";
51
- import type { Context, RequestContext } from "@blokjs/shared";
52
- import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
53
- import { Server as McpSdkServer } from "@modelcontextprotocol/sdk/server/index.js";
54
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
55
- import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
56
- import {
57
- CallToolRequestSchema,
58
- ListResourcesRequestSchema,
59
- ListToolsRequestSchema,
60
- ReadResourceRequestSchema,
61
- } from "@modelcontextprotocol/sdk/types.js";
62
- import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
63
- import type { Hono, Context as HonoContext } from "hono";
64
- import { v4 as uuid } from "uuid";
65
- import { zodToJsonSchema } from "zod-to-json-schema";
66
-
67
- // -----------------------------------------------------------------------------
68
- // Types
69
- // -----------------------------------------------------------------------------
70
-
71
- /** Parsed `x-user-context` identity (credential injection only — never scoping). */
72
- export interface McpUserContext {
73
- userId: string;
74
- email: string;
75
- }
76
-
77
- type McpTransportKind = "sse" | "streamable-http";
78
-
79
- interface McpToolMeta {
80
- name?: string;
81
- description?: string;
82
- }
83
- interface McpResourceMeta {
84
- uri: string;
85
- name?: string;
86
- description?: string;
87
- mimeType?: string;
88
- }
89
-
90
- /** Loosely-read `trigger.mcp` config (validated at workflow load by the helper). */
91
- interface McpTriggerConfig {
92
- path: string;
93
- serverName?: string;
94
- serverVersion?: string;
95
- transports?: McpTransportKind[];
96
- tool?: McpToolMeta;
97
- resource?: McpResourceMeta;
98
- middleware?: string[];
99
- }
100
-
101
- interface HttpTriggerLike {
102
- addPreCatchAllHook(cb: () => void | Promise<void>): void;
103
- }
104
-
105
- /** A workflow exposed as an MCP tool. */
106
- interface ToolEntry {
107
- workflowName: string;
108
- toolName: string;
109
- description: string;
110
- // biome-ignore lint/suspicious/noExplicitAny: workflow `input` is an opaque ZodType
111
- inputZod: any | undefined;
112
- }
113
-
114
- /** A workflow exposed as an MCP resource. */
115
- interface ResourceEntry {
116
- workflowName: string;
117
- uri: string;
118
- name: string;
119
- description?: string;
120
- mimeType: string;
121
- }
122
-
123
- /** All workflows that share a (path, serverName) form one MCP server. */
124
- interface ServerGroup {
125
- path: string;
126
- serverName: string;
127
- serverVersion: string;
128
- transports: McpTransportKind[];
129
- tools: ToolEntry[];
130
- resources: ResourceEntry[];
131
- }
132
-
133
- const DEFAULT_SERVER_NAME = "blok-mcp";
134
- const DEFAULT_SERVER_VERSION = "1.0.0";
135
- const DEFAULT_TRANSPORTS: McpTransportKind[] = ["sse", "streamable-http"];
136
-
137
- // -----------------------------------------------------------------------------
138
- // Helpers
139
- // -----------------------------------------------------------------------------
140
-
141
- /** Parse a base64-encoded `{userId,email}` identity string. Returns null on any failure. */
142
- export function parseUserContext(value: string | undefined | null): McpUserContext | null {
143
- if (!value || typeof value !== "string") return null;
144
- try {
145
- const decoded = Buffer.from(value, "base64").toString("utf-8");
146
- if (!decoded || decoded.trim() === "null") return null;
147
- const ctx = JSON.parse(decoded) as { userId?: string; email?: string };
148
- if (!ctx || typeof ctx.userId !== "string") return null;
149
- return { userId: ctx.userId, email: typeof ctx.email === "string" ? ctx.email : "unknown" };
150
- } catch {
151
- return null;
152
- }
153
- }
154
-
155
- /** Convert a Zod schema to a JSON-Schema object suitable for an MCP tool `inputSchema`. */
156
- // biome-ignore lint/suspicious/noExplicitAny: zod schema is opaque here
157
- function toInputJsonSchema(inputZod: any | undefined): { type: "object"; [k: string]: unknown } {
158
- const empty = { type: "object" as const, properties: {}, additionalProperties: true };
159
- if (!inputZod || typeof inputZod !== "object" || typeof inputZod.safeParse !== "function") {
160
- return empty;
161
- }
162
- try {
163
- const json = zodToJsonSchema(inputZod, { target: "jsonSchema7", $refStrategy: "none" }) as Record<string, unknown>;
164
- // biome-ignore lint/performance/noDelete: strip JSON-Schema meta the MCP client doesn't need
165
- delete json.$schema;
166
- if (json.type !== "object") {
167
- return { ...empty, _wrapped: json } as { type: "object"; [k: string]: unknown };
168
- }
169
- return json as { type: "object"; [k: string]: unknown };
170
- } catch {
171
- return empty;
172
- }
173
- }
174
-
175
- // -----------------------------------------------------------------------------
176
- // Trigger
177
- // -----------------------------------------------------------------------------
178
-
179
- export default class McpTrigger extends TriggerBase {
180
- protected nodeMap: RunnerGlobalOptions = {} as RunnerGlobalOptions;
181
- protected readonly logger = new DefaultLogger();
182
- protected readonly tracer = trace.getTracer(
183
- process.env.PROJECT_NAME || "trigger-mcp-workflow",
184
- process.env.PROJECT_VERSION || "0.0.1",
185
- );
186
- private readonly meter = metrics.getMeter("blok");
187
- private readonly counterToolCalls = this.meter.createCounter("blok_mcp_tool_calls_total", {
188
- description: "MCP tools/call dispatches.",
189
- unit: "1",
190
- });
191
- private readonly counterSessions = this.meter.createCounter("blok_mcp_sse_sessions_total", {
192
- description: "MCP SSE sessions opened.",
193
- unit: "1",
194
- });
195
-
196
- // biome-ignore lint/suspicious/noExplicitAny: Hono generic propagation (matches SSE/WS triggers)
197
- private readonly app: Hono<any, any, any>;
198
- private readonly httpTrigger: HttpTriggerLike | null;
199
- private wired = false;
200
-
201
- /** Live SSE sessions: sessionId → { transport, server, userContext }. */
202
- private sseSessions = new Map<
203
- string,
204
- { transport: SSEServerTransport; server: McpSdkServer; userContext: McpUserContext | null }
205
- >();
206
-
207
- // biome-ignore lint/suspicious/noExplicitAny: matches `app` field generic
208
- constructor(app: Hono<any, any, any>, httpTrigger?: HttpTriggerLike) {
209
- super();
210
- this.app = app;
211
- this.httpTrigger = httpTrigger ?? null;
212
- _setActiveMcpTrigger(this);
213
- }
214
-
215
- /** Inject the runner's GlobalOptions (nodes + workflows). Called before `listen()`. */
216
- setNodeMap(nodeMap: RunnerGlobalOptions): void {
217
- this.nodeMap = nodeMap;
218
- }
219
-
220
- async listen(): Promise<number> {
221
- const startTime = this.startCounter();
222
- if (this.wired) {
223
- this.logger.log("[blok][mcp] listen() called twice; ignoring");
224
- return this.endCounter(startTime);
225
- }
226
- this.wired = true;
227
-
228
- if (this.httpTrigger) {
229
- this.httpTrigger.addPreCatchAllHook(() => this.registerRoutesFromRegistry());
230
- } else {
231
- this.registerRoutesFromRegistry();
232
- }
233
- return this.endCounter(startTime);
234
- }
235
-
236
- async stop(): Promise<void> {
237
- for (const { transport } of this.sseSessions.values()) {
238
- try {
239
- await transport.close();
240
- } catch {
241
- /* ignore */
242
- }
243
- }
244
- this.sseSessions.clear();
245
- this.wired = false;
246
- if (_getActiveMcpTrigger() === this) _setActiveMcpTrigger(null);
247
- this.destroyMonitoring();
248
- this.logger.log("[blok][mcp] stopped");
249
- }
250
-
251
- getStats(): { sessions: number } {
252
- return { sessions: this.sseSessions.size };
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // Registry scan + grouping
257
- // ---------------------------------------------------------------------------
258
-
259
- private registerRoutesFromRegistry(): void {
260
- const groups = this.getServerGroups();
261
- if (groups.length === 0) {
262
- this.logger.log("[blok][mcp] no workflows with trigger.mcp found");
263
- return;
264
- }
265
- for (const group of groups) {
266
- this.registerGroupRoutes(group);
267
- }
268
- }
269
-
270
- private getServerGroups(): ServerGroup[] {
271
- const registry = WorkflowRegistry.getInstance();
272
- const byPath = new Map<string, ServerGroup>();
273
-
274
- for (const entry of registry.list()) {
275
- // Workflows registered as builders expose config on `_config`; plain
276
- // objects (tests / JSON) expose it at the top level.
277
- const wf = (entry.workflow as { _config?: unknown })?._config ?? entry.workflow;
278
- const cfg = (wf as { trigger?: { mcp?: McpTriggerConfig } })?.trigger?.mcp;
279
- if (!cfg || typeof cfg.path !== "string") continue;
280
-
281
- const path = cfg.path;
282
- let group = byPath.get(path);
283
- if (!group) {
284
- group = {
285
- path,
286
- serverName: cfg.serverName || DEFAULT_SERVER_NAME,
287
- serverVersion: cfg.serverVersion || DEFAULT_SERVER_VERSION,
288
- transports: Array.isArray(cfg.transports) && cfg.transports.length > 0 ? cfg.transports : DEFAULT_TRANSPORTS,
289
- tools: [],
290
- resources: [],
291
- };
292
- byPath.set(path, group);
293
- }
294
-
295
- if (cfg.resource && typeof cfg.resource.uri === "string") {
296
- group.resources.push({
297
- workflowName: entry.name,
298
- uri: cfg.resource.uri,
299
- name: cfg.resource.name || entry.name,
300
- description: cfg.resource.description,
301
- mimeType: cfg.resource.mimeType || "application/json",
302
- });
303
- } else {
304
- const inputZod = (wf as { input?: unknown })?.input;
305
- group.tools.push({
306
- workflowName: entry.name,
307
- toolName: cfg.tool?.name || entry.name,
308
- description: cfg.tool?.description || `Run the "${entry.name}" workflow.`,
309
- inputZod,
310
- });
311
- }
312
- }
313
-
314
- return [...byPath.values()];
315
- }
316
-
317
- // ---------------------------------------------------------------------------
318
- // MCP server factory (one per request/session, bound to a user context)
319
- // ---------------------------------------------------------------------------
320
-
321
- private buildServer(group: ServerGroup, getUserContext: () => McpUserContext | null): McpSdkServer {
322
- const server = new McpSdkServer(
323
- { name: group.serverName, version: group.serverVersion },
324
- { capabilities: { tools: {}, resources: {} } },
325
- );
326
-
327
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
328
- tools: group.tools.map((t) => ({
329
- name: t.toolName,
330
- description: t.description,
331
- inputSchema: toInputJsonSchema(t.inputZod),
332
- })),
333
- }));
334
-
335
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
336
- const toolName = req.params.name;
337
- const tool = group.tools.find((t) => t.toolName === toolName);
338
- if (!tool) {
339
- return { content: [{ type: "text", text: `Unknown tool: ${toolName}` }], isError: true };
340
- }
341
- const args = (req.params.arguments ?? {}) as Record<string, unknown>;
342
- return this.dispatchTool(tool, args, getUserContext());
343
- });
344
-
345
- if (group.resources.length > 0) {
346
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({
347
- resources: group.resources.map((r) => ({
348
- uri: r.uri,
349
- name: r.name,
350
- description: r.description,
351
- mimeType: r.mimeType,
352
- })),
353
- }));
354
-
355
- server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
356
- const uri = req.params.uri;
357
- const resource = group.resources.find((r) => r.uri === uri);
358
- if (!resource) throw new Error(`Unknown resource: ${uri}`);
359
- const result = await this.dispatchResource(resource, getUserContext());
360
- const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
361
- return { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text }] };
362
- });
363
- }
364
-
365
- return server;
366
- }
367
-
368
- // ---------------------------------------------------------------------------
369
- // Workflow dispatch (tools/call + resources/read run through the runner)
370
- // ---------------------------------------------------------------------------
371
-
372
- private async dispatchTool(
373
- tool: ToolEntry,
374
- args: Record<string, unknown>,
375
- userContext: McpUserContext | null,
376
- ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
377
- this.counterToolCalls.add(1, { tool: tool.toolName });
378
- try {
379
- const data = await this.runWorkflow(tool.workflowName, args, userContext, `mcp.tool:${tool.toolName}`);
380
- const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
381
- return { content: [{ type: "text", text }] };
382
- } catch (err) {
383
- const msg = err instanceof Error ? err.message : String(err);
384
- this.logger.error(`[blok][mcp] tool "${tool.toolName}" failed: ${msg}`);
385
- // Tool failure → MCP tool error result, NOT a transport crash.
386
- return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
387
- }
388
- }
389
-
390
- private async dispatchResource(resource: ResourceEntry, userContext: McpUserContext | null): Promise<unknown> {
391
- return this.runWorkflow(resource.workflowName, {}, userContext, `mcp.resource:${resource.uri}`);
392
- }
393
-
394
- /**
395
- * Run a workflow through the runner and return `ctx.response.data`. Mirrors
396
- * the WebhookTrigger request→response dispatch (init → context → middleware →
397
- * run). MCP tool calls within a session are serial, matching the shared
398
- * `this.configuration` lifecycle the other triggers use.
399
- */
400
- private async runWorkflow(
401
- workflowName: string,
402
- args: Record<string, unknown>,
403
- userContext: McpUserContext | null,
404
- spanLabel: string,
405
- ): Promise<unknown> {
406
- const requestId = uuid();
407
- return this.tracer.startActiveSpan(`mcp:${workflowName}`, async (span: Span) => {
408
- try {
409
- const registry = WorkflowRegistry.getInstance();
410
- const entry = registry.get(workflowName);
411
- if (!entry) throw new Error(`workflow "${workflowName}" not found in registry`);
412
- await this.configuration.init(workflowName, this.nodeMap, entry.workflow);
413
-
414
- const headers: Record<string, string> = {};
415
- if (userContext) {
416
- // Carry identity to the workflow exactly as HTTP would (base64 header).
417
- headers["x-user-context"] = Buffer.from(JSON.stringify(userContext), "utf-8").toString("base64");
418
- }
419
-
420
- const ctx: Context = this.createContext(undefined, workflowName, requestId);
421
- ctx.request = {
422
- body: args,
423
- headers,
424
- params: {},
425
- query: {},
426
- } as unknown as RequestContext;
427
- (ctx as Record<string, unknown>)._mcp = { userContext };
428
-
429
- await this.applyMiddlewareChain(ctx, this.nodeMap);
430
- await this.run(ctx);
431
-
432
- span.setAttribute("workflow_name", workflowName);
433
- span.setAttribute("mcp_label", spanLabel);
434
- span.setStatus({ code: SpanStatusCode.OK });
435
- return ctx.response?.data;
436
- } catch (err) {
437
- const msg = err instanceof Error ? err.message : String(err);
438
- span.recordException(err as Error);
439
- span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
440
- throw err;
441
- } finally {
442
- span.end();
443
- }
444
- });
445
- }
446
-
447
- // ---------------------------------------------------------------------------
448
- // Route registration (SSE + Streamable-HTTP)
449
- // ---------------------------------------------------------------------------
450
-
451
- private registerGroupRoutes(group: ServerGroup): void {
452
- this.logger.log(
453
- `[blok][mcp] server "${group.serverName}" at ${group.path} — ${group.tools.length} tool(s), ${group.resources.length} resource(s), transports=[${group.transports.join(",")}]`,
454
- );
455
-
456
- if (group.transports.includes("sse")) {
457
- this.registerSseRoutes(group);
458
- }
459
- if (group.transports.includes("streamable-http")) {
460
- this.registerStreamableHttpRoute(group);
461
- }
462
- }
463
-
464
- private registerSseRoutes(group: ServerGroup): void {
465
- const ssePath = `${group.path}/sse`;
466
- const messagesPath = `${group.path}/messages`;
467
- this.logger.log(`[blok][mcp] GET ${ssePath} POST ${messagesPath} (sse)`);
468
-
469
- // GET <path>/sse — open the SSE stream; SDK announces the messages endpoint.
470
- this.app.get(ssePath, async (c: HonoContext) => {
471
- const env = c.env as unknown as { incoming: IncomingMessage; outgoing: ServerResponse };
472
- if (!env?.outgoing) {
473
- return c.text("MCP SSE transport requires the Node server (@hono/node-server).", 500);
474
- }
475
- const rawUserCtx =
476
- c.req.header("x-user-context") || (new URL(c.req.url).searchParams.get("user_context") ?? undefined);
477
- const userContext = parseUserContext(rawUserCtx);
478
-
479
- const transport = new SSEServerTransport(messagesPath, env.outgoing);
480
- const sessionId = transport.sessionId;
481
- const server = this.buildServer(group, () => this.sseSessions.get(sessionId)?.userContext ?? null);
482
- this.sseSessions.set(sessionId, { transport, server, userContext });
483
- this.counterSessions.add(1, { server: group.serverName });
484
-
485
- transport.onclose = () => {
486
- this.sseSessions.delete(sessionId);
487
- };
488
-
489
- try {
490
- await server.connect(transport);
491
- } catch (err) {
492
- this.sseSessions.delete(sessionId);
493
- const msg = err instanceof Error ? err.message : String(err);
494
- this.logger.error(`[blok][mcp] sse connect failed: ${msg}`);
495
- }
496
- return RESPONSE_ALREADY_SENT;
497
- });
498
-
499
- // POST <path>/messages?sessionId=… — JSON-RPC messages for an open stream.
500
- this.app.post(messagesPath, async (c: HonoContext) => {
501
- const env = c.env as unknown as { incoming: IncomingMessage; outgoing: ServerResponse };
502
- const sessionId = new URL(c.req.url).searchParams.get("sessionId") ?? undefined;
503
- if (!sessionId) return c.text("Missing sessionId query parameter", 400);
504
- const session = this.sseSessions.get(sessionId);
505
- if (!session) return c.text("Session not found", 404);
506
- const body = await c.req.json().catch(() => undefined);
507
- try {
508
- await session.transport.handlePostMessage(env.incoming, env.outgoing, body);
509
- } catch (err) {
510
- const msg = err instanceof Error ? err.message : String(err);
511
- this.logger.error(`[blok][mcp] handlePostMessage failed: ${msg}`);
512
- if (!env.outgoing.headersSent) return c.text("Error handling MCP message", 500);
513
- }
514
- return RESPONSE_ALREADY_SENT;
515
- });
516
- }
517
-
518
- private registerStreamableHttpRoute(group: ServerGroup): void {
519
- this.logger.log(`[blok][mcp] ALL ${group.path} (streamable-http)`);
520
-
521
- // Stateless Streamable-HTTP: a fresh server + transport per request, served
522
- // directly off the Fetch `Request` (works on any Hono runtime).
523
- this.app.all(group.path, async (c: HonoContext) => {
524
- const rawUserCtx =
525
- c.req.header("x-user-context") || (new URL(c.req.url).searchParams.get("user_context") ?? undefined);
526
- const userContext = parseUserContext(rawUserCtx);
527
-
528
- const server = this.buildServer(group, () => userContext);
529
- const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
530
- try {
531
- await server.connect(transport);
532
- const res = await transport.handleRequest(c.req.raw);
533
- return res;
534
- } catch (err) {
535
- const msg = err instanceof Error ? err.message : String(err);
536
- this.logger.error(`[blok][mcp] streamable-http request failed: ${msg}`);
537
- return c.json({ jsonrpc: "2.0", error: { code: -32603, message: msg }, id: null }, 500);
538
- }
539
- });
540
- }
541
- }
542
-
543
- // -----------------------------------------------------------------------------
544
- // Singleton accessor (parity with SSE/Webhook triggers)
545
- // -----------------------------------------------------------------------------
546
-
547
- let activeTrigger: McpTrigger | null = null;
548
- export function _setActiveMcpTrigger(trigger: McpTrigger | null): void {
549
- activeTrigger = trigger;
550
- }
551
- export function _getActiveMcpTrigger(): McpTrigger | null {
552
- return activeTrigger;
553
- }
554
-
555
- export type { McpTriggerConfig };
package/src/index.ts DELETED
@@ -1,41 +0,0 @@
1
- /**
2
- * @blokjs/trigger-mcp
3
- *
4
- * Model Context Protocol (MCP) trigger for Blok workflows. Exposes workflows as
5
- * MCP tools + resources to external clients (Cursor, Claude Code, …) over SSE
6
- * (legacy 2-endpoint) and Streamable-HTTP transports, multiplexed on the shared
7
- * Hono port alongside HTTP / WS / SSE / Webhook routes — same registry, same
8
- * runner, same Studio tracing.
9
- *
10
- * A workflow opts in with `trigger.mcp` and declares its tool input via the
11
- * workflow's `input` Zod schema:
12
- *
13
- * ```ts
14
- * import { workflow, $ } from "@blokjs/helper";
15
- * import { z } from "zod";
16
- *
17
- * export default workflow({
18
- * name: "search_code",
19
- * version: "1.0.0",
20
- * input: z.object({ query: z.string() }),
21
- * trigger: { mcp: { path: "/mcp", serverName: "tetrix-platform",
22
- * tool: { description: "Search the indexed codebase" } } },
23
- * steps: [ { id: "s", use: "@tetrix/meili-search", inputs: { query: $.req.body.query } } ],
24
- * });
25
- * ```
26
- *
27
- * Mounting (in an app's HTTP entry, mirroring SSE/Webhook):
28
- *
29
- * ```ts
30
- * const mcp = new McpTrigger(httpTrigger.getApp(), httpTrigger);
31
- * mcp.setNodeMap({ nodes, workflows });
32
- * await mcp.listen();
33
- * ```
34
- */
35
-
36
- import McpTrigger, { _getActiveMcpTrigger, _setActiveMcpTrigger, parseUserContext } from "./McpTrigger";
37
-
38
- export default McpTrigger;
39
- export { McpTrigger, _getActiveMcpTrigger, _setActiveMcpTrigger, parseUserContext };
40
- export type { McpTriggerConfig, McpUserContext } from "./McpTrigger";
41
- export type { McpTriggerOpts } from "@blokjs/helper";
package/tsconfig.json DELETED
@@ -1,32 +0,0 @@
1
- {
2
- "ts-node": {
3
- "transpileOnly": true
4
- },
5
- "compilerOptions": {
6
- "target": "ES2022",
7
- "module": "es2022",
8
- "lib": ["ES2022"],
9
- "declaration": true,
10
- "strict": true,
11
- "noImplicitAny": true,
12
- "strictNullChecks": true,
13
- "noImplicitThis": true,
14
- "alwaysStrict": true,
15
- "noUnusedLocals": false,
16
- "noUnusedParameters": false,
17
- "noImplicitReturns": true,
18
- "noFallthroughCasesInSwitch": false,
19
- "inlineSourceMap": true,
20
- "inlineSources": true,
21
- "experimentalDecorators": true,
22
- "emitDecoratorMetadata": true,
23
- "skipLibCheck": true,
24
- "esModuleInterop": true,
25
- "resolveJsonModule": true,
26
- "outDir": "./dist",
27
- "rootDir": "./src",
28
- "moduleResolution": "bundler"
29
- },
30
- "include": ["src/**/*"],
31
- "exclude": ["node_modules", "dist", "**/*.test.ts"]
32
- }