@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.
Files changed (63) hide show
  1. package/dist/bin/mcp-bridge.d.ts +2 -0
  2. package/dist/bin/mcp-bridge.js +320 -0
  3. package/dist/src/config.d.ts +19 -0
  4. package/dist/src/config.js +145 -0
  5. package/{src/index.ts → dist/src/index.d.ts} +1 -30
  6. package/dist/src/index.js +21 -0
  7. package/dist/src/mcp-router.d.ts +65 -0
  8. package/dist/src/mcp-router.js +271 -0
  9. package/dist/src/protocol.d.ts +4 -0
  10. package/dist/src/protocol.js +58 -0
  11. package/dist/src/schema-convert.d.ts +11 -0
  12. package/dist/src/schema-convert.js +150 -0
  13. package/dist/src/standalone-server.d.ts +30 -0
  14. package/dist/src/standalone-server.js +312 -0
  15. package/dist/src/tool-naming.d.ts +3 -0
  16. package/dist/src/tool-naming.js +38 -0
  17. package/dist/src/transport-base.d.ts +76 -0
  18. package/dist/src/transport-base.js +163 -0
  19. package/dist/src/transport-sse.d.ts +16 -0
  20. package/dist/src/transport-sse.js +207 -0
  21. package/dist/src/transport-stdio.d.ts +20 -0
  22. package/dist/src/transport-stdio.js +281 -0
  23. package/dist/src/transport-streamable-http.d.ts +11 -0
  24. package/dist/src/transport-streamable-http.js +164 -0
  25. package/dist/src/types.d.ts +72 -0
  26. package/dist/src/types.js +4 -0
  27. package/dist/src/update-checker.d.ts +25 -0
  28. package/dist/src/update-checker.js +132 -0
  29. package/package.json +19 -4
  30. package/scripts/install-server.ps1 +25 -58
  31. package/scripts/install-server.sh +37 -90
  32. package/servers/apify/README.md +6 -6
  33. package/servers/github/README.md +6 -6
  34. package/servers/google-maps/README.md +6 -6
  35. package/servers/hetzner/README.md +6 -6
  36. package/servers/hostinger/README.md +6 -6
  37. package/servers/linear/README.md +6 -6
  38. package/servers/miro/README.md +6 -6
  39. package/servers/notion/README.md +6 -6
  40. package/servers/stripe/README.md +6 -6
  41. package/servers/tavily/README.md +6 -6
  42. package/servers/todoist/README.md +6 -6
  43. package/servers/wise/README.md +6 -6
  44. package/bin/mcp-bridge.js +0 -9
  45. package/bin/mcp-bridge.ts +0 -335
  46. package/src/config.ts +0 -168
  47. package/src/mcp-router.ts +0 -366
  48. package/src/protocol.ts +0 -69
  49. package/src/schema-convert.ts +0 -178
  50. package/src/standalone-server.ts +0 -385
  51. package/src/tool-naming.ts +0 -51
  52. package/src/transport-base.ts +0 -199
  53. package/src/transport-sse.ts +0 -230
  54. package/src/transport-stdio.ts +0 -312
  55. package/src/transport-streamable-http.ts +0 -188
  56. package/src/types.ts +0 -88
  57. package/src/update-checker.ts +0 -155
  58. package/tests/collision.test.ts +0 -60
  59. package/tests/env-resolve.test.ts +0 -68
  60. package/tests/mcp-router.test.ts +0 -301
  61. package/tests/schema-convert.test.ts +0 -70
  62. package/tests/transport-base.test.ts +0 -214
  63. package/tsconfig.json +0 -15
@@ -1,199 +0,0 @@
1
- import { McpTransport, McpRequest, McpResponse, McpServerConfig } from "./types.js";
2
-
3
- export type PendingRequest = { resolve: Function; reject: Function; timeout: NodeJS.Timeout };
4
-
5
- /**
6
- * Base class for all MCP transports. Provides shared logic for:
7
- * - Message handling (JSON-RPC response routing, notification dispatch)
8
- * - Pending request management with timeout
9
- * - Reconnection with exponential backoff + jitter
10
- * - Environment variable resolution for headers, env, and args
11
- * - Non-TLS remote URL warnings
12
- */
13
- export abstract class BaseTransport implements McpTransport {
14
- protected config: McpServerConfig;
15
- protected clientConfig: any;
16
- protected connected = false;
17
- protected pendingRequests = new Map<number, PendingRequest>();
18
- protected logger: any;
19
- protected reconnectTimer: NodeJS.Timeout | null = null;
20
- protected onReconnected?: () => Promise<void>;
21
- protected backoffDelay = 0;
22
-
23
- constructor(config: McpServerConfig, clientConfig: any, logger: any, onReconnected?: () => Promise<void>) {
24
- this.config = config;
25
- this.clientConfig = clientConfig;
26
- this.logger = logger;
27
- this.onReconnected = onReconnected;
28
- }
29
-
30
- abstract connect(): Promise<void>;
31
- abstract disconnect(): Promise<void>;
32
- abstract sendRequest(request: McpRequest): Promise<McpResponse>;
33
- abstract sendNotification(notification: any): Promise<void>;
34
-
35
- isConnected(): boolean {
36
- return this.connected;
37
- }
38
-
39
- /** Human-readable transport name for log messages (e.g. "stdio", "SSE", "streamable-http"). */
40
- protected abstract get transportName(): string;
41
-
42
- /**
43
- * Route an incoming JSON-RPC message to the appropriate handler:
44
- * - notifications/tools/list_changed -> trigger tool refresh
45
- * - Other notifications -> debug log
46
- * - Responses with id -> resolve/reject matching pending request
47
- */
48
- protected handleMessage(message: any): void {
49
- if (!message.id && message.method === "notifications/tools/list_changed") {
50
- if (this.onReconnected) {
51
- this.onReconnected().catch((error) => {
52
- this.logger.error("[mcp-bridge] Failed to refresh tools after list_changed notification:", error);
53
- });
54
- }
55
- return;
56
- }
57
-
58
- if (!message.id && message.method) {
59
- this.logger.debug(`[mcp-bridge] Unhandled ${this.transportName} notification: ${message.method}`);
60
- return;
61
- }
62
-
63
- if (message.id && this.pendingRequests.has(message.id)) {
64
- const pending = this.pendingRequests.get(message.id)!;
65
- clearTimeout(pending.timeout);
66
- this.pendingRequests.delete(message.id);
67
-
68
- if (message.error) {
69
- pending.reject(new Error(message.error.message || "MCP error"));
70
- } else {
71
- pending.resolve(message);
72
- }
73
- }
74
- }
75
-
76
- /** Reject and clear all pending requests with the given reason. */
77
- protected rejectAllPending(reason: string): void {
78
- for (const [, pending] of this.pendingRequests) {
79
- clearTimeout(pending.timeout);
80
- pending.reject(new Error(reason));
81
- }
82
- this.pendingRequests.clear();
83
- }
84
-
85
- /**
86
- * Schedule a reconnection attempt with exponential backoff and jitter.
87
- * Rejects all pending requests before scheduling.
88
- */
89
- protected scheduleReconnect(): void {
90
- if (this.reconnectTimer) return;
91
-
92
- this.connected = false;
93
- this.rejectAllPending("Connection lost, request cancelled");
94
-
95
- const baseDelay = this.clientConfig.reconnectIntervalMs || 30000;
96
- if (this.backoffDelay <= 0) {
97
- this.backoffDelay = baseDelay;
98
- }
99
- const jitter = 0.5 + Math.random(); // 0.5x-1.5x jitter
100
- const reconnectInterval = Math.round(this.backoffDelay * jitter);
101
- this.reconnectTimer = setTimeout(async () => {
102
- this.reconnectTimer = null;
103
- try {
104
- await this.connect();
105
- this.logger.info(`${this.transportName} transport reconnected successfully`);
106
- this.backoffDelay = baseDelay;
107
-
108
- if (this.onReconnected) {
109
- await this.onReconnected();
110
- }
111
- } catch (error) {
112
- this.logger.error("Reconnection failed:", error);
113
- this.backoffDelay = Math.min(this.backoffDelay * 2, 300000);
114
- this.scheduleReconnect();
115
- }
116
- }, reconnectInterval);
117
- }
118
-
119
- /** Cancel any scheduled reconnection timer. */
120
- protected cleanupReconnectTimer(): void {
121
- if (this.reconnectTimer) {
122
- clearTimeout(this.reconnectTimer);
123
- this.reconnectTimer = null;
124
- }
125
- }
126
- }
127
-
128
- // -- Shared utility functions -----------------------------------------------
129
-
130
- /**
131
- * Resolve ${VAR} placeholders in a single string value using environment variables.
132
- * Throws if a referenced variable is not defined.
133
- *
134
- * @param value - String potentially containing ${VAR} placeholders
135
- * @param contextDescription - Human-readable context for error messages (e.g. 'header "Authorization"')
136
- * @param extraEnv - Additional env vars to check before process.env (e.g. merged child process env)
137
- */
138
- export function resolveEnvVars(
139
- value: string,
140
- contextDescription: string,
141
- extraEnv?: Record<string, string | undefined>
142
- ): string {
143
- return value.replace(/\$\{(\w+)\}/g, (_, varName) => {
144
- const resolved = extraEnv?.[varName] ?? process.env[varName];
145
- if (resolved === undefined) {
146
- throw new Error(`[mcp-bridge] Missing required environment variable "${varName}" while resolving ${contextDescription}`);
147
- }
148
- return resolved;
149
- });
150
- }
151
-
152
- /**
153
- * Resolve ${VAR} placeholders in all values of a Record<string, string>.
154
- *
155
- * @param record - Key-value pairs with potential ${VAR} placeholders in values
156
- * @param contextPrefix - Prefix for error context (e.g. "header", "env key")
157
- * @param extraEnv - Additional env vars to check before process.env
158
- */
159
- export function resolveEnvRecord(
160
- record: Record<string, string>,
161
- contextPrefix: string,
162
- extraEnv?: Record<string, string | undefined>
163
- ): Record<string, string> {
164
- const resolved: Record<string, string> = {};
165
- for (const [key, value] of Object.entries(record)) {
166
- resolved[key] = resolveEnvVars(value, `${contextPrefix} "${key}"`, extraEnv);
167
- }
168
- return resolved;
169
- }
170
-
171
- /**
172
- * Resolve ${VAR} placeholders in an array of command arguments.
173
- *
174
- * @param args - Array of argument strings with potential ${VAR} placeholders
175
- * @param extraEnv - Additional env vars to check before process.env
176
- */
177
- export function resolveArgs(
178
- args: string[],
179
- extraEnv?: Record<string, string | undefined>
180
- ): string[] {
181
- return args.map(arg =>
182
- resolveEnvVars(arg, `arg "${arg}"`, extraEnv)
183
- );
184
- }
185
-
186
- /**
187
- * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
188
- */
189
- export function warnIfNonTlsRemoteUrl(rawUrl: string, logger: any): void {
190
- try {
191
- const parsed = new URL(rawUrl);
192
- if (parsed.protocol !== "http:") return;
193
- const host = parsed.hostname;
194
- if (host === "localhost" || host === "127.0.0.1" || host === "::1") return;
195
- logger.warn(`[mcp-bridge] WARNING: Non-TLS connection to ${host} — credentials may be transmitted in plaintext`);
196
- } catch {
197
- // Ignore malformed URL here; connect() validation will fail later.
198
- }
199
- }
@@ -1,230 +0,0 @@
1
- import { McpRequest, McpResponse, McpServerConfig, nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
-
4
- export class SseTransport extends BaseTransport {
5
- private endpointUrl: string | null = null;
6
- private sseAbortController: AbortController | null = null;
7
- private currentDataBuffer: string[] = [];
8
-
9
- protected get transportName(): string { return "SSE"; }
10
-
11
- async connect(): Promise<void> {
12
- if (!this.config.url) {
13
- throw new Error("SSE transport requires URL");
14
- }
15
-
16
- warnIfNonTlsRemoteUrl(this.config.url, this.logger);
17
- // Validate that all header env vars resolve (fail fast)
18
- resolveEnvRecord(this.config.headers || {}, "header");
19
-
20
- this.sseAbortController = new AbortController();
21
-
22
- const connectionTimeout = this.clientConfig.connectionTimeoutMs || 10000;
23
- const streamReady = new Promise<void>((resolve, reject) => {
24
- const timer = setTimeout(() => reject(new Error("SSE endpoint URL not received within timeout")), connectionTimeout);
25
- this._onEndpointReceived = () => { clearTimeout(timer); resolve(); };
26
- });
27
-
28
- // Fire and forget the stream reader
29
- this.startEventStream().catch((error) => {
30
- if (error instanceof Error && error.name !== 'AbortError') {
31
- this.logger.error("[mcp-bridge] SSE stream error:", error.message);
32
- this.scheduleReconnect();
33
- }
34
- });
35
-
36
- await streamReady;
37
- this.connected = true;
38
- this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
39
- }
40
-
41
- private _onEndpointReceived: (() => void) | null = null;
42
-
43
- private async startEventStream(): Promise<void> {
44
- if (!this.config.url) return;
45
-
46
- const headers = resolveEnvRecord({
47
- ...this.config.headers,
48
- "Accept": "text/event-stream"
49
- }, "header");
50
-
51
- try {
52
- const response = await fetch(this.config.url, {
53
- method: "GET",
54
- headers,
55
- signal: this.sseAbortController?.signal
56
- });
57
-
58
- if (!response.ok) {
59
- throw new Error(`SSE connection failed: HTTP ${response.status}`);
60
- }
61
-
62
- if (!response.body) {
63
- throw new Error("No response body for SSE stream");
64
- }
65
-
66
- const reader = response.body.getReader();
67
- const decoder = new TextDecoder();
68
-
69
- let buffer = "";
70
- let currentEvent = "";
71
-
72
- while (true) {
73
- const { done, value } = await reader.read();
74
- if (done) break;
75
-
76
- buffer += decoder.decode(value, { stream: true });
77
- const lines = buffer.split('\n');
78
- buffer = lines.pop() || "";
79
-
80
- for (const line of lines) {
81
- const trimmed = line.trim();
82
- if (trimmed.startsWith("event: ")) {
83
- currentEvent = trimmed.substring(7).trim();
84
- } else if (trimmed === "") {
85
- this.processEventLine(line, currentEvent);
86
- currentEvent = "";
87
- } else {
88
- this.processEventLine(line, currentEvent);
89
- }
90
- }
91
- }
92
- } catch (error) {
93
- if (error instanceof Error && error.name === 'AbortError') return;
94
- this.logger.error("SSE stream error:", error);
95
- this.scheduleReconnect();
96
- }
97
- }
98
-
99
- private processEventLine(line: string, currentEvent: string = ""): void {
100
- const trimmed = line.trim();
101
- if (trimmed.startsWith("event: ")) return;
102
-
103
- if (trimmed.startsWith("data: ")) {
104
- this.currentDataBuffer.push(trimmed.substring(6));
105
- return;
106
- }
107
-
108
- if (trimmed === "") {
109
- if (this.currentDataBuffer.length === 0) return;
110
-
111
- const data = this.currentDataBuffer.join("\n");
112
- this.currentDataBuffer = [];
113
-
114
- if (currentEvent === "endpoint") {
115
- if (data.startsWith("/")) {
116
- const base = new URL(this.config.url!);
117
- this.endpointUrl = `${base.origin}${data}`;
118
- } else if (data.startsWith("http")) {
119
- if (!this.isSameOrigin(data)) {
120
- this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with mismatched origin: ${data}`);
121
- return;
122
- }
123
- this.endpointUrl = data;
124
- } else {
125
- this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with unsupported URL format: ${data}`);
126
- return;
127
- }
128
- this.logger.info(`[mcp-bridge] SSE endpoint URL received: ${this.endpointUrl}`);
129
- if (this._onEndpointReceived) {
130
- this._onEndpointReceived();
131
- this._onEndpointReceived = null;
132
- }
133
- return;
134
- }
135
-
136
- try {
137
- const message = JSON.parse(data);
138
- this.handleMessage(message);
139
- } catch {
140
- this.logger.debug("Failed to parse SSE data as JSON:", data);
141
- }
142
- }
143
- }
144
-
145
- async sendNotification(notification: any): Promise<void> {
146
- if (!this.connected || !this.endpointUrl) {
147
- throw new Error("SSE transport not connected or no endpoint URL");
148
- }
149
- const headers = resolveEnvRecord({
150
- ...this.config.headers,
151
- "Content-Type": "application/json"
152
- }, "header");
153
- const response = await fetch(this.endpointUrl!, {
154
- method: "POST",
155
- headers,
156
- body: JSON.stringify(notification)
157
- });
158
- if (!response.ok) {
159
- this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
160
- }
161
- }
162
-
163
- async sendRequest(request: McpRequest): Promise<McpResponse> {
164
- if (!this.connected || !this.endpointUrl) {
165
- throw new Error("SSE transport not connected or no endpoint URL");
166
- }
167
-
168
- const id = nextRequestId();
169
- const requestWithId = { ...request, id };
170
-
171
- return new Promise((resolve, reject) => {
172
- const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
173
- const timeout = setTimeout(() => {
174
- this.pendingRequests.delete(id);
175
- reject(new Error(`Request timeout after ${requestTimeout}ms`));
176
- }, requestTimeout);
177
-
178
- this.pendingRequests.set(id, { resolve, reject, timeout });
179
-
180
- const headers = resolveEnvRecord({
181
- ...this.config.headers,
182
- "Content-Type": "application/json"
183
- }, "header");
184
-
185
- // The response arrives via the SSE stream (handleMessage), not from this fetch.
186
- // The fetch only confirms the server accepted the request (HTTP 200).
187
- // If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
188
- fetch(this.endpointUrl!, {
189
- method: "POST",
190
- headers,
191
- body: JSON.stringify(requestWithId)
192
- })
193
- .then((response) => {
194
- if (!response.ok) {
195
- clearTimeout(timeout);
196
- this.pendingRequests.delete(id);
197
- reject(new Error(`HTTP ${response.status}`));
198
- }
199
- })
200
- .catch((error) => {
201
- clearTimeout(timeout);
202
- this.pendingRequests.delete(id);
203
- reject(error);
204
- });
205
- });
206
- }
207
-
208
- private isSameOrigin(url: string): boolean {
209
- try {
210
- if (!this.config.url) return false;
211
- const incoming = new URL(url);
212
- const base = new URL(this.config.url);
213
- return incoming.origin === base.origin;
214
- } catch {
215
- return false;
216
- }
217
- }
218
-
219
- async disconnect(): Promise<void> {
220
- this.connected = false;
221
- this.cleanupReconnectTimer();
222
-
223
- if (this.sseAbortController) {
224
- this.sseAbortController.abort();
225
- this.sseAbortController = null;
226
- }
227
-
228
- this.rejectAllPending("Connection closed");
229
- }
230
- }