@contextual-io/cli 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @contextual-io/cli
20
20
  $ ctxl COMMAND
21
21
  running command...
22
22
  $ ctxl (--version)
23
- @contextual-io/cli/0.7.1 linux-x64 node-v25.9.0
23
+ @contextual-io/cli/0.8.0 linux-x64 node-v25.9.0
24
24
  $ ctxl --help [COMMAND]
25
25
  USAGE
26
26
  $ ctxl COMMAND
@@ -39,6 +39,8 @@ USAGE
39
39
  * [`ctxl config login`](#ctxl-config-login)
40
40
  * [`ctxl config use CONFIG-ID`](#ctxl-config-use-config-id)
41
41
  * [`ctxl help [COMMAND]`](#ctxl-help-command)
42
+ * [`ctxl mcp <COMMAND>`](#ctxl-mcp-command)
43
+ * [`ctxl mcp serve [INTERFACE]`](#ctxl-mcp-serve-interface)
42
44
  * [`ctxl records <COMMAND>`](#ctxl-records-command)
43
45
  * [`ctxl records add [URI]`](#ctxl-records-add-uri)
44
46
  * [`ctxl records create [URI]`](#ctxl-records-create-uri)
@@ -259,6 +261,55 @@ DESCRIPTION
259
261
 
260
262
  _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.36/src/commands/help.ts)_
261
263
 
264
+ ## `ctxl mcp <COMMAND>`
265
+
266
+ Manage local MCP server commands. Start with 'ctxl mcp serve' to run the local MCP bridge.
267
+
268
+ ```
269
+ USAGE
270
+ $ ctxl mcp <COMMAND>
271
+
272
+ DESCRIPTION
273
+ Manage local MCP server commands. Start with 'ctxl mcp serve' to run the local MCP bridge.
274
+
275
+ EXAMPLES
276
+ $ ctxl mcp
277
+
278
+ $ ctxl mcp flow-editor --flow my-flow-id
279
+ ```
280
+
281
+ ## `ctxl mcp serve [INTERFACE]`
282
+
283
+ Start a local MCP HTTP server that connects to SolutionAI for a given interface type. By default it binds to http://localhost:5051/. The server fetches the full tool manifest from SolutionAI and serves all tools immediately. Pass a flowId with each tool call to target a specific flow, or use list_sessions to discover available flows.
284
+
285
+ ```
286
+ USAGE
287
+ $ ctxl mcp serve [INTERFACE] [-C <value>] [-f <value>] [-p <value>] [-t] [-V]
288
+
289
+ ARGUMENTS
290
+ [INTERFACE] [default: flow-editor] Interface type to scope tools for. Available interfaces depend on your platform
291
+ version.
292
+
293
+ FLAGS
294
+ -V, --verbose emit verbose MCP runtime diagnostics
295
+ -f, --flow=<value> pre-filter session listing to a specific flow ID
296
+ -p, --port=<value> local HTTP port (default: 5051)
297
+ -t, --[no-]tool-prefix prefix all MCP tool names with ctxl_
298
+
299
+ GLOBAL FLAGS
300
+ -C, --config-id=<value> Specify config id to use for call.
301
+
302
+ DESCRIPTION
303
+ Start a local MCP HTTP server that connects to SolutionAI for a given interface type. By default it binds to
304
+ http://localhost:5051/. The server fetches the full tool manifest from SolutionAI and serves all tools immediately.
305
+ Pass a flowId with each tool call to target a specific flow, or use list_sessions to discover available flows.
306
+
307
+ EXAMPLES
308
+ $ ctxl mcp serve
309
+
310
+ $ ctxl mcp serve flow-editor --flow my-flow-id
311
+ ```
312
+
262
313
  ## `ctxl records <COMMAND>`
263
314
 
264
315
  Manage records.
package/dist/base.js CHANGED
@@ -123,7 +123,13 @@ export class BaseConfigCommand extends Command {
123
123
  let { message } = err;
124
124
  if (err instanceof ZodError) {
125
125
  const flattened = z.flattenError(err);
126
- message = flattened.formErrors.join("\n");
126
+ const fieldErrors = Object.entries(flattened.fieldErrors)
127
+ .flatMap(([field, errors]) => (errors ?? []).map((error) => `${field}: ${error}`));
128
+ const combinedErrors = [
129
+ ...flattened.formErrors,
130
+ ...fieldErrors,
131
+ ];
132
+ message = combinedErrors.join("\n") || err.message;
127
133
  }
128
134
  if (!/See more help with/.test(message)) {
129
135
  message += "\nSee more help with --help";
@@ -0,0 +1,9 @@
1
+ import { BaseConfigCommand } from "../../base.js";
2
+ export default class Mcp extends BaseConfigCommand<typeof Mcp> {
3
+ static args: {};
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {};
7
+ static usage: string[];
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,16 @@
1
+ import { BaseConfigCommand } from "../../base.js";
2
+ export default class Mcp extends BaseConfigCommand {
3
+ static args = {};
4
+ static description = "Manage local MCP server commands. Start with 'ctxl mcp serve' to run the local MCP bridge.";
5
+ static examples = [
6
+ "<%= config.bin %> <%= command.id %>",
7
+ "<%= config.bin %> <%= command.id %> flow-editor --flow my-flow-id",
8
+ ];
9
+ static flags = {};
10
+ static usage = [
11
+ "<%= command.id %> <COMMAND>",
12
+ ];
13
+ async run() {
14
+ await this.showHelp();
15
+ }
16
+ }
@@ -0,0 +1,17 @@
1
+ import { BaseCommand } from "../../base.js";
2
+ export default class McpServe extends BaseCommand<typeof McpServe> {
3
+ static args: {
4
+ interface: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ flow: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ port: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ "tool-prefix": import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ };
15
+ run(): Promise<void>;
16
+ private resolveToken;
17
+ }
@@ -0,0 +1,124 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { BaseCommand } from "../../base.js";
3
+ import { createBridgeManager } from "../../mcp/bridge-manager.js";
4
+ import { startCtxlMcpHttpServer } from "../../mcp/http-server.js";
5
+ import { createCtxlMcpRuntime } from "../../mcp/runtime.js";
6
+ import { defaultToolPrefix } from "../../mcp/server.js";
7
+ const defaultHost = "127.0.0.1";
8
+ const defaultPort = 5051;
9
+ const getLocalhostAddress = (port) => `http://localhost:${port}/`;
10
+ export default class McpServe extends BaseCommand {
11
+ static args = {
12
+ interface: Args.string({
13
+ default: "flow-editor",
14
+ description: "Interface type to scope tools for. Available interfaces depend on your platform version.",
15
+ required: false,
16
+ }),
17
+ };
18
+ static description = [
19
+ "Start a local MCP HTTP server that connects to SolutionAI for a given interface type.",
20
+ `By default it binds to ${getLocalhostAddress(defaultPort)}.`,
21
+ "The server fetches the full tool manifest from SolutionAI and serves all tools immediately.",
22
+ "Pass a flowId with each tool call to target a specific flow, or use list_sessions to discover available flows.",
23
+ ].join(" ");
24
+ static examples = [
25
+ "<%= config.bin %> <%= command.id %>",
26
+ "<%= config.bin %> <%= command.id %> flow-editor --flow my-flow-id",
27
+ ];
28
+ static flags = {
29
+ flow: Flags.string({
30
+ char: "f",
31
+ description: "pre-filter session listing to a specific flow ID",
32
+ }),
33
+ port: Flags.integer({
34
+ char: "p",
35
+ description: `local HTTP port (default: ${defaultPort})`,
36
+ }),
37
+ "tool-prefix": Flags.boolean({
38
+ allowNo: true,
39
+ char: "t",
40
+ default: false,
41
+ description: `prefix all MCP tool names with ${defaultToolPrefix}`,
42
+ }),
43
+ url: Flags.string({
44
+ description: "override MCP route URL (http/https)",
45
+ hidden: true,
46
+ }),
47
+ verbose: Flags.boolean({
48
+ allowNo: false,
49
+ char: "V",
50
+ description: "emit verbose MCP runtime diagnostics",
51
+ }),
52
+ };
53
+ async run() {
54
+ const port = this.flags.port ?? defaultPort;
55
+ const toolPrefix = this.flags["tool-prefix"] ? defaultToolPrefix : "";
56
+ const interfaceType = this.args.interface;
57
+ const config = await this.currentConfig();
58
+ const server = await startCtxlMcpHttpServer({
59
+ createBridgeManager: () => createBridgeManager({
60
+ config,
61
+ interfaceType,
62
+ resolveToken: () => this.resolveToken(),
63
+ url: this.flags.url,
64
+ verbose: this.flags.verbose,
65
+ }),
66
+ createRuntime: (bridgeManager) => createCtxlMcpRuntime({
67
+ bridgeManager,
68
+ config,
69
+ disambiguator: {
70
+ description: "The ID of the flow to operate on. The user should know which flow they are working in. Use list_sessions only if the flow ID is unknown.",
71
+ key: "flowId",
72
+ },
73
+ flowId: this.flags.flow,
74
+ interfaceType,
75
+ toolPrefix,
76
+ url: this.flags.url,
77
+ verbose: this.flags.verbose,
78
+ version: this.config.version,
79
+ }),
80
+ host: defaultHost,
81
+ port,
82
+ verbose: this.flags.verbose,
83
+ });
84
+ this.log(`MCP server running at ${getLocalhostAddress(port)} (interface: ${interfaceType})`);
85
+ let isShuttingDown = false;
86
+ let settleShutdown;
87
+ const shutdownComplete = new Promise((resolve) => {
88
+ settleShutdown = resolve;
89
+ });
90
+ const shutdown = async () => {
91
+ if (isShuttingDown) {
92
+ return;
93
+ }
94
+ isShuttingDown = true;
95
+ this.log("\nMCP server stopping...");
96
+ setTimeout(() => {
97
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
98
+ process.exit(0);
99
+ }, 2000).unref();
100
+ await server.close();
101
+ settleShutdown?.();
102
+ };
103
+ const onSignal = () => {
104
+ if (isShuttingDown) {
105
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
106
+ process.exit(0);
107
+ return;
108
+ }
109
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
110
+ shutdown().catch(() => process.exit(1));
111
+ };
112
+ process.on("SIGINT", onSignal);
113
+ process.on("SIGTERM", onSignal);
114
+ process.on("SIGQUIT", onSignal);
115
+ await shutdownComplete;
116
+ process.removeListener("SIGINT", onSignal);
117
+ process.removeListener("SIGTERM", onSignal);
118
+ process.removeListener("SIGQUIT", onSignal);
119
+ }
120
+ async resolveToken() {
121
+ const { bearerToken } = await this.currentConfig();
122
+ return bearerToken;
123
+ }
124
+ }
@@ -0,0 +1,29 @@
1
+ import type { McpToolDefinition } from "../models/mcp.js";
2
+ import type { Config } from "../models/user-config.js";
3
+ import { McpSessionState } from "./session.js";
4
+ import { McpSocketBridge } from "./socket-bridge.js";
5
+ export type ManagedBridge = {
6
+ readonly bridge: McpSocketBridge;
7
+ readonly dynamicTools: Map<string, McpToolDefinition>;
8
+ readonly flowId: string;
9
+ readonly session: McpSessionState;
10
+ };
11
+ type BridgeListener = {
12
+ onToolsChanged: () => void;
13
+ };
14
+ export type BridgeManagerOptions = {
15
+ config: Config;
16
+ interfaceType: string;
17
+ resolveToken: () => Promise<string>;
18
+ url?: string;
19
+ verbose?: boolean;
20
+ };
21
+ export type BridgeManager = {
22
+ acquire: (flowId: string) => Promise<ManagedBridge>;
23
+ addListener: (flowId: string, listener: BridgeListener) => () => void;
24
+ closeAll: () => Promise<void>;
25
+ get: (flowId: string) => ManagedBridge | undefined;
26
+ getAny: () => ManagedBridge | undefined;
27
+ };
28
+ export declare const createBridgeManager: ({ config, interfaceType, resolveToken, url, verbose, }: BridgeManagerOptions) => BridgeManager;
29
+ export {};
@@ -0,0 +1,260 @@
1
+ import { getSolutionAiApiEndpoint } from "../utils/endpoints.js";
2
+ import { createMcpLogHelpers } from "./logger.js";
3
+ import { McpSessionState } from "./session.js";
4
+ import { getMcpSocketRoute, McpSocketBridge } from "./socket-bridge.js";
5
+ /* eslint-disable no-await-in-loop */
6
+ const reconnectMaxAttempts = 3;
7
+ const reconnectBaseDelayMs = 2000;
8
+ const reconnectCooldownMs = 30_000;
9
+ const defaultMcpRoutePath = "/comms";
10
+ const abortableSleep = (ms, signal) => new Promise((resolve) => {
11
+ if (signal.aborted) {
12
+ resolve();
13
+ return;
14
+ }
15
+ const timer = setTimeout(resolve, ms);
16
+ signal.addEventListener("abort", () => {
17
+ clearTimeout(timer);
18
+ resolve();
19
+ }, { once: true });
20
+ });
21
+ export const createBridgeManager = ({ config, interfaceType, resolveToken, url, verbose, }) => {
22
+ const { log, logError } = createMcpLogHelpers(verbose);
23
+ const { tenantId } = config;
24
+ const bridges = new Map();
25
+ const listeners = new Map();
26
+ const reconnectAborts = new Map();
27
+ const autoRebindTimers = new Map();
28
+ const lastReconnectAts = new Map();
29
+ let closed = false;
30
+ const getRouteUrl = () => url ?? `${getSolutionAiApiEndpoint(tenantId, config.silo)}${defaultMcpRoutePath}`;
31
+ const getRoute = () => getMcpSocketRoute({ url: getRouteUrl() });
32
+ const notifyListeners = (flowId) => {
33
+ const flowListeners = listeners.get(flowId);
34
+ if (!flowListeners)
35
+ return;
36
+ for (const listener of flowListeners) {
37
+ listener.onToolsChanged();
38
+ }
39
+ };
40
+ const syncDynamicTools = (managed, payload, replace) => {
41
+ if (replace) {
42
+ managed.dynamicTools.clear();
43
+ }
44
+ if (payload) {
45
+ for (const tool of payload) {
46
+ managed.dynamicTools.set(tool.name, tool);
47
+ }
48
+ }
49
+ notifyListeners(managed.flowId);
50
+ };
51
+ const attemptReconnect = async (flowId) => {
52
+ const abort = new AbortController();
53
+ reconnectAborts.set(flowId, abort);
54
+ const managed = bridges.get(flowId);
55
+ if (!managed)
56
+ return;
57
+ try {
58
+ for (let attempt = 1; attempt <= reconnectMaxAttempts; attempt++) {
59
+ if (abort.signal.aborted || closed)
60
+ return;
61
+ const delay = reconnectBaseDelayMs * 2 ** (attempt - 1);
62
+ await abortableSleep(delay, abort.signal);
63
+ if (abort.signal.aborted || closed)
64
+ return;
65
+ log("reconnect_attempt", { attempt, flowId, maxAttempts: reconnectMaxAttempts, tenantId });
66
+ try {
67
+ const token = await resolveToken();
68
+ const route = getRoute();
69
+ await managed.bridge.connect({
70
+ interfaceType,
71
+ orgId: tenantId,
72
+ token,
73
+ url: route.url,
74
+ });
75
+ if (abort.signal.aborted || closed) {
76
+ await managed.bridge.disconnect().catch(() => { });
77
+ return;
78
+ }
79
+ const manifest = await managed.bridge.requestManifest(interfaceType);
80
+ syncDynamicTools(managed, manifest.definitions, true);
81
+ managed.session.markReady();
82
+ lastReconnectAts.set(flowId, Date.now());
83
+ log("reconnect_success", { attempt, flowId, tenantId, toolCount: manifest.definitions.length });
84
+ return;
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : `${error}`;
88
+ logError("reconnect_attempt_failed", { attempt, flowId, message, tenantId });
89
+ }
90
+ }
91
+ logError("reconnect_exhausted", { flowId, maxAttempts: reconnectMaxAttempts, tenantId });
92
+ managed.session.markError("SOCKET_DISCONNECTED", "Reconnection failed after all retry attempts.");
93
+ }
94
+ finally {
95
+ const current = reconnectAborts.get(flowId);
96
+ if (current === abort) {
97
+ reconnectAborts.delete(flowId);
98
+ }
99
+ }
100
+ };
101
+ const createManagedBridge = (flowId) => {
102
+ const session = new McpSessionState();
103
+ const dynamicTools = new Map();
104
+ const bridge = new McpSocketBridge({
105
+ onCommandStatus({ commandId, status, toolName }) {
106
+ log("command_status", { commandId, flowId, status, toolName });
107
+ },
108
+ onDetached({ mcpSessionId, reason }) {
109
+ const previousConnection = session.getSnapshot().connection;
110
+ session.markUnbound();
111
+ log("remote_detached", { flowId, mcpSessionId, reason, tenantId });
112
+ if (reason === "target-disconnected" && previousConnection && !closed) {
113
+ const timer = setTimeout(() => {
114
+ autoRebindTimers.delete(flowId);
115
+ if (closed || session.getSnapshot().bindingState === "bound")
116
+ return;
117
+ log("auto_rebind_attempt", { flowId, tenantId });
118
+ session.beginBind();
119
+ bridge.bindSession({ flowId: previousConnection.flowId })
120
+ .then((result) => {
121
+ session.markBound({ boundAt: result.boundAt, flowId: result.flowId, flowName: result.flowName });
122
+ notifyListeners(flowId);
123
+ log("auto_rebind_success", { flowId: result.flowId, tenantId });
124
+ })
125
+ .catch(() => {
126
+ session.markUnbound();
127
+ });
128
+ }, 3000);
129
+ autoRebindTimers.set(flowId, timer);
130
+ }
131
+ },
132
+ onDisconnected(reason) {
133
+ session.markUnbound();
134
+ const { serverState } = session.getSnapshot();
135
+ if (serverState !== "ready" || closed) {
136
+ logError("socket_disconnected", { flowId, reason, tenantId });
137
+ return;
138
+ }
139
+ const lastReconnectAt = lastReconnectAts.get(flowId) ?? 0;
140
+ const canReconnect = Date.now() - lastReconnectAt > reconnectCooldownMs;
141
+ if (canReconnect) {
142
+ log("reconnect_start", { flowId, reason, tenantId });
143
+ attemptReconnect(flowId).catch(() => {
144
+ session.markError("SOCKET_DISCONNECTED", `Reconnection failed after socket disconnect (${reason})`);
145
+ });
146
+ return;
147
+ }
148
+ logError("socket_disconnected", { flowId, reason, tenantId });
149
+ session.markError("SOCKET_DISCONNECTED", `Socket disconnected (${reason})`);
150
+ },
151
+ onSessionAvailable({ flowId: sessionFlowId, flowName }) {
152
+ log("session_available", { flowId: sessionFlowId, flowName });
153
+ },
154
+ onSessionUnavailable({ flowId: sessionFlowId, reason }) {
155
+ log("session_unavailable", { flowId: sessionFlowId, reason });
156
+ },
157
+ onToolRegistrySync({ action, definitions, mcpSessionId, replace: shouldReplace, tools }) {
158
+ log("tool_registry_received", {
159
+ action,
160
+ definitionCount: definitions.length,
161
+ flowId,
162
+ mcpSessionId,
163
+ replace: shouldReplace === true,
164
+ toolCount: tools.length,
165
+ });
166
+ if (action === "clear") {
167
+ log("tool_registry_clear_skipped", { flowId, mcpSessionId });
168
+ notifyListeners(managed.flowId);
169
+ return;
170
+ }
171
+ syncDynamicTools(managed, definitions, shouldReplace === true);
172
+ },
173
+ });
174
+ const managed = { bridge, dynamicTools, flowId, session };
175
+ return managed;
176
+ };
177
+ return {
178
+ async acquire(flowId) {
179
+ // Exact match by flowId
180
+ const existing = bridges.get(flowId);
181
+ if (existing && existing.bridge.isConnected) {
182
+ return existing;
183
+ }
184
+ // Reuse any connected bridge that's already bound to this flow
185
+ for (const [key, managed] of bridges.entries()) {
186
+ if (managed.bridge.isConnected && managed.session.getSnapshot().connection?.flowId === flowId) {
187
+ if (key !== flowId) {
188
+ bridges.delete(key);
189
+ bridges.set(flowId, managed);
190
+ }
191
+ return managed;
192
+ }
193
+ }
194
+ // No reusable bridge — create and connect a new one
195
+ const managed = existing ?? createManagedBridge(flowId);
196
+ if (!existing) {
197
+ bridges.set(flowId, managed);
198
+ }
199
+ const token = await resolveToken();
200
+ const route = getRoute();
201
+ log("bridge_connect_start", { flowId, tenantId });
202
+ await managed.bridge.connect({
203
+ interfaceType,
204
+ orgId: tenantId,
205
+ token,
206
+ url: route.url,
207
+ });
208
+ try {
209
+ const manifest = await managed.bridge.requestManifest(interfaceType);
210
+ syncDynamicTools(managed, manifest.definitions, true);
211
+ managed.session.markReady();
212
+ log("bridge_connect_ready", { flowId, tenantId, toolCount: manifest.definitions.length });
213
+ }
214
+ catch (error) {
215
+ const message = error instanceof Error ? error.message : `${error}`;
216
+ logError("bridge_manifest_failed", { flowId, message, tenantId });
217
+ managed.session.markError("MANIFEST_FAILED", message);
218
+ }
219
+ return managed;
220
+ },
221
+ addListener(flowId, listener) {
222
+ let flowListeners = listeners.get(flowId);
223
+ if (!flowListeners) {
224
+ flowListeners = new Set();
225
+ listeners.set(flowId, flowListeners);
226
+ }
227
+ flowListeners.add(listener);
228
+ return () => {
229
+ flowListeners.delete(listener);
230
+ if (flowListeners.size === 0) {
231
+ listeners.delete(flowId);
232
+ }
233
+ };
234
+ },
235
+ async closeAll() {
236
+ closed = true;
237
+ for (const abort of reconnectAborts.values()) {
238
+ abort.abort();
239
+ }
240
+ reconnectAborts.clear();
241
+ for (const timer of autoRebindTimers.values()) {
242
+ clearTimeout(timer);
243
+ }
244
+ autoRebindTimers.clear();
245
+ await Promise.all([...bridges.values()].map((managed) => managed.bridge.disconnect().catch(() => { })));
246
+ bridges.clear();
247
+ listeners.clear();
248
+ },
249
+ get(flowId) {
250
+ return bridges.get(flowId);
251
+ },
252
+ getAny() {
253
+ for (const managed of bridges.values()) {
254
+ if (managed.bridge.isConnected) {
255
+ return managed;
256
+ }
257
+ }
258
+ },
259
+ };
260
+ };
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ export declare const makePrefixed: <P extends string>(prefix: P) => <T extends string>(tool: T) => `${P}${T}`;
3
+ export declare const isInArray: <T extends string>(haystack: T[], needle: string) => needle is T;
4
+ export declare const McpDetachedPayload: z.ZodObject<{
5
+ detachedAt: z.ZodNumber;
6
+ mcpSessionId: z.ZodString;
7
+ reason: z.ZodEnum<{
8
+ "target-disconnected": "target-disconnected";
9
+ "mcp-disconnected": "mcp-disconnected";
10
+ "manual-detach": "manual-detach";
11
+ }>;
12
+ }, z.core.$strip>;
13
+ export type McpDetachedPayload = z.infer<typeof McpDetachedPayload>;
14
+ export declare const McpErrorPayload: z.ZodObject<{
15
+ error: z.ZodString;
16
+ }, z.core.$strip>;
17
+ export type McpErrorPayload = z.infer<typeof McpErrorPayload>;
18
+ export declare const McpToolErrorPayload: z.ZodObject<{
19
+ commandId: z.ZodOptional<z.ZodString>;
20
+ error: z.ZodObject<{
21
+ data: z.ZodOptional<z.ZodUnknown>;
22
+ message: z.ZodString;
23
+ code: z.ZodEnum<{
24
+ INTERNAL_ERROR: "INTERNAL_ERROR";
25
+ NOT_ATTACHED: "NOT_ATTACHED";
26
+ UNKNOWN_TOOL: "UNKNOWN_TOOL";
27
+ COMMAND_IN_FLIGHT: "COMMAND_IN_FLIGHT";
28
+ TIMEOUT: "TIMEOUT";
29
+ UNAUTHORIZED: "UNAUTHORIZED";
30
+ }>;
31
+ }, z.core.$strip>;
32
+ }, z.core.$strip>;
33
+ export type McpToolErrorPayload = z.infer<typeof McpToolErrorPayload>;
34
+ export declare const McpSessionAvailablePayload: z.ZodObject<{
35
+ flowId: z.ZodString;
36
+ flowName: z.ZodString;
37
+ }, z.core.$strip>;
38
+ export type McpSessionAvailablePayload = z.infer<typeof McpSessionAvailablePayload>;
39
+ export declare const McpSessionUnavailablePayload: z.ZodObject<{
40
+ flowId: z.ZodString;
41
+ reason: z.ZodString;
42
+ }, z.core.$strip>;
43
+ export type McpSessionUnavailablePayload = z.infer<typeof McpSessionUnavailablePayload>;
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { McpDetachReason as McpDetachReasonSchema, McpSessionId, McpToolError, McpToolErrorCode, } from "../models/mcp.js";
3
+ export const makePrefixed = (prefix) => (tool) => `${prefix}${tool}`;
4
+ export const isInArray = (haystack, needle) => haystack.includes(needle);
5
+ // Schemas for socket events that don't have a direct model equivalent.
6
+ export const McpDetachedPayload = z.object({
7
+ detachedAt: z.number().int(),
8
+ mcpSessionId: McpSessionId,
9
+ reason: McpDetachReasonSchema,
10
+ });
11
+ export const McpErrorPayload = z.object({
12
+ error: z.string(),
13
+ });
14
+ export const McpToolErrorPayload = z.object({
15
+ commandId: z.string().optional(),
16
+ error: McpToolError.extend({
17
+ code: McpToolErrorCode,
18
+ }),
19
+ });
20
+ export const McpSessionAvailablePayload = z.object({
21
+ flowId: z.string(),
22
+ flowName: z.string(),
23
+ });
24
+ export const McpSessionUnavailablePayload = z.object({
25
+ flowId: z.string(),
26
+ reason: z.string(),
27
+ });
@@ -0,0 +1,45 @@
1
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2
+ import { type IncomingMessage, type ServerResponse } from "node:http";
3
+ import type { BridgeManager } from "./bridge-manager.js";
4
+ import type { CtxlMcpRuntime } from "./runtime.js";
5
+ type McpRequest = IncomingMessage & {
6
+ body?: unknown;
7
+ };
8
+ type McpResponse = ServerResponse;
9
+ type HttpSessionTransport = Pick<StreamableHTTPServerTransport, "close" | "handleRequest" | "sessionId"> & {
10
+ onclose?: (() => void) | undefined;
11
+ onerror?: ((error: Error) => void) | undefined;
12
+ };
13
+ type TransportFactory = (callbacks: {
14
+ onSessionClosed: (sessionId: string) => void;
15
+ onSessionInitialized: (sessionId: string) => void;
16
+ }) => HttpSessionTransport;
17
+ type SessionManagerOptions = {
18
+ createBridgeManager: () => BridgeManager;
19
+ createRuntime: (bridgeManager: BridgeManager) => CtxlMcpRuntime;
20
+ createTransport?: TransportFactory;
21
+ verbose?: boolean;
22
+ };
23
+ type StartHttpServerOptions = SessionManagerOptions & {
24
+ host: string;
25
+ port: number;
26
+ };
27
+ export declare const getHttpServerError: (error: unknown, port: number) => Error;
28
+ export declare const createCtxlMcpHttpSessionManager: ({ createBridgeManager, createRuntime, createTransport, verbose, }: SessionManagerOptions) => {
29
+ closeAllSessions(reason: string): Promise<void>;
30
+ handleDelete(request: McpRequest, response: McpResponse): Promise<void>;
31
+ handleGet(request: McpRequest, response: McpResponse): Promise<void>;
32
+ handlePost(request: McpRequest, response: McpResponse): Promise<void>;
33
+ hasSession(sessionId: string): boolean;
34
+ };
35
+ export declare const startCtxlMcpHttpServer: ({ host, port, ...sessionManagerOptions }: StartHttpServerOptions) => Promise<{
36
+ close(): Promise<void>;
37
+ sessionManager: {
38
+ closeAllSessions(reason: string): Promise<void>;
39
+ handleDelete(request: McpRequest, response: McpResponse): Promise<void>;
40
+ handleGet(request: McpRequest, response: McpResponse): Promise<void>;
41
+ handlePost(request: McpRequest, response: McpResponse): Promise<void>;
42
+ hasSession(sessionId: string): boolean;
43
+ };
44
+ }>;
45
+ export {};