@aiwerk/mcp-bridge 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/mcp-bridge.d.ts +2 -0
- package/dist/bin/mcp-bridge.js +320 -0
- package/dist/src/config.d.ts +19 -0
- package/dist/src/config.js +145 -0
- package/{src/index.ts → dist/src/index.d.ts} +1 -30
- package/dist/src/index.js +21 -0
- package/dist/src/mcp-router.d.ts +65 -0
- package/dist/src/mcp-router.js +271 -0
- package/dist/src/protocol.d.ts +4 -0
- package/dist/src/protocol.js +58 -0
- package/dist/src/schema-convert.d.ts +11 -0
- package/dist/src/schema-convert.js +150 -0
- package/dist/src/standalone-server.d.ts +30 -0
- package/dist/src/standalone-server.js +312 -0
- package/dist/src/tool-naming.d.ts +3 -0
- package/dist/src/tool-naming.js +38 -0
- package/dist/src/transport-base.d.ts +76 -0
- package/dist/src/transport-base.js +163 -0
- package/dist/src/transport-sse.d.ts +16 -0
- package/dist/src/transport-sse.js +207 -0
- package/dist/src/transport-stdio.d.ts +20 -0
- package/dist/src/transport-stdio.js +281 -0
- package/dist/src/transport-streamable-http.d.ts +11 -0
- package/dist/src/transport-streamable-http.js +164 -0
- package/dist/src/types.d.ts +72 -0
- package/dist/src/types.js +4 -0
- package/dist/src/update-checker.d.ts +25 -0
- package/dist/src/update-checker.js +132 -0
- package/package.json +19 -4
- package/scripts/install-server.ps1 +25 -58
- package/scripts/install-server.sh +37 -90
- package/servers/apify/README.md +6 -6
- package/servers/github/README.md +6 -6
- package/servers/google-maps/README.md +6 -6
- package/servers/hetzner/README.md +6 -6
- package/servers/hostinger/README.md +6 -6
- package/servers/linear/README.md +6 -6
- package/servers/miro/README.md +6 -6
- package/servers/notion/README.md +6 -6
- package/servers/stripe/README.md +6 -6
- package/servers/tavily/README.md +6 -6
- package/servers/todoist/README.md +6 -6
- package/servers/wise/README.md +6 -6
- package/bin/mcp-bridge.js +0 -9
- package/bin/mcp-bridge.ts +0 -335
- package/src/config.ts +0 -168
- package/src/mcp-router.ts +0 -366
- package/src/protocol.ts +0 -69
- package/src/schema-convert.ts +0 -178
- package/src/standalone-server.ts +0 -385
- package/src/tool-naming.ts +0 -51
- package/src/transport-base.ts +0 -199
- package/src/transport-sse.ts +0 -230
- package/src/transport-stdio.ts +0 -312
- package/src/transport-streamable-http.ts +0 -188
- package/src/types.ts +0 -88
- package/src/update-checker.ts +0 -155
- package/tests/collision.test.ts +0 -60
- package/tests/env-resolve.test.ts +0 -68
- package/tests/mcp-router.test.ts +0 -301
- package/tests/schema-convert.test.ts +0 -70
- package/tests/transport-base.test.ts +0 -214
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { nextRequestId } from "./types.js";
|
|
2
|
+
import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
3
|
+
export class SseTransport extends BaseTransport {
|
|
4
|
+
endpointUrl = null;
|
|
5
|
+
sseAbortController = null;
|
|
6
|
+
currentDataBuffer = [];
|
|
7
|
+
get transportName() { return "SSE"; }
|
|
8
|
+
async connect() {
|
|
9
|
+
if (!this.config.url) {
|
|
10
|
+
throw new Error("SSE transport requires URL");
|
|
11
|
+
}
|
|
12
|
+
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
13
|
+
// Validate that all header env vars resolve (fail fast)
|
|
14
|
+
resolveEnvRecord(this.config.headers || {}, "header");
|
|
15
|
+
this.sseAbortController = new AbortController();
|
|
16
|
+
const connectionTimeout = this.clientConfig.connectionTimeoutMs || 10000;
|
|
17
|
+
const streamReady = new Promise((resolve, reject) => {
|
|
18
|
+
const timer = setTimeout(() => reject(new Error("SSE endpoint URL not received within timeout")), connectionTimeout);
|
|
19
|
+
this._onEndpointReceived = () => { clearTimeout(timer); resolve(); };
|
|
20
|
+
});
|
|
21
|
+
// Fire and forget the stream reader
|
|
22
|
+
this.startEventStream().catch((error) => {
|
|
23
|
+
if (error instanceof Error && error.name !== 'AbortError') {
|
|
24
|
+
this.logger.error("[mcp-bridge] SSE stream error:", error.message);
|
|
25
|
+
this.scheduleReconnect();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
await streamReady;
|
|
29
|
+
this.connected = true;
|
|
30
|
+
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
31
|
+
}
|
|
32
|
+
_onEndpointReceived = null;
|
|
33
|
+
async startEventStream() {
|
|
34
|
+
if (!this.config.url)
|
|
35
|
+
return;
|
|
36
|
+
const headers = resolveEnvRecord({
|
|
37
|
+
...this.config.headers,
|
|
38
|
+
"Accept": "text/event-stream"
|
|
39
|
+
}, "header");
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(this.config.url, {
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers,
|
|
44
|
+
signal: this.sseAbortController?.signal
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`SSE connection failed: HTTP ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
if (!response.body) {
|
|
50
|
+
throw new Error("No response body for SSE stream");
|
|
51
|
+
}
|
|
52
|
+
const reader = response.body.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let buffer = "";
|
|
55
|
+
let currentEvent = "";
|
|
56
|
+
while (true) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done)
|
|
59
|
+
break;
|
|
60
|
+
buffer += decoder.decode(value, { stream: true });
|
|
61
|
+
const lines = buffer.split('\n');
|
|
62
|
+
buffer = lines.pop() || "";
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (trimmed.startsWith("event: ")) {
|
|
66
|
+
currentEvent = trimmed.substring(7).trim();
|
|
67
|
+
}
|
|
68
|
+
else if (trimmed === "") {
|
|
69
|
+
this.processEventLine(line, currentEvent);
|
|
70
|
+
currentEvent = "";
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.processEventLine(line, currentEvent);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof Error && error.name === 'AbortError')
|
|
80
|
+
return;
|
|
81
|
+
this.logger.error("SSE stream error:", error);
|
|
82
|
+
this.scheduleReconnect();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
processEventLine(line, currentEvent = "") {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (trimmed.startsWith("event: "))
|
|
88
|
+
return;
|
|
89
|
+
if (trimmed.startsWith("data: ")) {
|
|
90
|
+
this.currentDataBuffer.push(trimmed.substring(6));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (trimmed === "") {
|
|
94
|
+
if (this.currentDataBuffer.length === 0)
|
|
95
|
+
return;
|
|
96
|
+
const data = this.currentDataBuffer.join("\n");
|
|
97
|
+
this.currentDataBuffer = [];
|
|
98
|
+
if (currentEvent === "endpoint") {
|
|
99
|
+
if (data.startsWith("/")) {
|
|
100
|
+
const base = new URL(this.config.url);
|
|
101
|
+
this.endpointUrl = `${base.origin}${data}`;
|
|
102
|
+
}
|
|
103
|
+
else if (data.startsWith("http")) {
|
|
104
|
+
if (!this.isSameOrigin(data)) {
|
|
105
|
+
this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with mismatched origin: ${data}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.endpointUrl = data;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with unsupported URL format: ${data}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.logger.info(`[mcp-bridge] SSE endpoint URL received: ${this.endpointUrl}`);
|
|
115
|
+
if (this._onEndpointReceived) {
|
|
116
|
+
this._onEndpointReceived();
|
|
117
|
+
this._onEndpointReceived = null;
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const message = JSON.parse(data);
|
|
123
|
+
this.handleMessage(message);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.logger.debug("Failed to parse SSE data as JSON:", data);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async sendNotification(notification) {
|
|
131
|
+
if (!this.connected || !this.endpointUrl) {
|
|
132
|
+
throw new Error("SSE transport not connected or no endpoint URL");
|
|
133
|
+
}
|
|
134
|
+
const headers = resolveEnvRecord({
|
|
135
|
+
...this.config.headers,
|
|
136
|
+
"Content-Type": "application/json"
|
|
137
|
+
}, "header");
|
|
138
|
+
const response = await fetch(this.endpointUrl, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers,
|
|
141
|
+
body: JSON.stringify(notification)
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async sendRequest(request) {
|
|
148
|
+
if (!this.connected || !this.endpointUrl) {
|
|
149
|
+
throw new Error("SSE transport not connected or no endpoint URL");
|
|
150
|
+
}
|
|
151
|
+
const id = nextRequestId();
|
|
152
|
+
const requestWithId = { ...request, id };
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
|
|
155
|
+
const timeout = setTimeout(() => {
|
|
156
|
+
this.pendingRequests.delete(id);
|
|
157
|
+
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
158
|
+
}, requestTimeout);
|
|
159
|
+
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
160
|
+
const headers = resolveEnvRecord({
|
|
161
|
+
...this.config.headers,
|
|
162
|
+
"Content-Type": "application/json"
|
|
163
|
+
}, "header");
|
|
164
|
+
// The response arrives via the SSE stream (handleMessage), not from this fetch.
|
|
165
|
+
// The fetch only confirms the server accepted the request (HTTP 200).
|
|
166
|
+
// If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
|
|
167
|
+
fetch(this.endpointUrl, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify(requestWithId)
|
|
171
|
+
})
|
|
172
|
+
.then((response) => {
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
this.pendingRequests.delete(id);
|
|
176
|
+
reject(new Error(`HTTP ${response.status}`));
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
.catch((error) => {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
this.pendingRequests.delete(id);
|
|
182
|
+
reject(error);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
isSameOrigin(url) {
|
|
187
|
+
try {
|
|
188
|
+
if (!this.config.url)
|
|
189
|
+
return false;
|
|
190
|
+
const incoming = new URL(url);
|
|
191
|
+
const base = new URL(this.config.url);
|
|
192
|
+
return incoming.origin === base.origin;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async disconnect() {
|
|
199
|
+
this.connected = false;
|
|
200
|
+
this.cleanupReconnectTimer();
|
|
201
|
+
if (this.sseAbortController) {
|
|
202
|
+
this.sseAbortController.abort();
|
|
203
|
+
this.sseAbortController = null;
|
|
204
|
+
}
|
|
205
|
+
this.rejectAllPending("Connection closed");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { McpRequest, McpResponse } from "./types.js";
|
|
2
|
+
import { BaseTransport } from "./transport-base.js";
|
|
3
|
+
export declare class StdioTransport extends BaseTransport {
|
|
4
|
+
private process;
|
|
5
|
+
private framingMode;
|
|
6
|
+
private stdoutBuffer;
|
|
7
|
+
private isShuttingDown;
|
|
8
|
+
protected get transportName(): string;
|
|
9
|
+
connect(): Promise<void>;
|
|
10
|
+
private startProcess;
|
|
11
|
+
private writeMessage;
|
|
12
|
+
sendNotification(notification: any): Promise<void>;
|
|
13
|
+
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
14
|
+
private processStdoutBuffer;
|
|
15
|
+
private parseNewlineMessageFromBuffer;
|
|
16
|
+
private parseLspMessageFromBuffer;
|
|
17
|
+
disconnect(): Promise<void>;
|
|
18
|
+
isConnected(): boolean;
|
|
19
|
+
private terminateProcessGracefully;
|
|
20
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { nextRequestId } from "./types.js";
|
|
3
|
+
import { BaseTransport, resolveEnvRecord, resolveArgs } from "./transport-base.js";
|
|
4
|
+
export class StdioTransport extends BaseTransport {
|
|
5
|
+
process = null;
|
|
6
|
+
framingMode = "auto";
|
|
7
|
+
stdoutBuffer = Buffer.alloc(0);
|
|
8
|
+
isShuttingDown = false;
|
|
9
|
+
get transportName() { return "stdio"; }
|
|
10
|
+
async connect() {
|
|
11
|
+
if (!this.config.command) {
|
|
12
|
+
throw new Error("Stdio transport requires command");
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
this.isShuttingDown = false;
|
|
16
|
+
await this.startProcess();
|
|
17
|
+
this.connected = true;
|
|
18
|
+
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
this.logger.error("Stdio transport connection failed:", error);
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async startProcess() {
|
|
26
|
+
if (!this.config.command)
|
|
27
|
+
return;
|
|
28
|
+
const env = { ...process.env, ...resolveEnvRecord(this.config.env || {}, "env key") };
|
|
29
|
+
const args = resolveArgs(this.config.args || [], env);
|
|
30
|
+
this.process = spawn(this.config.command, args, {
|
|
31
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
32
|
+
env
|
|
33
|
+
});
|
|
34
|
+
if (!this.process.stdin || !this.process.stdout || !this.process.stderr) {
|
|
35
|
+
throw new Error("Failed to create process pipes");
|
|
36
|
+
}
|
|
37
|
+
this.framingMode = this.config.framing || "auto";
|
|
38
|
+
this.stdoutBuffer = Buffer.alloc(0);
|
|
39
|
+
this.process.stdout.on("data", (data) => {
|
|
40
|
+
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, data]);
|
|
41
|
+
// Safety limit: prevent unbounded buffer growth from misbehaving servers
|
|
42
|
+
const MAX_BUFFER = 50 * 1024 * 1024; // 50MB
|
|
43
|
+
if (this.stdoutBuffer.length > MAX_BUFFER) {
|
|
44
|
+
this.logger.error(`[mcp-bridge] Stdio buffer exceeded ${MAX_BUFFER} bytes, killing process`);
|
|
45
|
+
this.process?.kill();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.processStdoutBuffer();
|
|
49
|
+
});
|
|
50
|
+
this.process.stderr.on("data", (data) => {
|
|
51
|
+
this.logger.debug(`MCP server stderr: ${data.toString()}`);
|
|
52
|
+
});
|
|
53
|
+
this.process.on("exit", (code, signal) => {
|
|
54
|
+
this.logger.debug(`MCP server process exited: code=${code}, signal=${signal}`);
|
|
55
|
+
this.connected = false;
|
|
56
|
+
this.process = null;
|
|
57
|
+
this.rejectAllPending("Process exited");
|
|
58
|
+
if (!this.isShuttingDown) {
|
|
59
|
+
this.scheduleReconnect();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
this.process.on("error", (error) => {
|
|
63
|
+
this.logger.error("MCP server process error:", error);
|
|
64
|
+
this.connected = false;
|
|
65
|
+
this.process = null;
|
|
66
|
+
this.rejectAllPending("Process error");
|
|
67
|
+
if (!this.isShuttingDown) {
|
|
68
|
+
this.scheduleReconnect();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const connectionTimeout = this.clientConfig.connectionTimeoutMs || 5000;
|
|
72
|
+
await new Promise((resolve, reject) => {
|
|
73
|
+
let settled = false;
|
|
74
|
+
let timeout;
|
|
75
|
+
const cleanup = () => {
|
|
76
|
+
this.process?.stdout?.off("data", onFirstData);
|
|
77
|
+
this.process?.off("error", onProcessError);
|
|
78
|
+
this.process?.off("exit", onProcessExit);
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
};
|
|
81
|
+
const settleResolve = () => {
|
|
82
|
+
if (settled)
|
|
83
|
+
return;
|
|
84
|
+
settled = true;
|
|
85
|
+
cleanup();
|
|
86
|
+
resolve();
|
|
87
|
+
};
|
|
88
|
+
const settleReject = (error) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
cleanup();
|
|
93
|
+
reject(error);
|
|
94
|
+
};
|
|
95
|
+
const onFirstData = () => settleResolve();
|
|
96
|
+
const onProcessError = (error) => settleReject(error);
|
|
97
|
+
const onProcessExit = () => settleReject(new Error("MCP server exited before stdout became ready"));
|
|
98
|
+
this.process.stdout.once("data", onFirstData);
|
|
99
|
+
this.process.once("error", onProcessError);
|
|
100
|
+
this.process.once("exit", onProcessExit);
|
|
101
|
+
timeout = setTimeout(() => {
|
|
102
|
+
this.logger.warn(`[mcp-bridge] Stdio startup stdout readiness timed out after ${connectionTimeout}ms; continuing`);
|
|
103
|
+
settleResolve();
|
|
104
|
+
}, connectionTimeout);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
writeMessage(message) {
|
|
108
|
+
const json = JSON.stringify(message);
|
|
109
|
+
if (this.framingMode === "lsp") {
|
|
110
|
+
const body = Buffer.from(json, "utf8");
|
|
111
|
+
this.process.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
112
|
+
this.process.stdin.write(body);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
this.process.stdin.write(json + '\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async sendNotification(notification) {
|
|
119
|
+
if (!this.connected || !this.process?.stdin) {
|
|
120
|
+
throw new Error("Stdio transport not connected");
|
|
121
|
+
}
|
|
122
|
+
this.writeMessage(notification);
|
|
123
|
+
}
|
|
124
|
+
async sendRequest(request) {
|
|
125
|
+
if (!this.connected || !this.process?.stdin) {
|
|
126
|
+
throw new Error("Stdio transport not connected");
|
|
127
|
+
}
|
|
128
|
+
const id = nextRequestId();
|
|
129
|
+
const requestWithId = { ...request, id };
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
|
|
132
|
+
const timeout = setTimeout(() => {
|
|
133
|
+
this.pendingRequests.delete(id);
|
|
134
|
+
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
135
|
+
}, requestTimeout);
|
|
136
|
+
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
137
|
+
try {
|
|
138
|
+
this.writeMessage(requestWithId);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
this.pendingRequests.delete(id);
|
|
143
|
+
reject(error);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
processStdoutBuffer() {
|
|
148
|
+
while (true) {
|
|
149
|
+
if (this.framingMode === "auto") {
|
|
150
|
+
const bufferText = this.stdoutBuffer.toString("utf8");
|
|
151
|
+
if (bufferText.includes("Content-Length:")) {
|
|
152
|
+
this.framingMode = "lsp";
|
|
153
|
+
}
|
|
154
|
+
else if (this.stdoutBuffer.includes(0x0a)) {
|
|
155
|
+
this.framingMode = "newline";
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (this.framingMode === "lsp") {
|
|
162
|
+
if (!this.parseLspMessageFromBuffer())
|
|
163
|
+
return;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!this.parseNewlineMessageFromBuffer())
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
parseNewlineMessageFromBuffer() {
|
|
171
|
+
const newlineIndex = this.stdoutBuffer.indexOf(0x0a);
|
|
172
|
+
if (newlineIndex === -1)
|
|
173
|
+
return false;
|
|
174
|
+
const lineBuffer = this.stdoutBuffer.subarray(0, newlineIndex);
|
|
175
|
+
this.stdoutBuffer = this.stdoutBuffer.subarray(newlineIndex + 1);
|
|
176
|
+
const line = lineBuffer.toString("utf8").trim();
|
|
177
|
+
if (!line)
|
|
178
|
+
return true;
|
|
179
|
+
try {
|
|
180
|
+
const message = JSON.parse(line);
|
|
181
|
+
this.handleMessage(message);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
this.logger.debug("Failed to parse stdout JSON:", line);
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
parseLspMessageFromBuffer() {
|
|
189
|
+
const separator = Buffer.from("\r\n\r\n");
|
|
190
|
+
let headerEndIndex = this.stdoutBuffer.indexOf(separator);
|
|
191
|
+
let headerLength = separator.length;
|
|
192
|
+
if (headerEndIndex === -1) {
|
|
193
|
+
const altSeparator = Buffer.from("\n\n");
|
|
194
|
+
headerEndIndex = this.stdoutBuffer.indexOf(altSeparator);
|
|
195
|
+
headerLength = altSeparator.length;
|
|
196
|
+
}
|
|
197
|
+
if (headerEndIndex === -1)
|
|
198
|
+
return false;
|
|
199
|
+
const headerText = this.stdoutBuffer.subarray(0, headerEndIndex).toString("utf8");
|
|
200
|
+
const contentLengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
|
|
201
|
+
if (!contentLengthMatch) {
|
|
202
|
+
this.logger.warn("[mcp-bridge] Missing Content-Length in LSP-framed stdout message; dropping malformed frame");
|
|
203
|
+
this.stdoutBuffer = this.stdoutBuffer.subarray(headerEndIndex + headerLength);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
|
207
|
+
const bodyStart = headerEndIndex + headerLength;
|
|
208
|
+
const bodyEnd = bodyStart + contentLength;
|
|
209
|
+
if (this.stdoutBuffer.length < bodyEnd)
|
|
210
|
+
return false;
|
|
211
|
+
const body = this.stdoutBuffer.subarray(bodyStart, bodyEnd).toString("utf8");
|
|
212
|
+
this.stdoutBuffer = this.stdoutBuffer.subarray(bodyEnd);
|
|
213
|
+
try {
|
|
214
|
+
const message = JSON.parse(body);
|
|
215
|
+
this.handleMessage(message);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
this.logger.debug("Failed to parse LSP stdout JSON:", body);
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
async disconnect() {
|
|
223
|
+
this.isShuttingDown = true;
|
|
224
|
+
this.connected = false;
|
|
225
|
+
this.cleanupReconnectTimer();
|
|
226
|
+
const activeProcess = this.process;
|
|
227
|
+
if (activeProcess) {
|
|
228
|
+
if (activeProcess.stdin && !activeProcess.stdin.destroyed) {
|
|
229
|
+
try {
|
|
230
|
+
this.writeMessage({ jsonrpc: "2.0", method: "close" });
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
this.logger.debug("[mcp-bridge] Failed to send close notification during stdio disconnect");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
await this.terminateProcessGracefully(activeProcess);
|
|
237
|
+
if (this.process === activeProcess) {
|
|
238
|
+
this.process = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this.rejectAllPending("Connection closed");
|
|
242
|
+
}
|
|
243
|
+
isConnected() {
|
|
244
|
+
return this.connected && this.process !== null;
|
|
245
|
+
}
|
|
246
|
+
async terminateProcessGracefully(proc) {
|
|
247
|
+
if (proc.exitCode !== null || proc.killed)
|
|
248
|
+
return;
|
|
249
|
+
await new Promise((resolve) => {
|
|
250
|
+
let done = false;
|
|
251
|
+
let forceKillTimer = null;
|
|
252
|
+
const finish = () => {
|
|
253
|
+
if (done)
|
|
254
|
+
return;
|
|
255
|
+
done = true;
|
|
256
|
+
if (forceKillTimer)
|
|
257
|
+
clearTimeout(forceKillTimer);
|
|
258
|
+
proc.off("exit", onExit);
|
|
259
|
+
resolve();
|
|
260
|
+
};
|
|
261
|
+
const onExit = () => finish();
|
|
262
|
+
proc.once("exit", onExit);
|
|
263
|
+
try {
|
|
264
|
+
proc.kill("SIGINT");
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
finish();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
forceKillTimer = setTimeout(() => {
|
|
271
|
+
if (proc.exitCode === null && !proc.killed) {
|
|
272
|
+
try {
|
|
273
|
+
proc.kill("SIGTERM");
|
|
274
|
+
}
|
|
275
|
+
catch { /* ignore */ }
|
|
276
|
+
}
|
|
277
|
+
setTimeout(finish, 200);
|
|
278
|
+
}, 2000);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpRequest, McpResponse } from "./types.js";
|
|
2
|
+
import { BaseTransport } from "./transport-base.js";
|
|
3
|
+
export declare class StreamableHttpTransport extends BaseTransport {
|
|
4
|
+
private sessionId?;
|
|
5
|
+
protected get transportName(): string;
|
|
6
|
+
connect(): Promise<void>;
|
|
7
|
+
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
8
|
+
sendNotification(notification: any): Promise<void>;
|
|
9
|
+
private probeServer;
|
|
10
|
+
disconnect(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { nextRequestId } from "./types.js";
|
|
2
|
+
import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
3
|
+
export class StreamableHttpTransport extends BaseTransport {
|
|
4
|
+
sessionId;
|
|
5
|
+
get transportName() { return "streamable-http"; }
|
|
6
|
+
async connect() {
|
|
7
|
+
if (!this.config.url) {
|
|
8
|
+
throw new Error("Streamable HTTP transport requires URL");
|
|
9
|
+
}
|
|
10
|
+
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
11
|
+
// Validate that all header env vars resolve (fail fast)
|
|
12
|
+
resolveEnvRecord(this.config.headers || {}, "header");
|
|
13
|
+
await this.probeServer();
|
|
14
|
+
this.connected = true;
|
|
15
|
+
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
16
|
+
this.logger.info(`[mcp-bridge] Streamable HTTP transport ready for ${this.config.url}`);
|
|
17
|
+
}
|
|
18
|
+
async sendRequest(request) {
|
|
19
|
+
if (!this.connected || !this.config.url) {
|
|
20
|
+
throw new Error("Streamable HTTP transport not connected");
|
|
21
|
+
}
|
|
22
|
+
const id = nextRequestId();
|
|
23
|
+
const requestWithId = { ...request, id };
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
|
|
26
|
+
const timeout = setTimeout(() => {
|
|
27
|
+
this.pendingRequests.delete(id);
|
|
28
|
+
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
29
|
+
}, requestTimeout);
|
|
30
|
+
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
31
|
+
const headers = resolveEnvRecord({
|
|
32
|
+
"Accept": "application/json, text/event-stream",
|
|
33
|
+
...this.config.headers,
|
|
34
|
+
"Content-Type": "application/json"
|
|
35
|
+
}, "header");
|
|
36
|
+
if (this.sessionId) {
|
|
37
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
38
|
+
}
|
|
39
|
+
fetch(this.config.url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers,
|
|
42
|
+
body: JSON.stringify(requestWithId)
|
|
43
|
+
})
|
|
44
|
+
.then(async (response) => {
|
|
45
|
+
const responseSessionId = response.headers.get("mcp-session-id");
|
|
46
|
+
if (responseSessionId) {
|
|
47
|
+
this.sessionId = responseSessionId;
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
this.pendingRequests.delete(id);
|
|
52
|
+
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const contentType = response.headers.get("content-type") || "";
|
|
57
|
+
let jsonResponse;
|
|
58
|
+
if (contentType.includes("text/event-stream")) {
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
const dataLines = text.split('\n')
|
|
61
|
+
.filter((line) => line.startsWith('data:'))
|
|
62
|
+
.map((line) => line.substring(5).trim());
|
|
63
|
+
if (dataLines.length > 0) {
|
|
64
|
+
jsonResponse = JSON.parse(dataLines[dataLines.length - 1]);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
throw new Error("No data lines in SSE response");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
jsonResponse = await response.json();
|
|
72
|
+
}
|
|
73
|
+
this.handleMessage(jsonResponse);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
this.pendingRequests.delete(id);
|
|
78
|
+
reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.catch(error => {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
this.pendingRequests.delete(id);
|
|
84
|
+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
85
|
+
this.logger.error("Connection error, scheduling reconnect:", error.message);
|
|
86
|
+
this.scheduleReconnect();
|
|
87
|
+
}
|
|
88
|
+
reject(error);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async sendNotification(notification) {
|
|
93
|
+
if (!this.connected || !this.config.url) {
|
|
94
|
+
throw new Error("Streamable HTTP transport not connected");
|
|
95
|
+
}
|
|
96
|
+
const headers = resolveEnvRecord({
|
|
97
|
+
"Accept": "application/json, text/event-stream",
|
|
98
|
+
...this.config.headers,
|
|
99
|
+
"Content-Type": "application/json"
|
|
100
|
+
}, "header");
|
|
101
|
+
if (this.sessionId) {
|
|
102
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(this.config.url, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers,
|
|
108
|
+
body: JSON.stringify(notification)
|
|
109
|
+
});
|
|
110
|
+
const responseSessionId = response.headers.get("mcp-session-id");
|
|
111
|
+
if (responseSessionId) {
|
|
112
|
+
this.sessionId = responseSessionId;
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok && response.status >= 500) {
|
|
115
|
+
throw new Error(`Server error: HTTP ${response.status}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof Error && error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
120
|
+
this.logger.error("Connection error during notification, scheduling reconnect:", error.message);
|
|
121
|
+
this.scheduleReconnect();
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async probeServer() {
|
|
127
|
+
if (!this.config.url)
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
const optionsResponse = await fetch(this.config.url, { method: "OPTIONS" });
|
|
131
|
+
if (optionsResponse.ok)
|
|
132
|
+
return;
|
|
133
|
+
const headers = resolveEnvRecord(this.config.headers || {}, "header");
|
|
134
|
+
const headResponse = await fetch(this.config.url, { method: "HEAD", headers });
|
|
135
|
+
if (!headResponse.ok) {
|
|
136
|
+
this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.logger.warn(`[mcp-bridge] Streamable HTTP server probe failed (non-blocking): ${error?.message || error}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async disconnect() {
|
|
144
|
+
this.connected = false;
|
|
145
|
+
this.cleanupReconnectTimer();
|
|
146
|
+
// Send DELETE request if we have a session to clean up
|
|
147
|
+
if (this.sessionId && this.config.url) {
|
|
148
|
+
try {
|
|
149
|
+
const headers = resolveEnvRecord(this.config.headers || {}, "header");
|
|
150
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
151
|
+
await fetch(this.config.url, {
|
|
152
|
+
method: "DELETE",
|
|
153
|
+
headers
|
|
154
|
+
});
|
|
155
|
+
this.sessionId = undefined;
|
|
156
|
+
this.logger.info("Streamable HTTP session cleaned up");
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
this.logger.warn("Failed to clean up session on disconnect:", error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.rejectAllPending("Connection closed");
|
|
163
|
+
}
|
|
164
|
+
}
|