@aiwerk/mcp-bridge 1.8.0 → 2.0.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,120 @@
1
+ const DEFAULT_MAX_ENTRIES = 100;
2
+ const DEFAULT_TTL_MS = 300_000;
3
+ function normalizeForStableJson(value, inArray) {
4
+ if (value === undefined || typeof value === "function" || typeof value === "symbol") {
5
+ return inArray ? null : undefined;
6
+ }
7
+ if (value === null || typeof value !== "object") {
8
+ return value;
9
+ }
10
+ if (Array.isArray(value)) {
11
+ return value.map((item) => normalizeForStableJson(item, true));
12
+ }
13
+ const obj = value;
14
+ const normalized = {};
15
+ const keys = Object.keys(obj).sort();
16
+ for (const key of keys) {
17
+ const normalizedValue = normalizeForStableJson(obj[key], false);
18
+ if (normalizedValue !== undefined) {
19
+ normalized[key] = normalizedValue;
20
+ }
21
+ }
22
+ return normalized;
23
+ }
24
+ export function stableStringify(value) {
25
+ const normalized = normalizeForStableJson(value, false);
26
+ const serialized = JSON.stringify(normalized);
27
+ return serialized === undefined ? "undefined" : serialized;
28
+ }
29
+ export function createResultCacheKey(server, tool, params) {
30
+ return `${server}:${tool}:${stableStringify(params)}`;
31
+ }
32
+ export class ResultCache {
33
+ maxEntries;
34
+ defaultTtlMs;
35
+ cacheTtl;
36
+ entries = new Map();
37
+ hits = 0;
38
+ misses = 0;
39
+ evictions = 0;
40
+ constructor(config = {}) {
41
+ this.maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
42
+ this.defaultTtlMs = config.defaultTtlMs ?? DEFAULT_TTL_MS;
43
+ this.cacheTtl = config.cacheTtl ?? {};
44
+ }
45
+ get(key) {
46
+ const entry = this.entries.get(key);
47
+ if (!entry) {
48
+ this.misses += 1;
49
+ return undefined;
50
+ }
51
+ if (entry.expiresAt <= Date.now()) {
52
+ this.entries.delete(key);
53
+ this.misses += 1;
54
+ return undefined;
55
+ }
56
+ this.entries.delete(key);
57
+ this.entries.set(key, entry);
58
+ this.hits += 1;
59
+ return entry.value;
60
+ }
61
+ set(key, value, ttlMs) {
62
+ const effectiveTtlMs = ttlMs ?? this.resolveTtlMsForKey(key);
63
+ const entry = {
64
+ value,
65
+ expiresAt: Date.now() + effectiveTtlMs
66
+ };
67
+ if (this.entries.has(key)) {
68
+ this.entries.delete(key);
69
+ }
70
+ this.entries.set(key, entry);
71
+ this.trimToCapacity();
72
+ }
73
+ invalidate(pattern) {
74
+ if (!pattern) {
75
+ const size = this.entries.size;
76
+ this.entries.clear();
77
+ return size;
78
+ }
79
+ let removed = 0;
80
+ for (const key of this.entries.keys()) {
81
+ const matches = typeof pattern === "string"
82
+ ? key.includes(pattern)
83
+ : pattern.test(key);
84
+ if (matches) {
85
+ this.entries.delete(key);
86
+ removed += 1;
87
+ }
88
+ }
89
+ return removed;
90
+ }
91
+ stats() {
92
+ return {
93
+ hits: this.hits,
94
+ misses: this.misses,
95
+ evictions: this.evictions,
96
+ size: this.entries.size
97
+ };
98
+ }
99
+ resolveTtlMsForKey(key) {
100
+ const firstColon = key.indexOf(":");
101
+ const secondColon = key.indexOf(":", firstColon + 1);
102
+ if (firstColon === -1 || secondColon === -1) {
103
+ return this.defaultTtlMs;
104
+ }
105
+ const server = key.slice(0, firstColon);
106
+ const tool = key.slice(firstColon + 1, secondColon);
107
+ const override = this.cacheTtl[`${server}:${tool}`];
108
+ return override ?? this.defaultTtlMs;
109
+ }
110
+ trimToCapacity() {
111
+ while (this.entries.size > this.maxEntries) {
112
+ const oldestKey = this.entries.keys().next().value;
113
+ if (oldestKey === undefined) {
114
+ break;
115
+ }
116
+ this.entries.delete(oldestKey);
117
+ this.evictions += 1;
118
+ }
119
+ }
120
+ }
@@ -207,11 +207,24 @@ export class StandaloneServer {
207
207
  type: "object",
208
208
  properties: {
209
209
  server: { type: "string", description: "Server name" },
210
- action: { type: "string", description: "list | call | refresh | status" },
211
- tool: { type: "string", description: "Tool name for action=call" },
212
- params: { type: "object", description: "Tool arguments" }
210
+ action: { type: "string", description: "list | call | batch | refresh | status | intent | schema | promotions" },
211
+ tool: { type: "string", description: "Tool name for action=call/schema" },
212
+ params: { type: "object", description: "Tool arguments" },
213
+ calls: {
214
+ type: "array",
215
+ description: "Batch calls for action=batch",
216
+ items: {
217
+ type: "object",
218
+ properties: {
219
+ server: { type: "string" },
220
+ tool: { type: "string" },
221
+ params: { type: "object" }
222
+ },
223
+ required: ["server", "tool"]
224
+ }
225
+ }
213
226
  },
214
- required: ["server"]
227
+ required: []
215
228
  }
216
229
  }]
217
230
  }
@@ -244,7 +257,10 @@ export class StandaloneServer {
244
257
  error: { code: -32004, message: `Unknown tool: ${toolName}. In router mode, use the 'mcp' tool.` }
245
258
  };
246
259
  }
247
- const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, toolArgs.params);
260
+ const dispatchParams = toolArgs.action === "batch"
261
+ ? { ...(toolArgs.params ?? {}), calls: toolArgs.calls }
262
+ : toolArgs.params;
263
+ const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, dispatchParams);
248
264
  // Check if result is an error
249
265
  if ("error" in result) {
250
266
  return {
@@ -397,7 +413,7 @@ export class StandaloneServer {
397
413
  async shutdown() {
398
414
  this.logger.info("[mcp-bridge] Shutting down...");
399
415
  if (this.router) {
400
- await this.router.disconnectAll();
416
+ await this.router.shutdown(this.config.shutdownTimeoutMs);
401
417
  }
402
418
  for (const [name, conn] of this.directConnections) {
403
419
  try {
@@ -0,0 +1,33 @@
1
+ export interface ToolResolutionCandidate {
2
+ server: string;
3
+ tool: string;
4
+ score: number;
5
+ suggested?: true;
6
+ }
7
+ export type ToolResolutionResult = {
8
+ server: string;
9
+ tool: string;
10
+ } | {
11
+ ambiguous: true;
12
+ message: string;
13
+ candidates: ToolResolutionCandidate[];
14
+ } | null;
15
+ export declare class ToolResolver {
16
+ private readonly basePriority;
17
+ private readonly toolsByName;
18
+ private readonly toolNamesByServer;
19
+ private readonly recentCalls;
20
+ constructor(serverOrder: string[]);
21
+ registerServerTools(server: string, tools: Array<{
22
+ name: string;
23
+ inputSchema: any;
24
+ }>): void;
25
+ removeServer(server: string): void;
26
+ resolve(toolName: string, params?: Record<string, unknown>, serverHint?: string): ToolResolutionResult;
27
+ recordCall(server: string, tool: string): void;
28
+ getKnownToolNames(): string[];
29
+ clear(): void;
30
+ private scoreCandidate;
31
+ private wasUsedRecently;
32
+ private computeParamMatch;
33
+ }
@@ -0,0 +1,135 @@
1
+ const RECENT_CALL_LIMIT = 5;
2
+ const BASE_PRIORITY_STEP = 0.1;
3
+ const BASE_PRIORITY_MIN = 0.1;
4
+ const RECENCY_BOOST = 0.3;
5
+ const PARAM_MATCH_WEIGHT = 0.2;
6
+ const AUTO_RESOLVE_DELTA = 0.15;
7
+ export class ToolResolver {
8
+ basePriority = new Map();
9
+ toolsByName = new Map();
10
+ toolNamesByServer = new Map();
11
+ recentCalls = [];
12
+ constructor(serverOrder) {
13
+ const reversed = [...serverOrder].reverse();
14
+ reversed.forEach((server, index) => {
15
+ const score = Math.max(1.0 - (index * BASE_PRIORITY_STEP), BASE_PRIORITY_MIN);
16
+ this.basePriority.set(server, score);
17
+ });
18
+ }
19
+ registerServerTools(server, tools) {
20
+ this.removeServer(server);
21
+ const names = new Set();
22
+ for (const tool of tools) {
23
+ if (!tool?.name)
24
+ continue;
25
+ const registered = {
26
+ server,
27
+ tool: tool.name,
28
+ inputSchema: tool.inputSchema
29
+ };
30
+ const existing = this.toolsByName.get(tool.name) ?? [];
31
+ existing.push(registered);
32
+ this.toolsByName.set(tool.name, existing);
33
+ names.add(tool.name);
34
+ }
35
+ this.toolNamesByServer.set(server, names);
36
+ }
37
+ removeServer(server) {
38
+ const previousNames = this.toolNamesByServer.get(server);
39
+ if (previousNames) {
40
+ for (const toolName of previousNames) {
41
+ const filtered = (this.toolsByName.get(toolName) ?? []).filter((entry) => entry.server !== server);
42
+ if (filtered.length === 0) {
43
+ this.toolsByName.delete(toolName);
44
+ continue;
45
+ }
46
+ this.toolsByName.set(toolName, filtered);
47
+ }
48
+ }
49
+ this.toolNamesByServer.delete(server);
50
+ }
51
+ resolve(toolName, params, serverHint) {
52
+ const candidates = this.toolsByName.get(toolName) ?? [];
53
+ if (candidates.length === 0) {
54
+ return null;
55
+ }
56
+ if (serverHint) {
57
+ const explicit = candidates.find((candidate) => candidate.server === serverHint);
58
+ if (!explicit) {
59
+ return null;
60
+ }
61
+ return { server: explicit.server, tool: explicit.tool };
62
+ }
63
+ if (candidates.length === 1) {
64
+ return { server: candidates[0].server, tool: candidates[0].tool };
65
+ }
66
+ const scored = candidates
67
+ .map((candidate) => ({
68
+ ...candidate,
69
+ score: this.scoreCandidate(candidate.server, candidate.inputSchema, params)
70
+ }))
71
+ .sort((a, b) => {
72
+ if (b.score !== a.score) {
73
+ return b.score - a.score;
74
+ }
75
+ return (this.basePriority.get(b.server) ?? BASE_PRIORITY_MIN) - (this.basePriority.get(a.server) ?? BASE_PRIORITY_MIN);
76
+ });
77
+ const first = scored[0];
78
+ const second = scored[1];
79
+ if (!second || (first.score - second.score) >= AUTO_RESOLVE_DELTA) {
80
+ return { server: first.server, tool: first.tool };
81
+ }
82
+ return {
83
+ ambiguous: true,
84
+ message: `Multiple servers provide '${toolName}'. Please specify server=`,
85
+ candidates: scored.map((candidate, index) => ({
86
+ server: candidate.server,
87
+ tool: candidate.tool,
88
+ score: Number(candidate.score.toFixed(2)),
89
+ ...(index === 0 ? { suggested: true } : {})
90
+ }))
91
+ };
92
+ }
93
+ recordCall(server, tool) {
94
+ this.recentCalls.push({ server, tool });
95
+ if (this.recentCalls.length > RECENT_CALL_LIMIT) {
96
+ this.recentCalls.shift();
97
+ }
98
+ }
99
+ getKnownToolNames() {
100
+ return [...this.toolsByName.keys()];
101
+ }
102
+ clear() {
103
+ this.toolsByName.clear();
104
+ this.toolNamesByServer.clear();
105
+ this.recentCalls.length = 0;
106
+ }
107
+ scoreCandidate(server, inputSchema, params) {
108
+ const base = this.basePriority.get(server) ?? BASE_PRIORITY_MIN;
109
+ const recency = this.wasUsedRecently(server) ? RECENCY_BOOST : 0;
110
+ const paramMatch = this.computeParamMatch(inputSchema, params) * PARAM_MATCH_WEIGHT;
111
+ return base + recency + paramMatch;
112
+ }
113
+ wasUsedRecently(server) {
114
+ return this.recentCalls.some((call) => call.server === server);
115
+ }
116
+ computeParamMatch(inputSchema, params) {
117
+ if (!params || typeof params !== "object") {
118
+ return 0;
119
+ }
120
+ const paramNames = Object.keys(params);
121
+ if (paramNames.length === 0) {
122
+ return 0;
123
+ }
124
+ const schemaProperties = inputSchema?.properties;
125
+ if (!schemaProperties || typeof schemaProperties !== "object") {
126
+ return 0;
127
+ }
128
+ const propertyNames = new Set(Object.keys(schemaProperties));
129
+ if (propertyNames.size === 0) {
130
+ return 0;
131
+ }
132
+ const matching = paramNames.filter((paramName) => propertyNames.has(paramName)).length;
133
+ return matching / paramNames.length;
134
+ }
135
+ }
@@ -70,6 +70,14 @@ export declare function resolveEnvRecord(record: Record<string, string>, context
70
70
  * @param extraEnv - Additional env vars to check before process.env
71
71
  */
72
72
  export declare function resolveArgs(args: string[], extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): string[];
73
+ /**
74
+ * Resolve auth config into HTTP headers.
75
+ */
76
+ export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
77
+ /**
78
+ * Resolve server headers and merge auth headers (auth takes precedence).
79
+ */
80
+ export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
73
81
  /**
74
82
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
75
83
  */
@@ -159,6 +159,26 @@ export function resolveEnvRecord(record, contextPrefix, extraEnv, envFallback) {
159
159
  export function resolveArgs(args, extraEnv, envFallback) {
160
160
  return args.map(arg => resolveEnvVars(arg, `arg "${arg}"`, extraEnv, envFallback));
161
161
  }
162
+ /**
163
+ * Resolve auth config into HTTP headers.
164
+ */
165
+ export function resolveAuthHeaders(config, extraEnv, envFallback) {
166
+ if (!config.auth)
167
+ return {};
168
+ if (config.auth.type === "bearer") {
169
+ const token = resolveEnvVars(config.auth.token, "auth token", extraEnv, envFallback);
170
+ return { Authorization: `Bearer ${token}` };
171
+ }
172
+ return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
173
+ }
174
+ /**
175
+ * Resolve server headers and merge auth headers (auth takes precedence).
176
+ */
177
+ export function resolveServerHeaders(config, extraEnv, envFallback) {
178
+ const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
179
+ const auth = resolveAuthHeaders(config, extraEnv, envFallback);
180
+ return { ...base, ...auth };
181
+ }
162
182
  /**
163
183
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
164
184
  */
@@ -4,6 +4,7 @@ export declare class SseTransport extends BaseTransport {
4
4
  private endpointUrl;
5
5
  private sseAbortController;
6
6
  private resolvedHeaders;
7
+ private pendingRequestControllers;
7
8
  protected get transportName(): string;
8
9
  connect(): Promise<void>;
9
10
  private _onEndpointReceived;
@@ -13,4 +14,5 @@ export declare class SseTransport extends BaseTransport {
13
14
  sendRequest(request: McpRequest): Promise<McpResponse>;
14
15
  private isSameOrigin;
15
16
  disconnect(): Promise<void>;
17
+ shutdown(): Promise<void>;
16
18
  }
@@ -1,9 +1,10 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
+ import { BaseTransport, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
3
  export class SseTransport extends BaseTransport {
4
4
  endpointUrl = null;
5
5
  sseAbortController = null;
6
6
  resolvedHeaders = null;
7
+ pendingRequestControllers = new Map();
7
8
  get transportName() { return "SSE"; }
8
9
  async connect() {
9
10
  if (!this.config.url) {
@@ -11,7 +12,7 @@ export class SseTransport extends BaseTransport {
11
12
  }
12
13
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
13
14
  // Resolve headers once and cache for all subsequent requests
14
- this.resolvedHeaders = resolveEnvRecord(this.config.headers || {}, "header");
15
+ this.resolvedHeaders = resolveServerHeaders(this.config);
15
16
  if (this.sseAbortController) {
16
17
  this.sseAbortController.abort();
17
18
  }
@@ -36,7 +37,7 @@ export class SseTransport extends BaseTransport {
36
37
  async startEventStream() {
37
38
  if (!this.config.url)
38
39
  return;
39
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
40
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
40
41
  const headers = { ...base, "Accept": "text/event-stream" };
41
42
  try {
42
43
  const response = await fetch(this.config.url, {
@@ -130,7 +131,7 @@ export class SseTransport extends BaseTransport {
130
131
  if (!this.connected || !this.endpointUrl) {
131
132
  throw new Error("SSE transport not connected or no endpoint URL");
132
133
  }
133
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
134
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
134
135
  const headers = { ...base, "Content-Type": "application/json" };
135
136
  const response = await fetch(this.endpointUrl, {
136
137
  method: "POST",
@@ -150,21 +151,27 @@ export class SseTransport extends BaseTransport {
150
151
  return new Promise((resolve, reject) => {
151
152
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
152
153
  const timeout = setTimeout(() => {
154
+ this.pendingRequestControllers.get(id)?.abort();
155
+ this.pendingRequestControllers.delete(id);
153
156
  this.pendingRequests.delete(id);
154
157
  reject(new Error(`Request timeout after ${requestTimeout}ms`));
155
158
  }, requestTimeout);
156
159
  this.pendingRequests.set(id, { resolve, reject, timeout });
157
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
160
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
158
161
  const headers = { ...base, "Content-Type": "application/json" };
162
+ const abortController = new AbortController();
163
+ this.pendingRequestControllers.set(id, abortController);
159
164
  // The response arrives via the SSE stream (handleMessage), not from this fetch.
160
165
  // The fetch only confirms the server accepted the request (HTTP 200).
161
166
  // If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
162
167
  fetch(this.endpointUrl, {
163
168
  method: "POST",
164
169
  headers,
165
- body: JSON.stringify(requestWithId)
170
+ body: JSON.stringify(requestWithId),
171
+ signal: abortController.signal
166
172
  })
167
173
  .then((response) => {
174
+ this.pendingRequestControllers.delete(id);
168
175
  if (!response.ok) {
169
176
  clearTimeout(timeout);
170
177
  this.pendingRequests.delete(id);
@@ -172,6 +179,7 @@ export class SseTransport extends BaseTransport {
172
179
  }
173
180
  })
174
181
  .catch((error) => {
182
+ this.pendingRequestControllers.delete(id);
175
183
  clearTimeout(timeout);
176
184
  this.pendingRequests.delete(id);
177
185
  reject(error);
@@ -197,6 +205,13 @@ export class SseTransport extends BaseTransport {
197
205
  this.sseAbortController.abort();
198
206
  this.sseAbortController = null;
199
207
  }
208
+ for (const [, controller] of this.pendingRequestControllers) {
209
+ controller.abort();
210
+ }
211
+ this.pendingRequestControllers.clear();
200
212
  this.rejectAllPending("Connection closed");
201
213
  }
214
+ async shutdown() {
215
+ await this.disconnect();
216
+ }
202
217
  }
@@ -15,6 +15,7 @@ export declare class StdioTransport extends BaseTransport {
15
15
  private parseNewlineMessageFromBuffer;
16
16
  private parseLspMessageFromBuffer;
17
17
  disconnect(): Promise<void>;
18
+ shutdown(timeoutMs?: number): Promise<void>;
18
19
  isConnected(): boolean;
19
20
  private terminateProcessGracefully;
20
21
  }
@@ -264,7 +264,20 @@ export class StdioTransport extends BaseTransport {
264
264
  this.logger.debug("[mcp-bridge] Failed to send close notification during stdio disconnect");
265
265
  }
266
266
  }
267
- await this.terminateProcessGracefully(activeProcess);
267
+ await this.terminateProcessGracefully(activeProcess, this.clientConfig.shutdownTimeoutMs ?? 5000);
268
+ if (this.process === activeProcess) {
269
+ this.process = null;
270
+ }
271
+ }
272
+ this.rejectAllPending("Connection closed");
273
+ }
274
+ async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? 5000) {
275
+ this.isShuttingDown = true;
276
+ this.connected = false;
277
+ this.cleanupReconnectTimer();
278
+ const activeProcess = this.process;
279
+ if (activeProcess) {
280
+ await this.terminateProcessGracefully(activeProcess, timeoutMs);
268
281
  if (this.process === activeProcess) {
269
282
  this.process = null;
270
283
  }
@@ -274,8 +287,8 @@ export class StdioTransport extends BaseTransport {
274
287
  isConnected() {
275
288
  return this.connected && this.process !== null;
276
289
  }
277
- async terminateProcessGracefully(proc) {
278
- if (proc.exitCode !== null || proc.killed)
290
+ async terminateProcessGracefully(proc, timeoutMs) {
291
+ if (proc.exitCode !== null)
279
292
  return;
280
293
  await new Promise((resolve) => {
281
294
  let done = false;
@@ -292,21 +305,20 @@ export class StdioTransport extends BaseTransport {
292
305
  const onExit = () => finish();
293
306
  proc.once("exit", onExit);
294
307
  try {
295
- proc.kill("SIGINT");
308
+ proc.kill("SIGTERM");
296
309
  }
297
310
  catch {
298
311
  finish();
299
312
  return;
300
313
  }
301
314
  forceKillTimer = setTimeout(() => {
302
- if (proc.exitCode === null && !proc.killed) {
315
+ if (proc.exitCode === null) {
303
316
  try {
304
- proc.kill("SIGTERM");
317
+ proc.kill("SIGKILL");
305
318
  }
306
319
  catch { /* ignore */ }
307
320
  }
308
- setTimeout(finish, 200);
309
- }, 2000);
321
+ }, Math.max(0, timeoutMs));
310
322
  });
311
323
  }
312
324
  }
@@ -2,10 +2,13 @@ import { McpRequest, McpResponse } from "./types.js";
2
2
  import { BaseTransport } from "./transport-base.js";
3
3
  export declare class StreamableHttpTransport extends BaseTransport {
4
4
  private sessionId?;
5
+ private resolvedHeaders;
6
+ private pendingRequestControllers;
5
7
  protected get transportName(): string;
6
8
  connect(): Promise<void>;
7
9
  sendRequest(request: McpRequest): Promise<McpResponse>;
8
10
  sendNotification(notification: any): Promise<void>;
9
11
  private probeServer;
10
12
  disconnect(): Promise<void>;
13
+ shutdown(): Promise<void>;
11
14
  }