@aiwerk/mcp-bridge 1.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/mcp-bridge.js +9 -0
  4. package/bin/mcp-bridge.ts +335 -0
  5. package/package.json +42 -0
  6. package/scripts/install-server.ps1 +300 -0
  7. package/scripts/install-server.sh +357 -0
  8. package/servers/apify/README.md +40 -0
  9. package/servers/apify/config.json +13 -0
  10. package/servers/apify/env_vars +1 -0
  11. package/servers/apify/install.ps1 +3 -0
  12. package/servers/apify/install.sh +4 -0
  13. package/servers/candidates.md +13 -0
  14. package/servers/github/README.md +40 -0
  15. package/servers/github/config.json +21 -0
  16. package/servers/github/env_vars +1 -0
  17. package/servers/github/install.ps1 +3 -0
  18. package/servers/github/install.sh +4 -0
  19. package/servers/google-maps/README.md +40 -0
  20. package/servers/google-maps/config.json +17 -0
  21. package/servers/google-maps/env_vars +1 -0
  22. package/servers/google-maps/install.ps1 +3 -0
  23. package/servers/google-maps/install.sh +4 -0
  24. package/servers/hetzner/README.md +41 -0
  25. package/servers/hetzner/config.json +16 -0
  26. package/servers/hetzner/env_vars +1 -0
  27. package/servers/hetzner/install.ps1 +3 -0
  28. package/servers/hetzner/install.sh +4 -0
  29. package/servers/hostinger/README.md +40 -0
  30. package/servers/hostinger/config.json +17 -0
  31. package/servers/hostinger/env_vars +1 -0
  32. package/servers/hostinger/install.ps1 +3 -0
  33. package/servers/hostinger/install.sh +4 -0
  34. package/servers/index.json +125 -0
  35. package/servers/linear/README.md +40 -0
  36. package/servers/linear/config.json +16 -0
  37. package/servers/linear/env_vars +1 -0
  38. package/servers/linear/install.ps1 +3 -0
  39. package/servers/linear/install.sh +4 -0
  40. package/servers/miro/README.md +40 -0
  41. package/servers/miro/config.json +19 -0
  42. package/servers/miro/env_vars +1 -0
  43. package/servers/miro/install.ps1 +3 -0
  44. package/servers/miro/install.sh +4 -0
  45. package/servers/notion/README.md +42 -0
  46. package/servers/notion/config.json +17 -0
  47. package/servers/notion/env_vars +1 -0
  48. package/servers/notion/install.ps1 +3 -0
  49. package/servers/notion/install.sh +4 -0
  50. package/servers/stripe/README.md +40 -0
  51. package/servers/stripe/config.json +19 -0
  52. package/servers/stripe/env_vars +1 -0
  53. package/servers/stripe/install.ps1 +3 -0
  54. package/servers/stripe/install.sh +4 -0
  55. package/servers/tavily/README.md +40 -0
  56. package/servers/tavily/config.json +17 -0
  57. package/servers/tavily/env_vars +1 -0
  58. package/servers/tavily/install.ps1 +3 -0
  59. package/servers/tavily/install.sh +4 -0
  60. package/servers/todoist/README.md +40 -0
  61. package/servers/todoist/config.json +17 -0
  62. package/servers/todoist/env_vars +1 -0
  63. package/servers/todoist/install.ps1 +3 -0
  64. package/servers/todoist/install.sh +4 -0
  65. package/servers/wise/README.md +41 -0
  66. package/servers/wise/config.json +16 -0
  67. package/servers/wise/env_vars +1 -0
  68. package/servers/wise/install.ps1 +3 -0
  69. package/servers/wise/install.sh +4 -0
  70. package/src/config.ts +168 -0
  71. package/src/index.ts +44 -0
  72. package/src/mcp-router.ts +366 -0
  73. package/src/protocol.ts +69 -0
  74. package/src/schema-convert.ts +178 -0
  75. package/src/standalone-server.ts +385 -0
  76. package/src/tool-naming.ts +51 -0
  77. package/src/transport-base.ts +199 -0
  78. package/src/transport-sse.ts +230 -0
  79. package/src/transport-stdio.ts +312 -0
  80. package/src/transport-streamable-http.ts +188 -0
  81. package/src/types.ts +88 -0
  82. package/src/update-checker.ts +155 -0
  83. package/tests/collision.test.ts +60 -0
  84. package/tests/env-resolve.test.ts +68 -0
  85. package/tests/mcp-router.test.ts +301 -0
  86. package/tests/schema-convert.test.ts +70 -0
  87. package/tests/transport-base.test.ts +214 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,366 @@
1
+ import {
2
+ McpClientConfig,
3
+ McpServerConfig,
4
+ McpTool,
5
+ McpTransport
6
+ } from "./types.js";
7
+ import { SseTransport } from "./transport-sse.js";
8
+ import { StdioTransport } from "./transport-stdio.js";
9
+ import { StreamableHttpTransport } from "./transport-streamable-http.js";
10
+ import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.js";
11
+
12
+ type RouterErrorCode =
13
+ | "unknown_server"
14
+ | "unknown_tool"
15
+ | "connection_failed"
16
+ | "mcp_error"
17
+ | "invalid_params";
18
+
19
+ export interface RouterToolHint {
20
+ name: string;
21
+ description: string;
22
+ requiredParams: string[];
23
+ }
24
+
25
+ export interface RouterServerStatus {
26
+ name: string;
27
+ transport: string;
28
+ status: "connected" | "idle" | "disconnected";
29
+ tools: number;
30
+ lastUsed?: string;
31
+ }
32
+
33
+ export type RouterDispatchResponse =
34
+ | { server: string; action: "list"; tools: RouterToolHint[] }
35
+ | { server: string; action: "refresh"; refreshed: true; tools: RouterToolHint[] }
36
+ | { server: string; action: "call"; tool: string; result: any }
37
+ | { action: "status"; servers: RouterServerStatus[] }
38
+ | {
39
+ error: RouterErrorCode;
40
+ message: string;
41
+ available?: string[];
42
+ code?: number;
43
+ };
44
+
45
+ export interface RouterTransportRefs {
46
+ sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: any, onReconnected?: () => Promise<void>) => McpTransport;
47
+ stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: any, onReconnected?: () => Promise<void>) => McpTransport;
48
+ streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: any, onReconnected?: () => Promise<void>) => McpTransport;
49
+ }
50
+
51
+ interface RouterServerState {
52
+ transport: McpTransport;
53
+ initialized: boolean;
54
+ toolsCache?: RouterToolHint[];
55
+ toolNames: string[];
56
+ lastUsedAt: number;
57
+ idleTimer: NodeJS.Timeout | null;
58
+ initPromise?: Promise<void>;
59
+ }
60
+
61
+ const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
62
+ const DEFAULT_MAX_CONCURRENT = 5;
63
+
64
+ export class McpRouter {
65
+ private readonly servers: Record<string, McpServerConfig>;
66
+ private readonly clientConfig: McpClientConfig;
67
+ private readonly logger: any;
68
+ private readonly transportRefs: RouterTransportRefs;
69
+ private readonly idleTimeoutMs: number;
70
+ private readonly maxConcurrent: number;
71
+ private readonly states = new Map<string, RouterServerState>();
72
+
73
+ constructor(
74
+ servers: Record<string, McpServerConfig>,
75
+ clientConfig: McpClientConfig,
76
+ logger: any,
77
+ transportRefs?: Partial<RouterTransportRefs>
78
+ ) {
79
+ this.servers = servers;
80
+ this.clientConfig = clientConfig;
81
+ this.logger = logger;
82
+ this.transportRefs = {
83
+ sse: transportRefs?.sse ?? SseTransport,
84
+ stdio: transportRefs?.stdio ?? StdioTransport,
85
+ streamableHttp: transportRefs?.streamableHttp ?? StreamableHttpTransport
86
+ };
87
+ this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
88
+ this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
89
+ }
90
+
91
+ static generateDescription(servers: Record<string, McpServerConfig>): string {
92
+ const serverNames = Object.keys(servers);
93
+ if (serverNames.length === 0) {
94
+ return "Call MCP server tools. No servers configured.";
95
+ }
96
+
97
+ const serverList = serverNames
98
+ .map((name) => {
99
+ const desc = servers[name].description;
100
+ return desc ? `${name} (${desc})` : name;
101
+ })
102
+ .join(", ");
103
+
104
+ return `Call any MCP server tool. Servers: ${serverList}. Use action='list' to discover tools and required parameters, action='call' to execute a tool, action='refresh' to clear cache and re-discover tools, and action='status' to check server connection states. If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
105
+ }
106
+
107
+ async dispatch(server?: string, action: string = "call", tool?: string, params?: any): Promise<RouterDispatchResponse> {
108
+ try {
109
+ const normalizedAction = action || "call";
110
+
111
+ // Status action: no server required, shows all server states
112
+ if (normalizedAction === "status") {
113
+ return this.getStatus();
114
+ }
115
+
116
+ if (!server) {
117
+ return this.error("invalid_params", "server is required");
118
+ }
119
+ if (!this.servers[server]) {
120
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
121
+ }
122
+ if (normalizedAction === "list") {
123
+ try {
124
+ const tools = await this.getToolList(server);
125
+ return { server, action: "list", tools };
126
+ } catch (error) {
127
+ return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }
130
+
131
+ if (normalizedAction === "refresh") {
132
+ try {
133
+ const state = await this.ensureConnected(server);
134
+ state.toolsCache = undefined;
135
+ state.toolNames = [];
136
+ const tools = await this.getToolList(server);
137
+ return { server, action: "refresh", refreshed: true, tools };
138
+ } catch (error) {
139
+ return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
140
+ }
141
+ }
142
+
143
+ if (normalizedAction !== "call") {
144
+ return this.error("invalid_params", `action must be one of: list, call, refresh`);
145
+ }
146
+
147
+ if (!tool) {
148
+ return this.error("invalid_params", "tool is required for action=call");
149
+ }
150
+
151
+ try {
152
+ await this.getToolList(server);
153
+ } catch (error) {
154
+ return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ const state = this.states.get(server)!;
157
+
158
+ if (!state.toolNames.includes(tool)) {
159
+ return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
160
+ }
161
+
162
+ this.markUsed(server);
163
+ const response = await state.transport.sendRequest({
164
+ jsonrpc: "2.0",
165
+ method: "tools/call",
166
+ params: {
167
+ name: tool,
168
+ arguments: params ?? {}
169
+ }
170
+ });
171
+
172
+ if (response.error) {
173
+ return this.error("mcp_error", response.error.message, undefined, response.error.code);
174
+ }
175
+
176
+ return { server, action: "call", tool, result: response.result };
177
+ } catch (error) {
178
+ return this.error("mcp_error", error instanceof Error ? error.message : String(error));
179
+ }
180
+ }
181
+
182
+ async getToolList(server: string): Promise<RouterToolHint[]> {
183
+ if (!this.servers[server]) {
184
+ throw new Error(`Server '${server}' not found`);
185
+ }
186
+
187
+ const state = await this.ensureConnected(server);
188
+ if (state.toolsCache) {
189
+ this.markUsed(server);
190
+ return state.toolsCache;
191
+ }
192
+
193
+ const tools = await fetchToolsList(state.transport);
194
+ state.toolNames = tools.map((tool) => tool.name);
195
+ state.toolsCache = tools.map((tool) => ({
196
+ name: tool.name,
197
+ description: tool.description || "",
198
+ requiredParams: this.extractRequiredParams(tool)
199
+ }));
200
+
201
+ this.markUsed(server);
202
+ return state.toolsCache;
203
+ }
204
+
205
+ private getStatus(): RouterDispatchResponse {
206
+ const serverStatuses: RouterServerStatus[] = Object.entries(this.servers).map(([name, config]) => {
207
+ const state = this.states.get(name);
208
+ let status: "connected" | "idle" | "disconnected" = "disconnected";
209
+ if (state?.transport.isConnected()) {
210
+ const idleMs = Date.now() - state.lastUsedAt;
211
+ status = idleMs > 60_000 ? "idle" : "connected";
212
+ }
213
+ return {
214
+ name,
215
+ transport: config.transport,
216
+ status,
217
+ tools: state?.toolNames.length ?? 0,
218
+ ...(state?.lastUsedAt ? { lastUsed: new Date(state.lastUsedAt).toISOString() } : {})
219
+ };
220
+ });
221
+ return { action: "status", servers: serverStatuses };
222
+ }
223
+
224
+ async disconnectAll(): Promise<void> {
225
+ for (const serverName of Object.keys(this.servers)) {
226
+ await this.disconnectServer(serverName);
227
+ }
228
+ }
229
+
230
+ private async ensureConnected(server: string): Promise<RouterServerState> {
231
+ let state = this.states.get(server);
232
+ if (!state) {
233
+ const transport = this.createTransport(server, this.servers[server]);
234
+ state = {
235
+ transport,
236
+ initialized: false,
237
+ toolNames: [],
238
+ lastUsedAt: Date.now(),
239
+ idleTimer: null
240
+ };
241
+ this.states.set(server, state);
242
+ }
243
+
244
+ if (state.initPromise) {
245
+ await state.initPromise;
246
+ return state;
247
+ }
248
+
249
+ state.initPromise = (async () => {
250
+ if (!state!.transport.isConnected()) {
251
+ await state!.transport.connect();
252
+ }
253
+ if (!state!.initialized) {
254
+ await initializeProtocol(state!.transport, PACKAGE_VERSION);
255
+ state!.initialized = true;
256
+ }
257
+ this.markUsed(server);
258
+ await this.enforceMaxConcurrent(server);
259
+ })();
260
+
261
+ try {
262
+ await state.initPromise;
263
+ return state;
264
+ } finally {
265
+ state.initPromise = undefined;
266
+ }
267
+ }
268
+
269
+ private async enforceMaxConcurrent(activeServer: string): Promise<void> {
270
+ const connectedServers = [...this.states.entries()]
271
+ .filter(([_, s]) => s.transport.isConnected())
272
+ .map(([name, s]) => ({ name, lastUsedAt: s.lastUsedAt }));
273
+
274
+ if (connectedServers.length <= this.maxConcurrent) {
275
+ return;
276
+ }
277
+
278
+ connectedServers.sort((a, b) => a.lastUsedAt - b.lastUsedAt);
279
+ for (const candidate of connectedServers) {
280
+ if (candidate.name === activeServer) {
281
+ continue;
282
+ }
283
+ await this.disconnectServer(candidate.name);
284
+ this.logger.info(`[mcp-bridge] Router evicted idle server via LRU: ${candidate.name}`);
285
+ return;
286
+ }
287
+ }
288
+
289
+ private async disconnectServer(server: string): Promise<void> {
290
+ const state = this.states.get(server);
291
+ if (!state) return;
292
+
293
+ if (state.idleTimer) {
294
+ clearTimeout(state.idleTimer);
295
+ state.idleTimer = null;
296
+ }
297
+
298
+ if (state.transport.isConnected()) {
299
+ await state.transport.disconnect();
300
+ }
301
+
302
+ state.initialized = false;
303
+ state.toolsCache = undefined;
304
+ state.toolNames = [];
305
+ }
306
+
307
+ private markUsed(server: string): void {
308
+ const state = this.states.get(server);
309
+ if (!state) return;
310
+
311
+ state.lastUsedAt = Date.now();
312
+
313
+ if (state.idleTimer) {
314
+ clearTimeout(state.idleTimer);
315
+ }
316
+
317
+ state.idleTimer = setTimeout(() => {
318
+ this.disconnectServer(server).catch((error) => {
319
+ this.logger.warn(`[mcp-bridge] Router idle disconnect failed for ${server}:`, error);
320
+ });
321
+ }, this.idleTimeoutMs);
322
+ // Don't keep the process alive just for idle disconnect
323
+ if (state.idleTimer && typeof state.idleTimer.unref === "function") {
324
+ state.idleTimer.unref();
325
+ }
326
+ }
327
+
328
+ private createTransport(serverName: string, serverConfig: McpServerConfig): McpTransport {
329
+ const onReconnected = async () => {
330
+ const state = this.states.get(serverName);
331
+ if (!state) return;
332
+ state.initialized = false;
333
+ state.toolsCache = undefined;
334
+ state.toolNames = [];
335
+ };
336
+
337
+ if (serverConfig.transport === "sse") {
338
+ return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
339
+ }
340
+ if (serverConfig.transport === "stdio") {
341
+ return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
342
+ }
343
+ if (serverConfig.transport === "streamable-http") {
344
+ return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected);
345
+ }
346
+
347
+ throw new Error(`Unsupported transport: ${serverConfig.transport}`);
348
+ }
349
+
350
+ private extractRequiredParams(tool: McpTool): string[] {
351
+ const required = tool.inputSchema?.required;
352
+ if (!Array.isArray(required)) {
353
+ return [];
354
+ }
355
+ return required.filter((name: unknown) => typeof name === "string");
356
+ }
357
+
358
+ private error(error: RouterErrorCode, message: string, available?: string[], code?: number): RouterDispatchResponse {
359
+ return {
360
+ error,
361
+ message,
362
+ ...(available ? { available } : {}),
363
+ ...(typeof code === "number" ? { code } : {})
364
+ };
365
+ }
366
+ }
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { McpRequest, McpResponse, McpTool, McpTransport } from "./types.js";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ export const PACKAGE_VERSION: string = (() => {
10
+ try {
11
+ return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
12
+ } catch {
13
+ return "0.0.0";
14
+ }
15
+ })();
16
+
17
+ export async function initializeProtocol(transport: McpTransport, version: string): Promise<void> {
18
+ const initRequest: McpRequest = {
19
+ jsonrpc: "2.0",
20
+ method: "initialize",
21
+ params: {
22
+ protocolVersion: "2024-11-05",
23
+ capabilities: {},
24
+ clientInfo: {
25
+ name: "mcp-bridge",
26
+ version: version || PACKAGE_VERSION
27
+ }
28
+ }
29
+ };
30
+
31
+ const response = await transport.sendRequest(initRequest);
32
+ if (response.error) {
33
+ throw new Error(`Initialize failed: ${response.error.message}`);
34
+ }
35
+
36
+ await transport.sendNotification({
37
+ jsonrpc: "2.0",
38
+ method: "notifications/initialized"
39
+ });
40
+ }
41
+
42
+ export async function fetchToolsList(transport: McpTransport): Promise<McpTool[]> {
43
+ const allTools: McpTool[] = [];
44
+ let cursor: string | undefined;
45
+
46
+ while (true) {
47
+ const request: McpRequest = {
48
+ jsonrpc: "2.0",
49
+ method: "tools/list",
50
+ ...(cursor ? { params: { cursor } } : {})
51
+ };
52
+
53
+ const response: McpResponse = await transport.sendRequest(request);
54
+ if (response.error) {
55
+ throw new Error(response.error.message);
56
+ }
57
+
58
+ const pageTools = Array.isArray(response.result?.tools) ? response.result.tools : [];
59
+ allTools.push(...pageTools);
60
+
61
+ const nextCursor = response.result?.nextCursor;
62
+ if (!nextCursor) {
63
+ break;
64
+ }
65
+ cursor = nextCursor;
66
+ }
67
+
68
+ return allTools;
69
+ }
@@ -0,0 +1,178 @@
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
+ }