@thinkwell/conductor 0.5.5 → 0.5.6

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.
@@ -1,224 +1,128 @@
1
- /**
2
- * HTTP Listener for MCP Bridge
3
- *
4
- * Listens on an ephemeral HTTP port for MCP connections from agents
5
- * and bridges them to ACP `_mcp/*` messages.
6
- *
7
- * The listener handles the Streamable HTTP transport for MCP:
8
- * - POST requests for JSON-RPC messages
9
- * - SSE responses for streaming
10
- */
11
1
  import { createServer } from "node:http";
12
2
  import { randomUUID } from "node:crypto";
13
- import { isJsonRpcRequest, isJsonRpcNotification, createResponder, } from "@thinkwell/protocol";
14
- /**
15
- * Create an HTTP listener for MCP connections
16
- *
17
- * The listener waits for the session ID before accepting connections,
18
- * ensuring we can always correlate connections with sessions.
19
- */
3
+ import { isJsonRpcRequest, isJsonRpcNotification, createResponder } from "@thinkwell/protocol";
20
4
  export async function createHttpListener(options) {
21
- const { acpUrl, onMessage } = options;
22
- let sessionId = null;
23
- const connections = new Map();
24
- const sockets = new Set();
25
- let server = null;
26
- // Create HTTP server
27
- server = createServer(async (req, res) => {
28
- // CORS headers for local development
29
- res.setHeader("Access-Control-Allow-Origin", "*");
30
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS, GET");
31
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id");
32
- if (req.method === "OPTIONS") {
33
- res.writeHead(204);
34
- res.end();
35
- return;
36
- }
37
- if (req.method === "GET") {
38
- // GET request is for SSE stream - return 204 to acknowledge
39
- res.writeHead(204);
40
- res.end();
41
- return;
42
- }
43
- if (req.method !== "POST") {
44
- res.writeHead(405, { "Content-Type": "application/json" });
45
- res.end(JSON.stringify({ error: "Method not allowed" }));
46
- return;
47
- }
48
- try {
49
- // Parse the request body first - don't wait for session ID
50
- const body = await readBody(req);
51
- const message = JSON.parse(body);
52
- // Get or create connection for this request
53
- // For simplicity, we use a single connection per listener
54
- // (in practice, MCP typically uses one connection per session)
55
- let connection = connections.get("default");
56
- if (!connection) {
57
- const connectionId = randomUUID();
58
- // Use a placeholder session ID - it will be set later
59
- // The session ID is not needed for MCP protocol messages
60
- connection = {
61
- connectionId,
62
- sessionId: sessionId ?? "pending",
63
- pendingResponses: new Map(),
64
- };
65
- connections.set("default", connection);
66
- // Notify conductor of new connection
67
- onMessage({
68
- type: "connection-received",
69
- acpUrl,
70
- sessionId: connection.sessionId,
71
- connectionId,
72
- send: (responseData) => {
73
- // This is called when conductor sends a response back
74
- // We need to route it to the right pending response
75
- },
76
- close: () => {
77
- connections.delete("default");
78
- },
79
- });
80
- }
81
- // Handle the message
82
- await handleMessage(connection, message, res, onMessage);
83
- }
84
- catch (error) {
85
- console.error("MCP bridge HTTP error:", error);
86
- res.writeHead(500, { "Content-Type": "application/json" });
87
- res.end(JSON.stringify({ error: "Internal server error" }));
88
- }
89
- });
90
- // Listen on an ephemeral port
91
- await new Promise((resolve, reject) => {
92
- server.listen(0, "127.0.0.1", () => resolve());
93
- server.on("error", reject);
94
- });
95
- // Track all socket connections so we can destroy them on close
96
- server.on('connection', (socket) => {
97
- sockets.add(socket);
98
- socket.on('close', () => {
99
- sockets.delete(socket);
100
- });
101
- });
102
- const address = server.address();
103
- if (!address || typeof address === "string") {
104
- throw new Error("Failed to get server address");
5
+ const { acpUrl, onMessage } = options;
6
+ let sessionId = null;
7
+ const connections = /* @__PURE__ */ new Map(), sockets = /* @__PURE__ */ new Set();
8
+ let server = null;
9
+ server = createServer(async (req, res) => {
10
+ if (res.setHeader("Access-Control-Allow-Origin", "*"), res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS, GET"), res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id"), req.method === "OPTIONS") {
11
+ res.writeHead(204), res.end();
12
+ return;
105
13
  }
106
- const port = address.port;
107
- return {
108
- acpUrl,
109
- port,
110
- setSessionId(id) {
111
- sessionId = id;
112
- // Update any existing connections with the real session ID
113
- for (const conn of connections.values()) {
114
- conn.sessionId = id;
115
- }
116
- },
117
- async close() {
118
- // Close all connections
119
- for (const [key, conn] of connections) {
120
- onMessage({
121
- type: "connection-closed",
122
- connectionId: conn.connectionId,
123
- });
124
- connections.delete(key);
125
- }
126
- // Destroy all active sockets to allow server to close immediately
127
- for (const socket of sockets) {
128
- socket.destroy();
129
- }
130
- sockets.clear();
131
- // Close the server
132
- if (server) {
133
- await new Promise((resolve, reject) => {
134
- server.close((err) => {
135
- if (err) {
136
- reject(err);
137
- }
138
- else {
139
- resolve();
140
- }
141
- });
142
- });
143
- }
144
- },
145
- };
146
- }
147
- /**
148
- * Handle an incoming MCP message
149
- */
150
- async function handleMessage(connection, message, res, onMessage) {
151
- if (isJsonRpcRequest(message)) {
152
- // Use string key for the pending responses map
153
- const idKey = String(message.id);
154
- // Create a promise to wait for the response
155
- const responsePromise = new Promise((resolve) => {
156
- connection.pendingResponses.set(idKey, resolve);
157
- });
158
- // Create a dispatch with a responder that sends the response to HTTP
159
- const dispatch = {
160
- type: "request",
161
- id: message.id,
162
- method: message.method,
163
- params: message.params,
164
- responder: createResponder((result) => {
165
- const resolver = connection.pendingResponses.get(idKey);
166
- connection.pendingResponses.delete(idKey);
167
- resolver?.({ jsonrpc: "2.0", id: message.id, result });
168
- }, (error) => {
169
- const resolver = connection.pendingResponses.get(idKey);
170
- connection.pendingResponses.delete(idKey);
171
- resolver?.({ jsonrpc: "2.0", id: message.id, error });
172
- }),
173
- };
174
- // Notify conductor of the request
175
- onMessage({
176
- type: "client-message",
177
- connectionId: connection.connectionId,
178
- dispatch,
179
- });
180
- // Wait for the response
181
- const response = await responsePromise;
182
- // Return JSON-RPC response directly
183
- // Include Mcp-Session-Id header per MCP Streamable HTTP spec
184
- res.writeHead(200, {
185
- "Content-Type": "application/json",
186
- "Mcp-Session-Id": connection.connectionId,
14
+ if (req.method === "GET") {
15
+ res.writeHead(204), res.end();
16
+ return;
17
+ }
18
+ if (req.method !== "POST") {
19
+ res.writeHead(405, { "Content-Type": "application/json" }), res.end(JSON.stringify({ error: "Method not allowed" }));
20
+ return;
21
+ }
22
+ try {
23
+ const body = await readBody(req), message = JSON.parse(body);
24
+ let connection = connections.get("default");
25
+ if (!connection) {
26
+ const connectionId = randomUUID();
27
+ connection = {
28
+ connectionId,
29
+ sessionId: sessionId ?? "pending",
30
+ pendingResponses: /* @__PURE__ */ new Map()
31
+ }, connections.set("default", connection), onMessage({
32
+ type: "connection-received",
33
+ acpUrl,
34
+ sessionId: connection.sessionId,
35
+ connectionId,
36
+ send: (responseData) => {
37
+ },
38
+ close: () => {
39
+ connections.delete("default");
40
+ }
187
41
  });
188
- res.end(JSON.stringify(response));
42
+ }
43
+ await handleMessage(connection, message, res, onMessage);
44
+ } catch (error) {
45
+ console.error("MCP bridge HTTP error:", error), res.writeHead(500, { "Content-Type": "application/json" }), res.end(JSON.stringify({ error: "Internal server error" }));
189
46
  }
190
- else if (isJsonRpcNotification(message)) {
191
- // Create a notification dispatch
192
- const dispatch = {
193
- type: "notification",
194
- method: message.method,
195
- params: message.params,
196
- };
197
- // Notify conductor
47
+ }), await new Promise((resolve, reject) => {
48
+ server.listen(0, "127.0.0.1", () => resolve()), server.on("error", reject);
49
+ }), server.on("connection", (socket) => {
50
+ sockets.add(socket), socket.on("close", () => {
51
+ sockets.delete(socket);
52
+ });
53
+ });
54
+ const address = server.address();
55
+ if (!address || typeof address == "string")
56
+ throw new Error("Failed to get server address");
57
+ const port = address.port;
58
+ return {
59
+ acpUrl,
60
+ port,
61
+ setSessionId(id) {
62
+ sessionId = id;
63
+ for (const conn of connections.values())
64
+ conn.sessionId = id;
65
+ },
66
+ async close() {
67
+ for (const [key, conn] of connections)
198
68
  onMessage({
199
- type: "client-message",
200
- connectionId: connection.connectionId,
201
- dispatch,
69
+ type: "connection-closed",
70
+ connectionId: conn.connectionId
71
+ }), connections.delete(key);
72
+ for (const socket of sockets)
73
+ socket.destroy();
74
+ sockets.clear(), server && await new Promise((resolve, reject) => {
75
+ server.close((err) => {
76
+ err ? reject(err) : resolve();
202
77
  });
203
- // Notifications don't have responses
204
- res.writeHead(202);
205
- res.end();
206
- }
207
- else {
208
- // Unknown message type
209
- res.writeHead(400, { "Content-Type": "application/json" });
210
- res.end(JSON.stringify({ error: "Invalid message type" }));
78
+ });
211
79
  }
80
+ };
212
81
  }
213
- /**
214
- * Read the request body as a string
215
- */
216
- function readBody(req) {
217
- return new Promise((resolve, reject) => {
218
- const chunks = [];
219
- req.on("data", (chunk) => chunks.push(chunk));
220
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
221
- req.on("error", reject);
82
+ async function handleMessage(connection, message, res, onMessage) {
83
+ if (isJsonRpcRequest(message)) {
84
+ const idKey = String(message.id), responsePromise = new Promise((resolve) => {
85
+ connection.pendingResponses.set(idKey, resolve);
86
+ }), dispatch = {
87
+ type: "request",
88
+ id: message.id,
89
+ method: message.method,
90
+ params: message.params,
91
+ responder: createResponder((result) => {
92
+ const resolver = connection.pendingResponses.get(idKey);
93
+ connection.pendingResponses.delete(idKey), resolver?.({ jsonrpc: "2.0", id: message.id, result });
94
+ }, (error) => {
95
+ const resolver = connection.pendingResponses.get(idKey);
96
+ connection.pendingResponses.delete(idKey), resolver?.({ jsonrpc: "2.0", id: message.id, error });
97
+ })
98
+ };
99
+ onMessage({
100
+ type: "client-message",
101
+ connectionId: connection.connectionId,
102
+ dispatch
222
103
  });
104
+ const response = await responsePromise;
105
+ res.writeHead(200, {
106
+ "Content-Type": "application/json",
107
+ "Mcp-Session-Id": connection.connectionId
108
+ }), res.end(JSON.stringify(response));
109
+ } else if (isJsonRpcNotification(message)) {
110
+ const dispatch = {
111
+ type: "notification",
112
+ method: message.method,
113
+ params: message.params
114
+ };
115
+ onMessage({
116
+ type: "client-message",
117
+ connectionId: connection.connectionId,
118
+ dispatch
119
+ }), res.writeHead(202), res.end();
120
+ } else
121
+ res.writeHead(400, { "Content-Type": "application/json" }), res.end(JSON.stringify({ error: "Invalid message type" }));
122
+ }
123
+ function readBody(req) {
124
+ return new Promise((resolve, reject) => {
125
+ const chunks = [];
126
+ req.on("data", (chunk) => chunks.push(chunk)), req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))), req.on("error", reject);
127
+ });
223
128
  }
224
- //# sourceMappingURL=http-listener.js.map
@@ -1,8 +1,2 @@
1
- /**
2
- * MCP Bridge module
3
- *
4
- * Provides bridging between MCP-over-ACP and traditional HTTP-based MCP.
5
- */
6
1
  export { McpBridge } from "./mcp-bridge.js";
7
2
  export { createHttpListener } from "./http-listener.js";
8
- //# sourceMappingURL=index.js.map
@@ -1,170 +1,123 @@
1
- /**
2
- * MCP Bridge
3
- *
4
- * The MCP bridge manages HTTP listeners for `acp:` URLs and coordinates
5
- * the transformation of MCP server configurations during session creation.
6
- *
7
- * Key responsibilities:
8
- * 1. Transform `acp:$UUID` URLs to `http://localhost:$PORT` during session/new
9
- * 2. Spawn HTTP listeners for each `acp:` URL
10
- * 3. Route MCP messages between agents and proxies via `_mcp/*` messages
11
- * 4. Manage connection lifecycle
12
- */
13
1
  import { createHttpListener } from "./http-listener.js";
14
- /**
15
- * MCP Bridge manager
16
- *
17
- * Manages HTTP listeners and connection lifecycle for MCP-over-ACP bridging.
18
- */
19
2
  export class McpBridge {
20
- messageQueue;
21
- // Maps acp:uuid → HttpListener
22
- listeners = new Map();
23
- // Maps connectionId → ActiveMcpConnection
24
- connections = new Map();
25
- // Pending sessions waiting for session ID
26
- pendingSessions = new Map();
27
- constructor(options) {
28
- this.messageQueue = options.messageQueue;
3
+ messageQueue;
4
+ // Maps acp:uuid → HttpListener
5
+ listeners = /* @__PURE__ */ new Map();
6
+ // Maps connectionId → ActiveMcpConnection
7
+ connections = /* @__PURE__ */ new Map();
8
+ // Pending sessions waiting for session ID
9
+ pendingSessions = /* @__PURE__ */ new Map();
10
+ constructor(options) {
11
+ this.messageQueue = options.messageQueue;
12
+ }
13
+ /**
14
+ * Transform MCP servers in a session/new request
15
+ *
16
+ * For each `acp:` URL:
17
+ * 1. Spawn an HTTP listener on an ephemeral port
18
+ * 2. Replace the URL with `http://localhost:$PORT`
19
+ *
20
+ * Returns the transformed server list and a session key for later correlation.
21
+ */
22
+ async transformMcpServers(servers, sessionKey) {
23
+ if (!servers || servers.length === 0)
24
+ return { transformedServers: servers, hasAcpServers: !1 };
25
+ const pendingSession = {
26
+ listeners: [],
27
+ urlMap: /* @__PURE__ */ new Map()
28
+ }, transformedServers = [];
29
+ let hasAcpServers = !1;
30
+ for (const server of servers)
31
+ if (server.url.startsWith("acp:")) {
32
+ hasAcpServers = !0;
33
+ const listener = await createHttpListener({
34
+ acpUrl: server.url,
35
+ onMessage: (msg) => this.handleBridgeMessage(msg)
36
+ });
37
+ this.listeners.set(server.url, listener), pendingSession.listeners.push(listener), pendingSession.urlMap.set(server.url, `http://127.0.0.1:${listener.port}`), transformedServers.push({
38
+ ...server,
39
+ url: `http://127.0.0.1:${listener.port}`,
40
+ type: "http"
41
+ });
42
+ } else
43
+ transformedServers.push(server);
44
+ return hasAcpServers && this.pendingSessions.set(sessionKey, pendingSession), { transformedServers, hasAcpServers };
45
+ }
46
+ /**
47
+ * Complete session creation after receiving the session ID from the agent
48
+ *
49
+ * This delivers the session ID to all pending listeners so they can
50
+ * correlate connections with the session.
51
+ */
52
+ completeSession(sessionKey, sessionId) {
53
+ const pending = this.pendingSessions.get(sessionKey);
54
+ if (pending) {
55
+ for (const listener of pending.listeners)
56
+ listener.setSessionId(sessionId);
57
+ this.pendingSessions.delete(sessionKey);
29
58
  }
30
- /**
31
- * Transform MCP servers in a session/new request
32
- *
33
- * For each `acp:` URL:
34
- * 1. Spawn an HTTP listener on an ephemeral port
35
- * 2. Replace the URL with `http://localhost:$PORT`
36
- *
37
- * Returns the transformed server list and a session key for later correlation.
38
- */
39
- async transformMcpServers(servers, sessionKey) {
40
- if (!servers || servers.length === 0) {
41
- return { transformedServers: servers, hasAcpServers: false };
42
- }
43
- const pendingSession = {
44
- listeners: [],
45
- urlMap: new Map(),
46
- };
47
- const transformedServers = [];
48
- let hasAcpServers = false;
49
- for (const server of servers) {
50
- if (server.url.startsWith("acp:")) {
51
- hasAcpServers = true;
52
- // Spawn HTTP listener for this acp: URL
53
- const listener = await createHttpListener({
54
- acpUrl: server.url,
55
- onMessage: (msg) => this.handleBridgeMessage(msg),
56
- });
57
- this.listeners.set(server.url, listener);
58
- pendingSession.listeners.push(listener);
59
- pendingSession.urlMap.set(server.url, `http://127.0.0.1:${listener.port}`);
60
- // Transform to HTTP URL
61
- transformedServers.push({
62
- ...server,
63
- url: `http://127.0.0.1:${listener.port}`,
64
- type: "http",
65
- });
66
- }
67
- else {
68
- // Pass through non-acp servers unchanged
69
- transformedServers.push(server);
70
- }
71
- }
72
- if (hasAcpServers) {
73
- this.pendingSessions.set(sessionKey, pendingSession);
74
- }
75
- return { transformedServers, hasAcpServers };
59
+ }
60
+ /**
61
+ * Cancel a pending session (e.g., on error)
62
+ */
63
+ async cancelSession(sessionKey) {
64
+ const pending = this.pendingSessions.get(sessionKey);
65
+ if (pending) {
66
+ for (const listener of pending.listeners)
67
+ await listener.close(), this.listeners.delete(listener.acpUrl);
68
+ this.pendingSessions.delete(sessionKey);
76
69
  }
77
- /**
78
- * Complete session creation after receiving the session ID from the agent
79
- *
80
- * This delivers the session ID to all pending listeners so they can
81
- * correlate connections with the session.
82
- */
83
- completeSession(sessionKey, sessionId) {
84
- const pending = this.pendingSessions.get(sessionKey);
85
- if (!pending)
86
- return;
87
- for (const listener of pending.listeners) {
88
- listener.setSessionId(sessionId);
89
- }
90
- this.pendingSessions.delete(sessionKey);
91
- }
92
- /**
93
- * Cancel a pending session (e.g., on error)
94
- */
95
- async cancelSession(sessionKey) {
96
- const pending = this.pendingSessions.get(sessionKey);
97
- if (!pending)
98
- return;
99
- for (const listener of pending.listeners) {
100
- await listener.close();
101
- this.listeners.delete(listener.acpUrl);
102
- }
103
- this.pendingSessions.delete(sessionKey);
104
- }
105
- /**
106
- * Handle a message from an HTTP listener
107
- */
108
- handleBridgeMessage(msg) {
109
- switch (msg.type) {
110
- case "connection-received": {
111
- // Track the new connection
112
- this.connections.set(msg.connectionId, {
113
- connectionId: msg.connectionId,
114
- acpUrl: msg.acpUrl,
115
- sessionId: msg.sessionId,
116
- responder: null,
117
- });
118
- // Queue the connection notification for the conductor
119
- this.messageQueue.push({
120
- type: "mcp-connection-received",
121
- acpUrl: msg.acpUrl,
122
- connectionId: msg.connectionId,
123
- });
124
- break;
125
- }
126
- case "client-message": {
127
- // Route MCP message to conductor
128
- this.messageQueue.push({
129
- type: "mcp-client-to-server",
130
- connectionId: msg.connectionId,
131
- dispatch: msg.dispatch,
132
- });
133
- break;
134
- }
135
- case "connection-closed": {
136
- // Clean up connection
137
- this.connections.delete(msg.connectionId);
138
- // Queue disconnect notification
139
- this.messageQueue.push({
140
- type: "mcp-connection-disconnected",
141
- connectionId: msg.connectionId,
142
- });
143
- break;
144
- }
145
- }
146
- }
147
- /**
148
- * Get connection info by connection ID
149
- */
150
- getConnection(connectionId) {
151
- return this.connections.get(connectionId);
152
- }
153
- /**
154
- * Close all listeners and connections
155
- */
156
- async close() {
157
- // Close all connections
158
- this.connections.clear();
159
- // Close all listeners
160
- for (const listener of this.listeners.values()) {
161
- await listener.close();
162
- }
163
- this.listeners.clear();
164
- // Cancel pending sessions
165
- for (const key of this.pendingSessions.keys()) {
166
- await this.cancelSession(key);
167
- }
70
+ }
71
+ /**
72
+ * Handle a message from an HTTP listener
73
+ */
74
+ handleBridgeMessage(msg) {
75
+ switch (msg.type) {
76
+ case "connection-received": {
77
+ this.connections.set(msg.connectionId, {
78
+ connectionId: msg.connectionId,
79
+ acpUrl: msg.acpUrl,
80
+ sessionId: msg.sessionId,
81
+ responder: null
82
+ }), this.messageQueue.push({
83
+ type: "mcp-connection-received",
84
+ acpUrl: msg.acpUrl,
85
+ connectionId: msg.connectionId
86
+ });
87
+ break;
88
+ }
89
+ case "client-message": {
90
+ this.messageQueue.push({
91
+ type: "mcp-client-to-server",
92
+ connectionId: msg.connectionId,
93
+ dispatch: msg.dispatch
94
+ });
95
+ break;
96
+ }
97
+ case "connection-closed": {
98
+ this.connections.delete(msg.connectionId), this.messageQueue.push({
99
+ type: "mcp-connection-disconnected",
100
+ connectionId: msg.connectionId
101
+ });
102
+ break;
103
+ }
168
104
  }
105
+ }
106
+ /**
107
+ * Get connection info by connection ID
108
+ */
109
+ getConnection(connectionId) {
110
+ return this.connections.get(connectionId);
111
+ }
112
+ /**
113
+ * Close all listeners and connections
114
+ */
115
+ async close() {
116
+ this.connections.clear();
117
+ for (const listener of this.listeners.values())
118
+ await listener.close();
119
+ this.listeners.clear();
120
+ for (const key of this.pendingSessions.keys())
121
+ await this.cancelSession(key);
122
+ }
169
123
  }
170
- //# sourceMappingURL=mcp-bridge.js.map
@@ -1,8 +1 @@
1
- /**
2
- * Types for the MCP Bridge
3
- *
4
- * The MCP bridge enables agents without native MCP-over-ACP support
5
- * to work with proxy components that provide MCP servers using ACP transport.
6
- */
7
1
  export {};
8
- //# sourceMappingURL=types.js.map