@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 +52 -1
- package/dist/base.js +7 -1
- package/dist/commands/mcp/index.d.ts +9 -0
- package/dist/commands/mcp/index.js +16 -0
- package/dist/commands/mcp/serve.d.ts +17 -0
- package/dist/commands/mcp/serve.js +124 -0
- package/dist/mcp/bridge-manager.d.ts +29 -0
- package/dist/mcp/bridge-manager.js +260 -0
- package/dist/mcp/contracts.d.ts +43 -0
- package/dist/mcp/contracts.js +27 -0
- package/dist/mcp/http-server.d.ts +45 -0
- package/dist/mcp/http-server.js +242 -0
- package/dist/mcp/logger.d.ts +4 -0
- package/dist/mcp/logger.js +9 -0
- package/dist/mcp/runtime.d.ts +24 -0
- package/dist/mcp/runtime.js +297 -0
- package/dist/mcp/server.d.ts +90 -0
- package/dist/mcp/server.js +308 -0
- package/dist/mcp/session.d.ts +42 -0
- package/dist/mcp/session.js +75 -0
- package/dist/mcp/socket-bridge.d.ts +67 -0
- package/dist/mcp/socket-bridge.js +357 -0
- package/dist/models/mcp.d.ts +288 -0
- package/dist/models/mcp.js +137 -0
- package/dist/utils/endpoints.d.ts +1 -0
- package/dist/utils/endpoints.js +1 -0
- package/oclif.manifest.json +109 -1
- package/package.json +5 -1
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.
|
|
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
|
-
|
|
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 {};
|