@blokjs/trigger-mcp 0.6.18 → 0.6.19
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/package.json +5 -4
- package/src/McpTrigger.integration.test.ts +0 -277
- package/src/McpTrigger.ts +0 -555
- package/src/index.ts +0 -41
- package/tsconfig.json +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blokjs/trigger-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.19",
|
|
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
|
-
"@blokjs/runner": "^0.6.
|
|
19
|
-
"@blokjs/shared": "^0.6.
|
|
18
|
+
"@blokjs/helper": "^0.6.19",
|
|
19
|
+
"@blokjs/runner": "^0.6.19",
|
|
20
|
+
"@blokjs/shared": "^0.6.19",
|
|
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
|
-
}
|