@blokjs/trigger-mcp 0.6.12

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.
@@ -0,0 +1,39 @@
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
+ import McpTrigger, { _getActiveMcpTrigger, _setActiveMcpTrigger, parseUserContext } from "./McpTrigger";
36
+ export default McpTrigger;
37
+ export { McpTrigger, _getActiveMcpTrigger, _setActiveMcpTrigger, parseUserContext };
38
+ export type { McpTriggerConfig, McpUserContext } from "./McpTrigger";
39
+ export type { McpTriggerOpts } from "@blokjs/helper";
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
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
+ import McpTrigger, { _getActiveMcpTrigger, _setActiveMcpTrigger, parseUserContext } from "./McpTrigger";
36
+ export default McpTrigger;
37
+ export { McpTrigger, _getActiveMcpTrigger, _setActiveMcpTrigger, parseUserContext };
38
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7OztHQWlDRztBQUVILE9BQU8sVUFBVSxFQUFFLEVBQUUsb0JBQW9CLEVBQUUsb0JBQW9CLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFFeEcsZUFBZSxVQUFVLENBQUM7QUFDMUIsT0FBTyxFQUFFLFVBQVUsRUFBRSxvQkFBb0IsRUFBRSxvQkFBb0IsRUFBRSxnQkFBZ0IsRUFBRSxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBAYmxva2pzL3RyaWdnZXItbWNwXG4gKlxuICogTW9kZWwgQ29udGV4dCBQcm90b2NvbCAoTUNQKSB0cmlnZ2VyIGZvciBCbG9rIHdvcmtmbG93cy4gRXhwb3NlcyB3b3JrZmxvd3MgYXNcbiAqIE1DUCB0b29scyArIHJlc291cmNlcyB0byBleHRlcm5hbCBjbGllbnRzIChDdXJzb3IsIENsYXVkZSBDb2RlLCDigKYpIG92ZXIgU1NFXG4gKiAobGVnYWN5IDItZW5kcG9pbnQpIGFuZCBTdHJlYW1hYmxlLUhUVFAgdHJhbnNwb3J0cywgbXVsdGlwbGV4ZWQgb24gdGhlIHNoYXJlZFxuICogSG9ubyBwb3J0IGFsb25nc2lkZSBIVFRQIC8gV1MgLyBTU0UgLyBXZWJob29rIHJvdXRlcyDigJQgc2FtZSByZWdpc3RyeSwgc2FtZVxuICogcnVubmVyLCBzYW1lIFN0dWRpbyB0cmFjaW5nLlxuICpcbiAqIEEgd29ya2Zsb3cgb3B0cyBpbiB3aXRoIGB0cmlnZ2VyLm1jcGAgYW5kIGRlY2xhcmVzIGl0cyB0b29sIGlucHV0IHZpYSB0aGVcbiAqIHdvcmtmbG93J3MgYGlucHV0YCBab2Qgc2NoZW1hOlxuICpcbiAqIGBgYHRzXG4gKiBpbXBvcnQgeyB3b3JrZmxvdywgJCB9IGZyb20gXCJAYmxva2pzL2hlbHBlclwiO1xuICogaW1wb3J0IHsgeiB9IGZyb20gXCJ6b2RcIjtcbiAqXG4gKiBleHBvcnQgZGVmYXVsdCB3b3JrZmxvdyh7XG4gKiAgIG5hbWU6IFwic2VhcmNoX2NvZGVcIixcbiAqICAgdmVyc2lvbjogXCIxLjAuMFwiLFxuICogICBpbnB1dDogei5vYmplY3QoeyBxdWVyeTogei5zdHJpbmcoKSB9KSxcbiAqICAgdHJpZ2dlcjogeyBtY3A6IHsgcGF0aDogXCIvbWNwXCIsIHNlcnZlck5hbWU6IFwidGV0cml4LXBsYXRmb3JtXCIsXG4gKiAgICAgICAgICAgICAgICAgICAgIHRvb2w6IHsgZGVzY3JpcHRpb246IFwiU2VhcmNoIHRoZSBpbmRleGVkIGNvZGViYXNlXCIgfSB9IH0sXG4gKiAgIHN0ZXBzOiBbIHsgaWQ6IFwic1wiLCB1c2U6IFwiQHRldHJpeC9tZWlsaS1zZWFyY2hcIiwgaW5wdXRzOiB7IHF1ZXJ5OiAkLnJlcS5ib2R5LnF1ZXJ5IH0gfSBdLFxuICogfSk7XG4gKiBgYGBcbiAqXG4gKiBNb3VudGluZyAoaW4gYW4gYXBwJ3MgSFRUUCBlbnRyeSwgbWlycm9yaW5nIFNTRS9XZWJob29rKTpcbiAqXG4gKiBgYGB0c1xuICogY29uc3QgbWNwID0gbmV3IE1jcFRyaWdnZXIoaHR0cFRyaWdnZXIuZ2V0QXBwKCksIGh0dHBUcmlnZ2VyKTtcbiAqIG1jcC5zZXROb2RlTWFwKHsgbm9kZXMsIHdvcmtmbG93cyB9KTtcbiAqIGF3YWl0IG1jcC5saXN0ZW4oKTtcbiAqIGBgYFxuICovXG5cbmltcG9ydCBNY3BUcmlnZ2VyLCB7IF9nZXRBY3RpdmVNY3BUcmlnZ2VyLCBfc2V0QWN0aXZlTWNwVHJpZ2dlciwgcGFyc2VVc2VyQ29udGV4dCB9IGZyb20gXCIuL01jcFRyaWdnZXJcIjtcblxuZXhwb3J0IGRlZmF1bHQgTWNwVHJpZ2dlcjtcbmV4cG9ydCB7IE1jcFRyaWdnZXIsIF9nZXRBY3RpdmVNY3BUcmlnZ2VyLCBfc2V0QWN0aXZlTWNwVHJpZ2dlciwgcGFyc2VVc2VyQ29udGV4dCB9O1xuZXhwb3J0IHR5cGUgeyBNY3BUcmlnZ2VyQ29uZmlnLCBNY3BVc2VyQ29udGV4dCB9IGZyb20gXCIuL01jcFRyaWdnZXJcIjtcbmV4cG9ydCB0eXBlIHsgTWNwVHJpZ2dlck9wdHMgfSBmcm9tIFwiQGJsb2tqcy9oZWxwZXJcIjtcbiJdfQ==
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@blokjs/trigger-mcp",
3
+ "version": "0.6.12",
4
+ "description": "Model Context Protocol (MCP) trigger for Blok workflows — expose workflows as MCP tools over SSE + Streamable-HTTP on the shared Hono port.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "rm -rf dist && bun run tsc",
10
+ "build:dev": "tsc --watch",
11
+ "test": "vitest run",
12
+ "test:dev": "vitest"
13
+ },
14
+ "author": "Deskree Technologies Inc.",
15
+ "license": "Apache-2.0",
16
+ "dependencies": {
17
+ "@blokjs/helper": "^0.6.12",
18
+ "@blokjs/runner": "^0.6.12",
19
+ "@blokjs/shared": "^0.6.12",
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
21
+ "@opentelemetry/api": "^1.9.0",
22
+ "hono": "^4.11.7",
23
+ "uuid": "^11.1.0",
24
+ "zod": "^3.24.2",
25
+ "zod-to-json-schema": "^3.24.0"
26
+ },
27
+ "devDependencies": {
28
+ "@hono/node-server": "^1.19.9",
29
+ "@types/node": "^22.15.21",
30
+ "@types/uuid": "^11.0.0",
31
+ "typescript": "^5.8.3",
32
+ "vitest": "^4.0.18"
33
+ },
34
+ "private": false,
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,277 @@
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
+ });