@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,178 +0,0 @@
1
- // TSchema is intentionally loose — TypeBox returns dynamic types
2
- // that don't have a single static interface
3
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
- type TSchema = any;
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- type TypeBoxMod = { Type: any } | null;
7
-
8
- let cachedTypeBoxPromise: Promise<TypeBoxMod> | null = null;
9
-
10
- // Overridable loader for dependency injection (used by tests)
11
- let typeBoxLoader: (() => Promise<TypeBoxMod>) | null = null;
12
-
13
- export function setTypeBoxLoader(loader: (() => Promise<TypeBoxMod>) | null): void {
14
- typeBoxLoader = loader;
15
- cachedTypeBoxPromise = null; // auto-reset cache
16
- }
17
-
18
- async function getTypeBox(): Promise<TypeBoxMod> {
19
- // If a test loader is set, always use it (bypass cache)
20
- if (typeBoxLoader) {
21
- return typeBoxLoader();
22
- }
23
-
24
- if (cachedTypeBoxPromise) {
25
- return cachedTypeBoxPromise;
26
- }
27
-
28
- cachedTypeBoxPromise = (async () => {
29
- try {
30
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
- const mod: any = await import("@sinclair/typebox");
32
- const Type = mod?.Type ?? mod?.default?.Type;
33
- if (!Type) {
34
- throw new Error("TypeBox module missing Type export");
35
- }
36
- return { Type };
37
- } catch (error) {
38
- return null;
39
- }
40
- })();
41
-
42
- return cachedTypeBoxPromise;
43
- }
44
-
45
-
46
-
47
- async function anyFallback(): Promise<TSchema> {
48
- const typeBox = await getTypeBox();
49
- if (typeBox?.Type) {
50
- return typeBox.Type.Any();
51
- }
52
- // Empty schema {} is valid JSON Schema (matches anything), unlike { type: "any" } which is invalid.
53
- // This only triggers if @sinclair/typebox is missing — run `npm install` in the plugin directory.
54
- schemaLogger.warn("[mcp-bridge] TypeBox unavailable — using permissive empty schema fallback. Run `npm install` to fix.");
55
- return {};
56
- }
57
-
58
- // Logger can be injected via setSchemaLogger(); defaults to console
59
- let schemaLogger: { warn: (...args: unknown[]) => void } = console;
60
- export function setSchemaLogger(logger: { warn: (...args: unknown[]) => void }): void {
61
- schemaLogger = logger;
62
- }
63
-
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
- export async function convertJsonSchemaToTypeBox(schema: any, depth = 0): Promise<TSchema> {
66
- const typeBox = await getTypeBox();
67
- const Type = typeBox?.Type;
68
- if (!Type) {
69
- return anyFallback();
70
- }
71
-
72
- try {
73
- if (depth > 10) {
74
- schemaLogger.warn("[mcp-bridge] JSON schema depth limit exceeded (>10), falling back to Type.Any()");
75
- return Type.Any();
76
- }
77
-
78
- if (!schema || typeof schema !== "object") {
79
- return Type.Any();
80
- }
81
-
82
- // anyOf and oneOf both map to Type.Union (TypeBox doesn't distinguish)
83
- const unionSource = schema.anyOf || schema.oneOf;
84
- if (Array.isArray(unionSource) && unionSource.length > 0) {
85
- const variants = await Promise.all(
86
- unionSource.map((item: Record<string, unknown>) => convertJsonSchemaToTypeBox(item, depth + 1))
87
- );
88
- return Type.Union(variants);
89
- }
90
-
91
- // allOf maps to Type.Intersect
92
- if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
93
- const parts = await Promise.all(
94
- schema.allOf.map((item: Record<string, unknown>) => convertJsonSchemaToTypeBox(item, depth + 1))
95
- );
96
- return Type.Intersect(parts);
97
- }
98
-
99
- switch (schema.type) {
100
- case "string": {
101
- if (schema.enum) {
102
- return Type.Union(schema.enum.map((value: string) => Type.Literal(value)));
103
- }
104
- const stringOptions: Record<string, unknown> = {};
105
- if (schema.minLength !== undefined) stringOptions.minLength = schema.minLength;
106
- if (schema.maxLength !== undefined) stringOptions.maxLength = schema.maxLength;
107
- if (schema.pattern !== undefined) stringOptions.pattern = schema.pattern;
108
- return Type.String(stringOptions);
109
- }
110
- case "number":
111
- case "integer": {
112
- const numberOptions: Record<string, unknown> = {};
113
- if (schema.minimum !== undefined) numberOptions.minimum = schema.minimum;
114
- if (schema.maximum !== undefined) numberOptions.maximum = schema.maximum;
115
- return Type.Number(numberOptions);
116
- }
117
- case "boolean":
118
- return Type.Boolean();
119
- case "array":
120
- if (schema.items) {
121
- return Type.Array(await convertJsonSchemaToTypeBox(schema.items, depth + 1));
122
- }
123
- return Type.Array(Type.Any());
124
- case "object":
125
- if (schema.properties) {
126
- const propertyEntries = Object.entries(schema.properties);
127
- if (propertyEntries.length > 100) {
128
- schemaLogger.warn("[mcp-bridge] JSON schema object has too many properties (>100), falling back to Type.Any()");
129
- return Type.Any();
130
- }
131
-
132
- const properties: Record<string, TSchema> = {};
133
- const requiredSet = new Set<string>(
134
- Array.isArray(schema.required) ? schema.required : []
135
- );
136
-
137
- for (const [key, value] of propertyEntries) {
138
- const converted = await convertJsonSchemaToTypeBox(value as Record<string, unknown>, depth + 1);
139
- properties[key] = requiredSet.has(key) ? converted : Type.Optional(converted);
140
- }
141
- return Type.Object(properties);
142
- }
143
- return Type.Object({});
144
- case "null":
145
- return Type.Null();
146
- default:
147
- return Type.Any();
148
- }
149
- } catch (error) {
150
- schemaLogger.warn("[mcp-bridge] Failed to convert JSON schema, falling back to Type.Any()");
151
- return Type.Any();
152
- }
153
- }
154
-
155
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
- export async function createToolParameters(inputSchema: any): Promise<TSchema> {
157
- const typeBox = await getTypeBox();
158
- const Type = typeBox?.Type;
159
- if (!Type) {
160
- // TypeBox missing — return the raw JSON Schema as-is
161
- schemaLogger.warn("[mcp-bridge] TypeBox unavailable — passing raw JSON Schema. Run `npm install` to fix.");
162
- return inputSchema ?? {};
163
- }
164
-
165
- if (!inputSchema) {
166
- return Type.Object({});
167
- }
168
-
169
- // If the inputSchema is already a proper object schema, convert it
170
- if (inputSchema.type === "object") {
171
- return convertJsonSchemaToTypeBox(inputSchema, 0);
172
- }
173
-
174
- // If it's not an object, wrap it in an object
175
- return Type.Object({
176
- input: await convertJsonSchemaToTypeBox(inputSchema, 0)
177
- });
178
- }
@@ -1,385 +0,0 @@
1
- import { McpRouter } from "./mcp-router.js";
2
- import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.js";
3
- import { pickRegisteredToolName } from "./tool-naming.js";
4
- import {
5
- BridgeConfig,
6
- Logger,
7
- McpRequest,
8
- McpResponse,
9
- McpServerConfig,
10
- McpTool,
11
- McpTransport,
12
- } from "./types.js";
13
- import { SseTransport } from "./transport-sse.js";
14
- import { StdioTransport } from "./transport-stdio.js";
15
- import { StreamableHttpTransport } from "./transport-streamable-http.js";
16
-
17
- interface DirectToolEntry {
18
- serverName: string;
19
- originalName: string;
20
- registeredName: string;
21
- description: string;
22
- inputSchema: any;
23
- }
24
-
25
- /**
26
- * Standalone MCP server that wraps the router.
27
- * Implements the MCP protocol (initialize, tools/list, tools/call)
28
- * and forwards tool calls to backend MCP servers.
29
- */
30
- export class StandaloneServer {
31
- private config: BridgeConfig;
32
- private logger: Logger;
33
- private router: McpRouter | null = null;
34
- private initialized = false;
35
-
36
- // Direct mode state
37
- private directTools: DirectToolEntry[] = [];
38
- private directConnections = new Map<string, { transport: McpTransport; initialized: boolean }>();
39
-
40
- constructor(config: BridgeConfig, logger: Logger) {
41
- this.config = config;
42
- this.logger = logger;
43
-
44
- if (this.isRouterMode()) {
45
- this.router = new McpRouter(config.servers || {}, config, logger);
46
- }
47
- }
48
-
49
- private isRouterMode(): boolean {
50
- return (this.config.mode ?? "router") === "router";
51
- }
52
-
53
- /** Start stdio mode: read JSON-RPC from stdin, write responses to stdout. */
54
- async startStdio(): Promise<void> {
55
- const stdin = process.stdin;
56
- const stdout = process.stdout;
57
-
58
- stdin.setEncoding("utf8");
59
- let buffer = "";
60
-
61
- stdin.on("data", (chunk: string) => {
62
- buffer += chunk;
63
- const lines = buffer.split("\n");
64
- buffer = lines.pop() || "";
65
-
66
- for (const line of lines) {
67
- const trimmed = line.trim();
68
- if (!trimmed) continue;
69
- this.processLine(trimmed, stdout);
70
- }
71
- });
72
-
73
- stdin.on("end", () => {
74
- this.logger.info("[mcp-bridge] stdin closed, shutting down");
75
- this.shutdown().catch(err => {
76
- this.logger.error("[mcp-bridge] Shutdown error:", err);
77
- });
78
- });
79
-
80
- this.logger.info("[mcp-bridge] Stdio server ready");
81
- }
82
-
83
- private processLine(line: string, stdout: NodeJS.WriteStream): void {
84
- let request: any;
85
- try {
86
- request = JSON.parse(line);
87
- } catch {
88
- this.writeResponse(stdout, {
89
- jsonrpc: "2.0",
90
- id: 0,
91
- error: { code: -32700, message: "Parse error" }
92
- });
93
- return;
94
- }
95
-
96
- // Notifications (no id) — just acknowledge
97
- if (request.id === undefined || request.id === null) {
98
- // notifications/initialized, etc. — no response needed
99
- return;
100
- }
101
-
102
- this.handleRequest(request).then(response => {
103
- this.writeResponse(stdout, response);
104
- }).catch(err => {
105
- this.writeResponse(stdout, {
106
- jsonrpc: "2.0",
107
- id: request.id,
108
- error: { code: -32603, message: err instanceof Error ? err.message : String(err) }
109
- });
110
- });
111
- }
112
-
113
- private writeResponse(stdout: NodeJS.WriteStream, response: any): void {
114
- stdout.write(JSON.stringify(response) + "\n");
115
- }
116
-
117
- /** Handle a single MCP JSON-RPC request. */
118
- async handleRequest(request: McpRequest): Promise<McpResponse> {
119
- const id = request.id ?? 0;
120
-
121
- switch (request.method) {
122
- case "initialize":
123
- return this.handleInitialize(id);
124
-
125
- case "tools/list":
126
- return this.handleToolsList(id);
127
-
128
- case "tools/call":
129
- return this.handleToolsCall(id, request.params);
130
-
131
- case "ping":
132
- return { jsonrpc: "2.0", id, result: {} };
133
-
134
- default:
135
- return {
136
- jsonrpc: "2.0",
137
- id,
138
- error: { code: -32601, message: `Method not found: ${request.method}` }
139
- };
140
- }
141
- }
142
-
143
- private handleInitialize(id: number): McpResponse {
144
- this.initialized = true;
145
- return {
146
- jsonrpc: "2.0",
147
- id,
148
- result: {
149
- protocolVersion: "2024-11-05",
150
- capabilities: {
151
- tools: {}
152
- },
153
- serverInfo: {
154
- name: "mcp-bridge",
155
- version: PACKAGE_VERSION
156
- }
157
- }
158
- };
159
- }
160
-
161
- private async handleToolsList(id: number): Promise<McpResponse> {
162
- if (this.isRouterMode()) {
163
- return {
164
- jsonrpc: "2.0",
165
- id,
166
- result: {
167
- tools: [{
168
- name: "mcp",
169
- description: McpRouter.generateDescription(this.config.servers),
170
- inputSchema: {
171
- type: "object",
172
- properties: {
173
- server: { type: "string", description: "Server name" },
174
- action: { type: "string", description: "list | call | refresh | status" },
175
- tool: { type: "string", description: "Tool name for action=call" },
176
- params: { type: "object", description: "Tool arguments" }
177
- },
178
- required: ["server"]
179
- }
180
- }]
181
- }
182
- };
183
- }
184
-
185
- // Direct mode: discover all tools from all servers
186
- await this.discoverDirectTools();
187
- const tools = this.directTools.map(t => ({
188
- name: t.registeredName,
189
- description: t.description,
190
- inputSchema: t.inputSchema
191
- }));
192
-
193
- return { jsonrpc: "2.0", id, result: { tools } };
194
- }
195
-
196
- private async handleToolsCall(id: number, params: any): Promise<McpResponse> {
197
- const toolName = params?.name;
198
- const toolArgs = params?.arguments ?? {};
199
-
200
- if (!toolName) {
201
- return {
202
- jsonrpc: "2.0",
203
- id,
204
- error: { code: -32602, message: "Missing tool name" }
205
- };
206
- }
207
-
208
- if (this.isRouterMode()) {
209
- if (toolName !== "mcp") {
210
- return {
211
- jsonrpc: "2.0",
212
- id,
213
- error: { code: -32004, message: `Unknown tool: ${toolName}. In router mode, use the 'mcp' tool.` }
214
- };
215
- }
216
-
217
- const result = await this.router!.dispatch(
218
- toolArgs.server,
219
- toolArgs.action,
220
- toolArgs.tool,
221
- toolArgs.params
222
- );
223
-
224
- // Check if result is an error
225
- if ("error" in result) {
226
- return {
227
- jsonrpc: "2.0",
228
- id,
229
- result: {
230
- content: [{ type: "text", text: JSON.stringify(result) }]
231
- }
232
- };
233
- }
234
-
235
- return {
236
- jsonrpc: "2.0",
237
- id,
238
- result: {
239
- content: [{ type: "text", text: JSON.stringify(result) }]
240
- }
241
- };
242
- }
243
-
244
- // Direct mode: find and call the tool
245
- const entry = this.directTools.find(t => t.registeredName === toolName);
246
- if (!entry) {
247
- return {
248
- jsonrpc: "2.0",
249
- id,
250
- error: {
251
- code: -32004,
252
- message: `Unknown tool: ${toolName}`,
253
- data: { errorType: "unknown_tool" }
254
- }
255
- };
256
- }
257
-
258
- try {
259
- const conn = this.directConnections.get(entry.serverName);
260
- if (!conn || !conn.transport.isConnected()) {
261
- return {
262
- jsonrpc: "2.0",
263
- id,
264
- error: {
265
- code: -32001,
266
- message: `Server '${entry.serverName}' not connected`,
267
- data: { errorType: "connection_failed", server: entry.serverName, retriable: true }
268
- }
269
- };
270
- }
271
-
272
- const response = await conn.transport.sendRequest({
273
- jsonrpc: "2.0",
274
- method: "tools/call",
275
- params: { name: entry.originalName, arguments: toolArgs }
276
- });
277
-
278
- if (response.error) {
279
- return {
280
- jsonrpc: "2.0",
281
- id,
282
- error: {
283
- code: -32005,
284
- message: response.error.message,
285
- data: { errorType: "mcp_error", server: entry.serverName }
286
- }
287
- };
288
- }
289
-
290
- return { jsonrpc: "2.0", id, result: response.result };
291
- } catch (err) {
292
- return {
293
- jsonrpc: "2.0",
294
- id,
295
- error: {
296
- code: -32001,
297
- message: err instanceof Error ? err.message : String(err),
298
- data: { errorType: "connection_failed", server: entry.serverName, retriable: true }
299
- }
300
- };
301
- }
302
- }
303
-
304
- /** Connect to all backend servers and discover their tools (direct mode). */
305
- private async discoverDirectTools(): Promise<void> {
306
- if (this.directTools.length > 0) return; // Already discovered
307
-
308
- const globalNames = new Set<string>();
309
-
310
- for (const [serverName, serverConfig] of Object.entries(this.config.servers)) {
311
- try {
312
- const transport = this.createTransport(serverName, serverConfig);
313
- await transport.connect();
314
- await initializeProtocol(transport, PACKAGE_VERSION);
315
-
316
- this.directConnections.set(serverName, { transport, initialized: true });
317
-
318
- const tools = await fetchToolsList(transport);
319
- const localNames = new Set<string>();
320
-
321
- for (const tool of tools) {
322
- const registeredName = pickRegisteredToolName(
323
- serverName,
324
- tool.name,
325
- this.config.toolPrefix,
326
- localNames,
327
- globalNames,
328
- this.logger
329
- );
330
- localNames.add(registeredName);
331
- globalNames.add(registeredName);
332
-
333
- this.directTools.push({
334
- serverName,
335
- originalName: tool.name,
336
- registeredName,
337
- description: tool.description,
338
- inputSchema: tool.inputSchema
339
- });
340
- }
341
-
342
- this.logger.info(`[mcp-bridge] Discovered ${tools.length} tools from ${serverName}`);
343
- } catch (err) {
344
- this.logger.error(`[mcp-bridge] Failed to connect to ${serverName}:`, err);
345
- }
346
- }
347
- }
348
-
349
- private createTransport(serverName: string, serverConfig: McpServerConfig): McpTransport {
350
- const onReconnected = async () => {
351
- this.logger.info(`[mcp-bridge] ${serverName} reconnected`);
352
- };
353
-
354
- switch (serverConfig.transport) {
355
- case "sse":
356
- return new SseTransport(serverConfig, this.config, this.logger, onReconnected);
357
- case "stdio":
358
- return new StdioTransport(serverConfig, this.config, this.logger, onReconnected);
359
- case "streamable-http":
360
- return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected);
361
- default:
362
- throw new Error(`Unsupported transport: ${serverConfig.transport}`);
363
- }
364
- }
365
-
366
- /** Graceful shutdown: disconnect all backend servers. */
367
- async shutdown(): Promise<void> {
368
- this.logger.info("[mcp-bridge] Shutting down...");
369
-
370
- if (this.router) {
371
- await this.router.disconnectAll();
372
- }
373
-
374
- for (const [name, conn] of this.directConnections) {
375
- try {
376
- await conn.transport.disconnect();
377
- } catch (err) {
378
- this.logger.error(`[mcp-bridge] Error disconnecting ${name}:`, err);
379
- }
380
- }
381
- this.directConnections.clear();
382
-
383
- this.logger.info("[mcp-bridge] Shutdown complete");
384
- }
385
- }
@@ -1,51 +0,0 @@
1
- function isNameTaken(name: string, localNames: Set<string>, globalNames: Set<string>): boolean {
2
- return localNames.has(name) || globalNames.has(name);
3
- }
4
-
5
- export function pickRegisteredToolName(
6
- serverName: string,
7
- toolName: string,
8
- toolPrefix: boolean | "auto" | undefined,
9
- localNames: Set<string>,
10
- globalNames: Set<string>,
11
- logger?: { warn: (...args: unknown[]) => void }
12
- ): string {
13
- // toolPrefix: true = always prefix, false = never prefix, "auto" = prefix only on collision (default)
14
- const effectivePrefix = toolPrefix === undefined ? "auto" : toolPrefix;
15
-
16
- let candidate: string;
17
- if (effectivePrefix === true) {
18
- // Always prefix with server name
19
- candidate = `${serverName}_${toolName}`.replace(/[^a-zA-Z0-9_]/g, "_");
20
- } else if (effectivePrefix === false) {
21
- // Never prefix — use raw tool name, no collision fallback
22
- candidate = toolName.replace(/[^a-zA-Z0-9_]/g, "_");
23
- } else {
24
- // "auto" — try without prefix, auto-prefix on collision
25
- const unprefixed = toolName.replace(/[^a-zA-Z0-9_]/g, "_");
26
- if (isNameTaken(unprefixed, localNames, globalNames)) {
27
- const prefixedName = `${serverName}_${toolName}`.replace(/[^a-zA-Z0-9_]/g, "_");
28
- logger?.warn(
29
- `[mcp-bridge] Global tool name collision detected for "${unprefixed}". Auto-prefixing with server name: "${prefixedName}"`
30
- );
31
- candidate = prefixedName;
32
- } else {
33
- candidate = unprefixed;
34
- }
35
- }
36
-
37
- const uniqueBase = candidate;
38
- let suffix = 2;
39
- while (isNameTaken(candidate, localNames, globalNames)) {
40
- candidate = `${uniqueBase}_${suffix}`;
41
- suffix += 1;
42
- }
43
-
44
- if (candidate !== uniqueBase) {
45
- logger?.warn(
46
- `[mcp-bridge] Tool name collision after sanitization on server ${serverName}: "${uniqueBase}" -> "${candidate}"`
47
- );
48
- }
49
-
50
- return candidate;
51
- }