@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.
@@ -0,0 +1,242 @@
1
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import { createServer } from "node:http";
6
+ import { createMcpLogHelpers } from "./logger.js";
7
+ const jsonRpcErrorCode = -32_000;
8
+ const createStreamableTransport = (callbacks) => new StreamableHTTPServerTransport({
9
+ onsessionclosed: callbacks.onSessionClosed,
10
+ onsessioninitialized: callbacks.onSessionInitialized,
11
+ sessionIdGenerator: () => randomUUID(),
12
+ });
13
+ const getHeaderValue = (value) => (Array.isArray(value)
14
+ ? value[0]
15
+ : value);
16
+ const writeJsonRpcError = (response, statusCode, message) => {
17
+ response.statusCode = statusCode;
18
+ response.setHeader("content-type", "application/json");
19
+ response.end(JSON.stringify({
20
+ error: {
21
+ code: jsonRpcErrorCode,
22
+ message,
23
+ },
24
+ id: null,
25
+ jsonrpc: "2.0",
26
+ }));
27
+ };
28
+ export const getHttpServerError = (error, port) => {
29
+ if (error && typeof error === "object" && "code" in error) {
30
+ const nodeError = error;
31
+ if (nodeError.code === "EADDRINUSE") {
32
+ return new Error(`Port ${port} is unavailable. Retry with --port.`);
33
+ }
34
+ if (nodeError.code === "EACCES") {
35
+ return new Error(`Port ${port} cannot be opened on 127.0.0.1.`);
36
+ }
37
+ }
38
+ return error instanceof Error
39
+ ? error
40
+ : new Error(`${error}`);
41
+ };
42
+ const sessionInactivityMs = 10 * 60 * 1000; // 10 minutes idle = cleanup
43
+ const sessionCleanupIntervalMs = 60 * 1000; // check every minute
44
+ export const createCtxlMcpHttpSessionManager = ({ createBridgeManager, createRuntime, createTransport = createStreamableTransport, verbose, }) => {
45
+ const { log, logError } = createMcpLogHelpers(verbose);
46
+ const sessions = new Map();
47
+ const closingSessions = new Set();
48
+ let sharedBridgeManager;
49
+ const cleanupInterval = setInterval(() => {
50
+ const now = Date.now();
51
+ for (const [sessionId, session] of sessions.entries()) {
52
+ if (now - session.lastActivityAt > sessionInactivityMs) {
53
+ closeSession(sessionId, "inactivity_timeout").catch(() => { });
54
+ }
55
+ }
56
+ }, sessionCleanupIntervalMs);
57
+ const closeSession = async (sessionId, reason) => {
58
+ if (closingSessions.has(sessionId)) {
59
+ return;
60
+ }
61
+ const session = sessions.get(sessionId);
62
+ if (!session) {
63
+ return;
64
+ }
65
+ closingSessions.add(sessionId);
66
+ sessions.delete(sessionId);
67
+ log("session_closed", {
68
+ mcpSessionId: sessionId,
69
+ reason,
70
+ });
71
+ try {
72
+ await session.transport.close().catch(() => { });
73
+ await session.runtime.close().catch(() => { });
74
+ }
75
+ finally {
76
+ closingSessions.delete(sessionId);
77
+ }
78
+ };
79
+ const getSession = (request) => {
80
+ const sessionId = getHeaderValue(request.headers["mcp-session-id"]);
81
+ if (!sessionId) {
82
+ return {
83
+ error: {
84
+ message: "Missing mcp-session-id header",
85
+ statusCode: 400,
86
+ },
87
+ };
88
+ }
89
+ const session = sessions.get(sessionId);
90
+ if (!session) {
91
+ return {
92
+ error: {
93
+ message: `Unknown mcp-session-id '${sessionId}'. Session may be stale after a server restart; reconnect your MCP client to initialize a new session.`,
94
+ statusCode: 404,
95
+ },
96
+ };
97
+ }
98
+ session.lastActivityAt = Date.now();
99
+ return { session, sessionId };
100
+ };
101
+ return {
102
+ async closeAllSessions(reason) {
103
+ clearInterval(cleanupInterval);
104
+ const sessionIds = [...sessions.keys()];
105
+ await Promise.all(sessionIds.map(async (sessionId) => closeSession(sessionId, reason)));
106
+ await sharedBridgeManager?.closeAll().catch(() => { });
107
+ sharedBridgeManager = undefined;
108
+ },
109
+ async handleDelete(request, response) {
110
+ const current = getSession(request);
111
+ if ("error" in current) {
112
+ const error = current.error;
113
+ writeJsonRpcError(response, error.statusCode, error.message);
114
+ return;
115
+ }
116
+ await current.session.transport.handleRequest(request, response);
117
+ },
118
+ async handleGet(request, response) {
119
+ const current = getSession(request);
120
+ if ("error" in current) {
121
+ const error = current.error;
122
+ writeJsonRpcError(response, error.statusCode, error.message);
123
+ return;
124
+ }
125
+ await current.session.transport.handleRequest(request, response);
126
+ },
127
+ async handlePost(request, response) {
128
+ const sessionId = getHeaderValue(request.headers["mcp-session-id"]);
129
+ if (sessionId) {
130
+ const current = getSession(request);
131
+ if ("error" in current) {
132
+ const error = current.error;
133
+ writeJsonRpcError(response, error.statusCode, error.message);
134
+ return;
135
+ }
136
+ await current.session.transport.handleRequest(request, response, request.body);
137
+ return;
138
+ }
139
+ if (!isInitializeRequest(request.body)) {
140
+ writeJsonRpcError(response, 400, "Expected initialize request");
141
+ return;
142
+ }
143
+ if (!sharedBridgeManager) {
144
+ sharedBridgeManager = createBridgeManager();
145
+ }
146
+ const runtime = createRuntime(sharedBridgeManager);
147
+ let route;
148
+ try {
149
+ route = await runtime.inspectConnectionRoute();
150
+ }
151
+ catch (error) {
152
+ const message = error instanceof Error ? error.message : `${error}`;
153
+ writeJsonRpcError(response, 500, `Connection route check failed: ${message}`);
154
+ return;
155
+ }
156
+ const { connectUrl, namespace, namespaceUrl, path, silo, url: routeUrl } = route;
157
+ log("route_check", { connectUrl, namespace, namespaceUrl, path, silo, url: routeUrl });
158
+ const transport = createTransport({
159
+ onSessionClosed(sessionIdValue) {
160
+ closeSession(sessionIdValue, "session_terminated").catch(() => { });
161
+ },
162
+ onSessionInitialized(sessionIdValue) {
163
+ sessions.set(sessionIdValue, {
164
+ lastActivityAt: Date.now(),
165
+ runtime,
166
+ transport,
167
+ });
168
+ log("session_initialized", {
169
+ mcpSessionId: sessionIdValue,
170
+ });
171
+ },
172
+ });
173
+ // eslint-disable-next-line unicorn/prefer-add-event-listener
174
+ transport.onclose = () => {
175
+ if (transport.sessionId) {
176
+ closeSession(transport.sessionId, "transport_closed").catch(() => { });
177
+ }
178
+ };
179
+ // eslint-disable-next-line unicorn/prefer-add-event-listener
180
+ transport.onerror = (error) => {
181
+ logError("transport_error", {
182
+ ...(transport.sessionId && { mcpSessionId: transport.sessionId }),
183
+ message: error.message,
184
+ });
185
+ };
186
+ await runtime.connect(transport);
187
+ await transport.handleRequest(request, response, request.body);
188
+ },
189
+ hasSession(sessionId) {
190
+ return sessions.has(sessionId);
191
+ },
192
+ };
193
+ };
194
+ export const startCtxlMcpHttpServer = async ({ host, port, ...sessionManagerOptions }) => {
195
+ const { log, logError } = createMcpLogHelpers(sessionManagerOptions.verbose);
196
+ const app = createMcpExpressApp({ host });
197
+ const sessionManager = createCtxlMcpHttpSessionManager(sessionManagerOptions);
198
+ const safeHandle = (handler) => async (request, response) => {
199
+ try {
200
+ await handler(request, response);
201
+ }
202
+ catch (error) {
203
+ logError("http_request_error", {
204
+ message: error instanceof Error ? error.message : `${error}`,
205
+ method: request.method ?? "UNKNOWN",
206
+ });
207
+ if (!response.headersSent) {
208
+ writeJsonRpcError(response, 500, "Internal server error");
209
+ }
210
+ }
211
+ };
212
+ app.post("/", safeHandle((request, response) => sessionManager.handlePost(request, response)));
213
+ app.get("/", safeHandle((request, response) => sessionManager.handleGet(request, response)));
214
+ app.delete("/", safeHandle((request, response) => sessionManager.handleDelete(request, response)));
215
+ const server = createServer(app);
216
+ await new Promise((resolve, reject) => {
217
+ const onError = (error) => {
218
+ server.removeListener("listening", onListening);
219
+ reject(error);
220
+ };
221
+ const onListening = () => {
222
+ server.removeListener("error", onError);
223
+ resolve();
224
+ };
225
+ server.once("error", onError);
226
+ server.once("listening", onListening);
227
+ server.listen(port, "127.0.0.1");
228
+ }).catch((error) => {
229
+ throw getHttpServerError(error, port);
230
+ });
231
+ log("server_bound", { host, port });
232
+ return {
233
+ async close() {
234
+ server.closeAllConnections();
235
+ await Promise.all([
236
+ sessionManager.closeAllSessions("shutdown"),
237
+ new Promise(server.close),
238
+ ]);
239
+ },
240
+ sessionManager,
241
+ };
242
+ };
@@ -0,0 +1,4 @@
1
+ export declare const createMcpLogHelpers: (verbose?: boolean) => {
2
+ log(event: string, ...args: unknown[]): void;
3
+ logError(event: string, ...args: unknown[]): void;
4
+ };
@@ -0,0 +1,9 @@
1
+ export const createMcpLogHelpers = (verbose) => ({
2
+ log(event, ...args) {
3
+ if (verbose)
4
+ console.log("[ctxl mcp] event=%s" + " %j".repeat(args.length), event, ...args);
5
+ },
6
+ logError(event, ...args) {
7
+ console.error("[ctxl mcp] event=%s" + " %j".repeat(args.length), event, ...args);
8
+ },
9
+ });
@@ -0,0 +1,24 @@
1
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2
+ import type { Config } from "../models/user-config.js";
3
+ import type { BridgeManager } from "./bridge-manager.js";
4
+ import { type DisambiguatorConfig } from "./server.js";
5
+ import { type McpSocketRoute } from "./socket-bridge.js";
6
+ export type CtxlMcpRuntime = {
7
+ close: () => Promise<void>;
8
+ connect: (transport: Transport) => Promise<void>;
9
+ inspectConnectionRoute: () => Promise<McpSocketRoute & {
10
+ silo: Config["silo"];
11
+ }>;
12
+ };
13
+ export type CtxlMcpRuntimeOptions = {
14
+ bridgeManager: BridgeManager;
15
+ config: Config;
16
+ disambiguator?: DisambiguatorConfig;
17
+ flowId?: string;
18
+ interfaceType: string;
19
+ toolPrefix?: string;
20
+ url?: string;
21
+ verbose?: boolean;
22
+ version: string;
23
+ };
24
+ export declare const createCtxlMcpRuntime: ({ bridgeManager, config, disambiguator, flowId: globalFlowId, interfaceType, toolPrefix, url, verbose, version, }: CtxlMcpRuntimeOptions) => CtxlMcpRuntime;
@@ -0,0 +1,297 @@
1
+ import { getSolutionAiApiEndpoint } from "../utils/endpoints.js";
2
+ import { isInArray, makePrefixed } from "./contracts.js";
3
+ import { createMcpLogHelpers } from "./logger.js";
4
+ import { createCtxlMcpServer, toExposedDynamicToolName } from "./server.js";
5
+ import { getMcpSocketRoute } from "./socket-bridge.js";
6
+ const defaultMcpRoutePath = "/comms";
7
+ const getMyDomain = (tenantId, silo) => `${tenantId}.my${silo === "prod" ? "" : `.${silo}`}.contextual.io`;
8
+ const getFlowEditorUrl = (flowId, tenantId, silo) => `https://${flowId}.flow.${getMyDomain(tenantId, silo)}`;
9
+ export const createCtxlMcpRuntime = ({ bridgeManager, config, disambiguator, flowId: globalFlowId, interfaceType, toolPrefix = "", url, verbose, version, }) => {
10
+ const { log } = createMcpLogHelpers(verbose);
11
+ const { tenantId } = config;
12
+ const routeUrl = url ?? `${getSolutionAiApiEndpoint(tenantId, config.silo)}${defaultMcpRoutePath}`;
13
+ const prefixed = makePrefixed(toolPrefix);
14
+ const hasDisambiguator = Boolean(disambiguator);
15
+ let manifestBridge;
16
+ let currentBridge;
17
+ let removeToolsListener;
18
+ const boundBridges = new Map();
19
+ const bridgeListenerRemovers = new Map();
20
+ const pendingBinds = new Map();
21
+ const getSocketRoute = () => getMcpSocketRoute({ url: routeUrl });
22
+ const visibleTools = () => {
23
+ const metaTools = [prefixed("list_sessions"), prefixed("info")];
24
+ const toolSource = manifestBridge ?? currentBridge;
25
+ const dynamicTools = toolSource
26
+ ? [...toolSource.dynamicTools.values()]
27
+ .map(tool => toExposedDynamicToolName(tool.name, toolPrefix))
28
+ .filter(toolName => !isInArray(metaTools, toolName))
29
+ : [];
30
+ return [...metaTools, ...dynamicTools];
31
+ };
32
+ const listDynamicTools = () => manifestBridge ? [...manifestBridge.dynamicTools.values()]
33
+ : currentBridge ? [...currentBridge.dynamicTools.values()]
34
+ : [];
35
+ const notifyCatalogUpdate = async (reason, force = false) => {
36
+ const tools = visibleTools();
37
+ log("tools_catalog", { force, reason, toolCount: tools.length, tools: tools.join(",") });
38
+ await mcpServer.notifyToolsChanged(force);
39
+ };
40
+ const subscribeToBridgeMulti = (managed) => {
41
+ bridgeListenerRemovers.get(managed.flowId)?.();
42
+ boundBridges.set(managed.flowId, managed);
43
+ const removeListener = bridgeManager.addListener(managed.flowId, {
44
+ onToolsChanged() {
45
+ notifyCatalogUpdate("bridge_tools_changed", true).catch(() => { });
46
+ },
47
+ });
48
+ bridgeListenerRemovers.set(managed.flowId, removeListener);
49
+ };
50
+ const ensureBound = async (flowId) => {
51
+ const existing = boundBridges.get(flowId);
52
+ if (existing?.session.getSnapshot().bindingState === "bound"
53
+ && existing.session.getSnapshot().connection?.flowId === flowId) {
54
+ log("ensure_bound_hit", { flowId, path: "cached" });
55
+ return existing;
56
+ }
57
+ const pending = pendingBinds.get(flowId);
58
+ if (pending) {
59
+ log("ensure_bound_hit", { flowId, path: "pending" });
60
+ return pending;
61
+ }
62
+ log("ensure_bound_start", { existingBound: [...boundBridges.keys()].join(",") || "none", flowId });
63
+ const bindPromise = (async () => {
64
+ try {
65
+ const managed = bridgeManager.get(flowId);
66
+ if (managed?.session.getSnapshot().bindingState === "bound"
67
+ && managed.session.getSnapshot().connection?.flowId === flowId) {
68
+ log("ensure_bound_hit", { flowId, path: "manager_get_bound" });
69
+ subscribeToBridgeMulti(managed);
70
+ await notifyCatalogUpdate("auto_bound_existing", true);
71
+ return managed;
72
+ }
73
+ log("ensure_bound_acquire", { flowId });
74
+ const bridge = await bridgeManager.acquire(flowId);
75
+ const snapshot = bridge.session.getSnapshot();
76
+ log("ensure_bound_acquired", { bindingState: snapshot.bindingState, connectionFlowId: snapshot.connection?.flowId, flowId, serverState: snapshot.serverState });
77
+ if (snapshot.bindingState === "bound" && snapshot.connection?.flowId === flowId) {
78
+ log("ensure_bound_hit", { flowId, path: "acquire_already_bound" });
79
+ subscribeToBridgeMulti(bridge);
80
+ await notifyCatalogUpdate("auto_bound_existing", true);
81
+ return bridge;
82
+ }
83
+ if (snapshot.bindingState === "bound") {
84
+ log("ensure_bound_unbind_previous", { flowId, previousFlowId: snapshot.connection?.flowId });
85
+ await bridge.bridge.unbindSession();
86
+ bridge.session.markUnbound();
87
+ }
88
+ if (bridge.session.getSnapshot().serverState !== "ready") {
89
+ log("ensure_bound_fetch_manifest", { flowId, serverState: bridge.session.getSnapshot().serverState });
90
+ const manifest = await bridge.bridge.requestManifest(interfaceType);
91
+ for (const tool of manifest.definitions) {
92
+ bridge.dynamicTools.set(tool.name, tool);
93
+ }
94
+ bridge.session.markReady();
95
+ }
96
+ log("ensure_bound_begin_bind", { flowId });
97
+ bridge.session.beginBind();
98
+ try {
99
+ const result = await bridge.bridge.bindSession({ flowId });
100
+ log("ensure_bound_success", { flowId: result.flowId, flowName: result.flowName });
101
+ bridge.session.markBound({
102
+ boundAt: result.boundAt,
103
+ flowId: result.flowId,
104
+ flowName: result.flowName,
105
+ });
106
+ subscribeToBridgeMulti(bridge);
107
+ await notifyCatalogUpdate("auto_bound", true);
108
+ return bridge;
109
+ }
110
+ catch (error) {
111
+ const message = error instanceof Error ? error.message : `${error}`;
112
+ log("ensure_bound_failed", { error: message, flowId });
113
+ bridge.session.markUnbound();
114
+ throw error instanceof Error ? error : new Error(`${error}`);
115
+ }
116
+ }
117
+ finally {
118
+ pendingBinds.delete(flowId);
119
+ }
120
+ })();
121
+ pendingBinds.set(flowId, bindPromise);
122
+ return bindPromise;
123
+ };
124
+ const getAnyBridge = () => {
125
+ const candidates = [manifestBridge, currentBridge, boundBridges.values().next().value];
126
+ for (const bridge of candidates) {
127
+ if (bridge?.bridge.isConnected)
128
+ return bridge;
129
+ }
130
+ return bridgeManager.getAny();
131
+ };
132
+ const mcpServer = createCtxlMcpServer({
133
+ async callDynamicTool(toolName, args, disambiguatorValue) {
134
+ log("call_dynamic_tool", { disambiguatorValue: disambiguatorValue ?? "none", hasArgs: Object.keys(args).join(",") || "none", toolName });
135
+ if (disambiguatorValue) {
136
+ const bridge = await ensureBound(disambiguatorValue);
137
+ log("dynamic_tool_call", { flowId: bridge.flowId, tenantId, toolName });
138
+ return bridge.bridge.runTool({ args, name: toolName });
139
+ }
140
+ if (!currentBridge) {
141
+ throw new Error("No active session. Call list_sessions to find available flows.");
142
+ }
143
+ log("dynamic_tool_call", { tenantId, toolName });
144
+ return currentBridge.bridge.runTool({ args, name: toolName });
145
+ },
146
+ disambiguator,
147
+ globalDisambiguatorValue: globalFlowId,
148
+ handlers: {
149
+ async info() {
150
+ if (hasDisambiguator && boundBridges.size > 0) {
151
+ const flows = [...boundBridges.entries()].map(([flowId, managed]) => {
152
+ const snapshot = managed.session.getSnapshot();
153
+ return {
154
+ bindingState: snapshot.bindingState,
155
+ flowId,
156
+ ...(snapshot.connection && { flowName: snapshot.connection.flowName }),
157
+ serverState: snapshot.serverState,
158
+ };
159
+ });
160
+ return {
161
+ flows,
162
+ interfaceType,
163
+ ...(globalFlowId && { globalFlowId }),
164
+ tenantId,
165
+ };
166
+ }
167
+ const bridgeSnapshot = currentBridge?.session.getSnapshot();
168
+ return {
169
+ bindingState: bridgeSnapshot?.bindingState ?? "unbound",
170
+ interfaceType,
171
+ serverState: bridgeSnapshot?.serverState ?? "initializing",
172
+ ...(bridgeSnapshot?.connection && {
173
+ flow: {
174
+ flowId: bridgeSnapshot.connection.flowId,
175
+ flowName: bridgeSnapshot.connection.flowName,
176
+ },
177
+ }),
178
+ ...(bridgeSnapshot?.lastError && { lastError: bridgeSnapshot.lastError }),
179
+ tenantId,
180
+ };
181
+ },
182
+ async listSessions({ flowId: filterFlowId }) {
183
+ const resolvedFlowId = filterFlowId ?? globalFlowId;
184
+ const anyBridge = getAnyBridge();
185
+ const fetchSessions = async (bridge) => bridge.bridge.listSessions({ flowId: resolvedFlowId, interfaceType });
186
+ let serverResult;
187
+ if (anyBridge) {
188
+ try {
189
+ serverResult = await fetchSessions(anyBridge);
190
+ }
191
+ catch { /* no-op */ }
192
+ }
193
+ else {
194
+ try {
195
+ const bridge = await bridgeManager.acquire(resolvedFlowId ?? "_query");
196
+ serverResult = await fetchSessions(bridge);
197
+ }
198
+ catch { /* no-op */ }
199
+ }
200
+ if (!serverResult) {
201
+ return formatNoConnectionResult(filterFlowId);
202
+ }
203
+ const connectedFlows = getConnectedFlows(resolvedFlowId);
204
+ return formatListSessionsResult(serverResult, connectedFlows, filterFlowId);
205
+ },
206
+ },
207
+ isBound: () => currentBridge?.session.getSnapshot().bindingState === "bound",
208
+ listDynamicTools,
209
+ onToolCall(name) {
210
+ log("tool_call", { tenantId, toolName: name });
211
+ },
212
+ toolPrefix,
213
+ version,
214
+ visibleTools,
215
+ });
216
+ const getConnectedFlows = (filterFlowId) => {
217
+ const connected = [];
218
+ for (const managed of boundBridges.values()) {
219
+ const snapshot = managed.session.getSnapshot();
220
+ if (snapshot.bindingState !== "bound" || !snapshot.connection)
221
+ continue;
222
+ if (filterFlowId && snapshot.connection.flowId !== filterFlowId)
223
+ continue;
224
+ connected.push({ flowId: snapshot.connection.flowId, flowName: snapshot.connection.flowName });
225
+ }
226
+ if (currentBridge) {
227
+ const snapshot = currentBridge.session.getSnapshot();
228
+ if (snapshot.bindingState === "bound" && snapshot.connection) {
229
+ const alreadyIncluded = connected.some(c => c.flowId === snapshot.connection.flowId);
230
+ if (!alreadyIncluded && (!filterFlowId || snapshot.connection.flowId === filterFlowId)) {
231
+ connected.push({ flowId: snapshot.connection.flowId, flowName: snapshot.connection.flowName });
232
+ }
233
+ }
234
+ }
235
+ return connected;
236
+ };
237
+ const formatListSessionsResult = (result, connectedFlows, filterFlowId) => {
238
+ if (result.sessions.length === 0 && connectedFlows.length === 0) {
239
+ const flowHint = filterFlowId
240
+ ? `Open the flow in your browser: ${getFlowEditorUrl(filterFlowId, tenantId, config.silo)}`
241
+ : `Open a flow in your browser at https://<flow-id>.flow.${getMyDomain(tenantId, config.silo)}`;
242
+ return {
243
+ message: `No available browser sessions found. ${flowHint}`,
244
+ sessions: [],
245
+ };
246
+ }
247
+ return {
248
+ ...(connectedFlows.length > 0 && { connectedFlows }),
249
+ sessions: result.sessions,
250
+ };
251
+ };
252
+ const formatNoConnectionResult = (filterFlowId) => {
253
+ const flowHint = filterFlowId
254
+ ? `Open the flow in your browser: ${getFlowEditorUrl(filterFlowId, tenantId, config.silo)}`
255
+ : `Open a flow in your browser at https://<flow-id>.flow.${getMyDomain(tenantId, config.silo)}`;
256
+ return {
257
+ message: `Not connected to the API. ${flowHint}`,
258
+ sessions: [],
259
+ };
260
+ };
261
+ return {
262
+ async close() {
263
+ for (const remover of bridgeListenerRemovers.values()) {
264
+ remover();
265
+ }
266
+ bridgeListenerRemovers.clear();
267
+ boundBridges.clear();
268
+ pendingBinds.clear();
269
+ removeToolsListener?.();
270
+ removeToolsListener = undefined;
271
+ currentBridge = undefined;
272
+ manifestBridge = undefined;
273
+ await mcpServer.server.close().catch(() => { });
274
+ },
275
+ async connect(transport) {
276
+ try {
277
+ const bridge = await bridgeManager.acquire(globalFlowId ?? "_manifest");
278
+ manifestBridge = bridge;
279
+ if (globalFlowId && hasDisambiguator) {
280
+ await ensureBound(globalFlowId).catch(() => { });
281
+ }
282
+ else {
283
+ currentBridge = bridge;
284
+ }
285
+ }
286
+ catch {
287
+ // Bridge unavailable — base tools still served, manifest loaded on first bind
288
+ }
289
+ await mcpServer.server.connect(transport);
290
+ await notifyCatalogUpdate("transport_connected", true);
291
+ },
292
+ async inspectConnectionRoute() {
293
+ const route = getSocketRoute();
294
+ return { ...route, silo: config.silo };
295
+ },
296
+ };
297
+ };
@@ -0,0 +1,90 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import type { McpToolDefinition } from "../models/mcp.js";
4
+ declare const defaultToolPrefix = "ctxl_";
5
+ type DisambiguatorConfig = {
6
+ description: string;
7
+ key: string;
8
+ };
9
+ export { defaultToolPrefix };
10
+ export type { DisambiguatorConfig };
11
+ export type CtxlToolName = string;
12
+ type ToolHandlerResult = Record<string, unknown>;
13
+ export type CtxlToolHandlers = {
14
+ info: () => Promise<ToolHandlerResult>;
15
+ listSessions: (args: {
16
+ flowId?: string;
17
+ }) => Promise<ToolHandlerResult>;
18
+ };
19
+ type CreateCtxlMcpServerParams = {
20
+ callDynamicTool: (toolName: string, args: Record<string, unknown>, disambiguatorValue?: string) => Promise<unknown>;
21
+ disambiguator?: DisambiguatorConfig;
22
+ globalDisambiguatorValue?: string;
23
+ handlers: CtxlToolHandlers;
24
+ isBound: () => boolean;
25
+ listDynamicTools: () => McpToolDefinition[];
26
+ maxResponseBytes?: number;
27
+ onToolCall?: (name: CtxlToolName) => void;
28
+ toolPrefix?: string;
29
+ version: string;
30
+ visibleTools: () => CtxlToolName[];
31
+ };
32
+ export declare const toExposedDynamicToolName: (toolName: string, prefix: string) => string;
33
+ export declare const createCtxlMcpServer: ({ callDynamicTool, disambiguator, globalDisambiguatorValue, handlers, isBound, listDynamicTools, maxResponseBytes, onToolCall, toolPrefix, version, visibleTools, }: CreateCtxlMcpServerParams) => {
34
+ debug: {
35
+ callTool: (toolName: string, args: Record<string, unknown>) => Promise<CallToolResult>;
36
+ listTools: () => Promise<{
37
+ [x: string]: unknown;
38
+ tools: {
39
+ inputSchema: {
40
+ [x: string]: unknown;
41
+ type: "object";
42
+ properties?: {
43
+ [x: string]: object;
44
+ } | undefined;
45
+ required?: string[] | undefined;
46
+ };
47
+ name: string;
48
+ description?: string | undefined;
49
+ outputSchema?: {
50
+ [x: string]: unknown;
51
+ type: "object";
52
+ properties?: {
53
+ [x: string]: object;
54
+ } | undefined;
55
+ required?: string[] | undefined;
56
+ } | undefined;
57
+ annotations?: {
58
+ title?: string | undefined;
59
+ readOnlyHint?: boolean | undefined;
60
+ destructiveHint?: boolean | undefined;
61
+ idempotentHint?: boolean | undefined;
62
+ openWorldHint?: boolean | undefined;
63
+ } | undefined;
64
+ execution?: {
65
+ taskSupport?: "required" | "optional" | "forbidden" | undefined;
66
+ } | undefined;
67
+ _meta?: {
68
+ [x: string]: unknown;
69
+ } | undefined;
70
+ icons?: {
71
+ src: string;
72
+ mimeType?: string | undefined;
73
+ sizes?: string[] | undefined;
74
+ theme?: "dark" | "light" | undefined;
75
+ }[] | undefined;
76
+ title?: string | undefined;
77
+ }[];
78
+ _meta?: {
79
+ [x: string]: unknown;
80
+ progressToken?: string | number | undefined;
81
+ "io.modelcontextprotocol/related-task"?: {
82
+ taskId: string;
83
+ } | undefined;
84
+ } | undefined;
85
+ nextCursor?: string | undefined;
86
+ }>;
87
+ };
88
+ notifyToolsChanged(force?: boolean): Promise<void>;
89
+ server: McpServer;
90
+ };