@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.
- package/dist/McpTrigger.d.ts +114 -0
- package/dist/McpTrigger.js +423 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +38 -0
- package/package.json +38 -0
- package/src/McpTrigger.integration.test.ts +277 -0
- package/src/McpTrigger.ts +555 -0
- package/src/index.ts +41 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,555 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|