search-mcp-rotator 1.1.0 → 1.1.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/index.js +4 -11
- package/dist/multi-proxy.js +62 -61
- package/dist/proxy.js +11 -41
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -36,21 +36,14 @@ async function main() {
|
|
|
36
36
|
if (args.setup) {
|
|
37
37
|
const { runSetup } = await import('./setup.js');
|
|
38
38
|
await runSetup(args.config);
|
|
39
|
-
// Auto-warmup cache after setup so first MCP start is instant
|
|
40
|
-
try {
|
|
41
|
-
const config = await loadConfig(configPath);
|
|
42
|
-
process.stdout.write('\nWarming tools cache for faster MCP startup...\n');
|
|
43
|
-
const { warmupCache } = await import('./proxy.js');
|
|
44
|
-
await warmupCache(config.providers);
|
|
45
|
-
process.stdout.write('Cache ready.\n');
|
|
46
|
-
}
|
|
47
|
-
catch { }
|
|
48
39
|
return;
|
|
49
40
|
}
|
|
50
|
-
// ── Warmup
|
|
41
|
+
// ── Warmup ─ probe all providers in parallel & report tool counts ──────
|
|
42
|
+
// No disk cache is written; this exists purely to verify connectivity and
|
|
43
|
+
// surface schema info to the user during onboarding/troubleshooting.
|
|
51
44
|
if (args.warmup) {
|
|
52
45
|
const config = await loadConfig(configPath);
|
|
53
|
-
process.stdout.write('
|
|
46
|
+
process.stdout.write('Probing all configured providers...\n');
|
|
54
47
|
const { warmupCache } = await import('./proxy.js');
|
|
55
48
|
await warmupCache(config.providers);
|
|
56
49
|
process.stdout.write('Done.\n');
|
package/dist/multi-proxy.js
CHANGED
|
@@ -3,30 +3,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
4
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
import * as os from 'os';
|
|
9
6
|
import { KeyPool } from './key-pool.js';
|
|
10
7
|
import { ExhaustionDetector, extractCooldown } from './detector.js';
|
|
11
8
|
import { AuthInjector } from './auth-injector.js';
|
|
12
9
|
import { logger } from './logger.js';
|
|
13
|
-
const CACHE_DIR = path.join(os.homedir(), '.config', 'search-mcp-rotator');
|
|
14
|
-
const CACHE_FILE = path.join(CACHE_DIR, 'tools-cache.json');
|
|
15
|
-
function loadToolsCache() {
|
|
16
|
-
try {
|
|
17
|
-
if (fs.existsSync(CACHE_FILE))
|
|
18
|
-
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
|
|
19
|
-
}
|
|
20
|
-
catch { }
|
|
21
|
-
return {};
|
|
22
|
-
}
|
|
23
|
-
function saveToolsCache(cache) {
|
|
24
|
-
try {
|
|
25
|
-
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
26
|
-
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
27
|
-
}
|
|
28
|
-
catch { }
|
|
29
|
-
}
|
|
30
10
|
// ── Tool name helpers ─────────────────────────────────────────────────────────
|
|
31
11
|
// Prefix: "{provider}__{toolname}" — double underscore as separator
|
|
32
12
|
// All provider IDs are single words without underscores, making parsing unambiguous.
|
|
@@ -59,18 +39,12 @@ export class MultiProxy {
|
|
|
59
39
|
currentKey: '',
|
|
60
40
|
tools: [],
|
|
61
41
|
connected: false,
|
|
42
|
+
connectionPromise: null,
|
|
43
|
+
callLock: Promise.resolve(),
|
|
62
44
|
});
|
|
63
45
|
}
|
|
64
46
|
}
|
|
65
47
|
async start() {
|
|
66
|
-
// Load cached tools for all providers — instant, no network
|
|
67
|
-
const cache = loadToolsCache();
|
|
68
|
-
for (const [name, state] of this.providers) {
|
|
69
|
-
if (cache[name]?.length) {
|
|
70
|
-
state.tools = cache[name];
|
|
71
|
-
logger.info(`Loaded ${state.tools.length} cached tools for ${name}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
48
|
this.registerHandlers();
|
|
75
49
|
const transport = new StdioServerTransport();
|
|
76
50
|
await this.server.connect(transport);
|
|
@@ -78,33 +52,45 @@ export class MultiProxy {
|
|
|
78
52
|
providers: [...this.providers.keys()],
|
|
79
53
|
});
|
|
80
54
|
}
|
|
81
|
-
// ── Lazy upstream connection per provider
|
|
55
|
+
// ── Lazy upstream connection per provider (race-safe) ────────────────────
|
|
82
56
|
async ensureConnected(state) {
|
|
83
57
|
if (state.connected)
|
|
84
58
|
return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
59
|
+
// Concurrent callers share the same connection attempt
|
|
60
|
+
if (state.connectionPromise)
|
|
61
|
+
return state.connectionPromise;
|
|
62
|
+
state.connectionPromise = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
state.currentKey = state.keyPool.next();
|
|
65
|
+
const { url, headers } = state.authInjector.inject(state.currentKey, state.config.url);
|
|
66
|
+
state.transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
|
|
67
|
+
await state.client.connect(state.transport);
|
|
68
|
+
state.connected = true;
|
|
69
|
+
// Always fetch fresh tools from upstream — no disk cache. Providers can
|
|
70
|
+
// change/add/remove tools at any time; a stale cache would expose
|
|
71
|
+
// wrong schemas and missing tools to the LLM client.
|
|
72
|
+
const { tools } = await state.client.listTools();
|
|
73
|
+
state.tools = tools;
|
|
74
|
+
logger.info(`Connected to upstream for ${state.name}`, {
|
|
75
|
+
toolCount: tools.length,
|
|
76
|
+
currentKey: state.currentKey.slice(0, 8) + '...',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
state.connectionPromise = null;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return state.connectionPromise;
|
|
100
84
|
}
|
|
101
85
|
// ── Reconnect with a different key ───────────────────────────────────────
|
|
86
|
+
// NOTE: MCP SDK Client cannot be reused after close() — create a fresh instance.
|
|
102
87
|
async reconnect(state, key) {
|
|
103
88
|
try {
|
|
104
89
|
await state.client.close();
|
|
105
90
|
}
|
|
106
91
|
catch { }
|
|
107
92
|
state.connected = false;
|
|
93
|
+
state.client = new Client({ name: `${state.name}-client`, version: '1.0.0' }, { capabilities: {} });
|
|
108
94
|
const { url, headers } = state.authInjector.inject(key, state.config.url);
|
|
109
95
|
state.transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
|
|
110
96
|
await state.client.connect(state.transport);
|
|
@@ -127,19 +113,24 @@ export class MultiProxy {
|
|
|
127
113
|
}
|
|
128
114
|
// ── Register MCP handlers ────────────────────────────────────────────────
|
|
129
115
|
registerHandlers() {
|
|
130
|
-
// tools/list —
|
|
116
|
+
// tools/list — fetch fresh from all providers in parallel.
|
|
117
|
+
// Tools are kept in memory for the lifetime of this process (per-call
|
|
118
|
+
// refresh would be wasteful), but NEVER persisted to disk so we always
|
|
119
|
+
// pick up upstream tool changes on next process start.
|
|
131
120
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
121
|
+
// Connect all providers that haven't been connected yet — in parallel.
|
|
122
|
+
await Promise.allSettled([...this.providers.entries()]
|
|
123
|
+
.filter(([, state]) => state.tools.length === 0)
|
|
124
|
+
.map(async ([name, state]) => {
|
|
125
|
+
try {
|
|
126
|
+
await this.ensureConnected(state);
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
logger.warn(`Could not fetch tools for ${name}: ${e.message}`);
|
|
130
|
+
}
|
|
131
|
+
}));
|
|
132
132
|
const allTools = [];
|
|
133
133
|
for (const [name, state] of this.providers) {
|
|
134
|
-
// If no cache yet, connect upstream now (first-ever run)
|
|
135
|
-
if (state.tools.length === 0) {
|
|
136
|
-
try {
|
|
137
|
-
await this.ensureConnected(state);
|
|
138
|
-
}
|
|
139
|
-
catch (e) {
|
|
140
|
-
logger.warn(`Could not fetch tools for ${name}: ${e.message}`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
134
|
for (const tool of state.tools) {
|
|
144
135
|
allTools.push({
|
|
145
136
|
...tool,
|
|
@@ -159,7 +150,17 @@ export class MultiProxy {
|
|
|
159
150
|
const state = this.providers.get(parsed.provider);
|
|
160
151
|
if (!state)
|
|
161
152
|
throw new Error(`Unknown provider: ${parsed.provider}`);
|
|
162
|
-
|
|
153
|
+
// Serialize calls to the same provider (Client is not concurrent-safe)
|
|
154
|
+
const previousLock = state.callLock;
|
|
155
|
+
let releaseLock;
|
|
156
|
+
state.callLock = new Promise(r => { releaseLock = r; });
|
|
157
|
+
await previousLock;
|
|
158
|
+
try {
|
|
159
|
+
return await this.handleToolCall(state, parsed.toolName, request);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
releaseLock();
|
|
163
|
+
}
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
// ── Tool call with key rotation ──────────────────────────────────────────
|
|
@@ -180,18 +181,18 @@ export class MultiProxy {
|
|
|
180
181
|
let lastError = null;
|
|
181
182
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
182
183
|
try {
|
|
184
|
+
// First attempt: use existing connection.
|
|
185
|
+
// Retry: rotate to next healthy key.
|
|
183
186
|
if (attempt > 0) {
|
|
184
187
|
const nextKey = state.keyPool.next(strategyOverride);
|
|
185
188
|
if (!nextKey || nextKey === state.currentKey)
|
|
186
189
|
break;
|
|
187
190
|
await this.reconnect(state, nextKey);
|
|
188
191
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
const result = await state.client.callTool(cleanRequest.params);
|
|
192
|
+
// Long timeout (5 min) — heavy ops like research, page fetch, agent jobs
|
|
193
|
+
// need more than the SDK's 60s default. The MCP client wrapping us can
|
|
194
|
+
// still apply its own shorter timeout if desired.
|
|
195
|
+
const result = await state.client.callTool(cleanRequest.params, undefined, { timeout: 300000 });
|
|
195
196
|
const mcpError = this.extractMcpError(state.name, result);
|
|
196
197
|
if (mcpError && state.detector.isExhausted(mcpError)) {
|
|
197
198
|
const cooldown = extractCooldown(mcpError, state.name, state.config.cooldownMs);
|
package/dist/proxy.js
CHANGED
|
@@ -3,33 +3,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
4
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
import * as os from 'os';
|
|
9
6
|
import { KeyPool } from './key-pool.js';
|
|
10
7
|
import { extractCooldown } from './detector.js';
|
|
11
8
|
import { AuthInjector } from './auth-injector.js';
|
|
12
9
|
import { logger } from './logger.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (fs.existsSync(CACHE_FILE)) {
|
|
18
|
-
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch { }
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
function saveToolsCache(cache) {
|
|
25
|
-
try {
|
|
26
|
-
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
27
|
-
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
28
|
-
}
|
|
29
|
-
catch { }
|
|
30
|
-
}
|
|
10
|
+
// Probe upstream providers in parallel and report tool counts.
|
|
11
|
+
// No disk caching — providers may change tools at any time, so we always
|
|
12
|
+
// fetch fresh on each process start. This is a no-op for runtime behavior,
|
|
13
|
+
// only used by the `--warmup` CLI flag for diagnostic reporting.
|
|
31
14
|
export async function warmupCache(providers) {
|
|
32
|
-
const cache = loadToolsCache();
|
|
33
15
|
const results = await Promise.allSettled(Object.entries(providers)
|
|
34
16
|
.filter(([, cfg]) => cfg.enabled)
|
|
35
17
|
.map(async ([name, cfg]) => {
|
|
@@ -41,14 +23,12 @@ export async function warmupCache(providers) {
|
|
|
41
23
|
const client = new Client({ name: `${name}-warmup`, version: '1.0.0' }, { capabilities: {} });
|
|
42
24
|
await client.connect(transport);
|
|
43
25
|
const { tools } = await client.listTools();
|
|
44
|
-
cache[name] = tools;
|
|
45
26
|
await client.close();
|
|
46
27
|
process.stdout.write(` ✓ ${name} — ${tools.length} tools\n`);
|
|
47
28
|
}));
|
|
48
|
-
saveToolsCache(cache);
|
|
49
29
|
const failed = results.filter(r => r.status === 'rejected');
|
|
50
30
|
if (failed.length) {
|
|
51
|
-
failed.forEach(
|
|
31
|
+
failed.forEach(r => process.stdout.write(` ✗ failed: ${r.reason}\n`));
|
|
52
32
|
}
|
|
53
33
|
}
|
|
54
34
|
export class MCPProxy {
|
|
@@ -72,13 +52,7 @@ export class MCPProxy {
|
|
|
72
52
|
this.upstreamClient = new Client({ name: `${providerName}-rotator-client`, version: '1.0.0' }, { capabilities: {} });
|
|
73
53
|
}
|
|
74
54
|
async start() {
|
|
75
|
-
//
|
|
76
|
-
const cache = loadToolsCache();
|
|
77
|
-
if (cache[this.providerName]?.length) {
|
|
78
|
-
this.tools = cache[this.providerName];
|
|
79
|
-
logger.info(`Loaded ${this.tools.length} cached tools for ${this.providerName}`);
|
|
80
|
-
}
|
|
81
|
-
// Register handlers — upstream connects lazily on first actual tool call
|
|
55
|
+
// Register handlers — upstream connects lazily on first tools/list or tools/call
|
|
82
56
|
this.registerHandlers();
|
|
83
57
|
const transport = new StdioServerTransport();
|
|
84
58
|
await this.server.connect(transport);
|
|
@@ -107,21 +81,17 @@ export class MCPProxy {
|
|
|
107
81
|
return;
|
|
108
82
|
this.currentKey = this.keyPool.next();
|
|
109
83
|
await this.connectUpstream(this.currentKey);
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
this.tools =
|
|
113
|
-
const cache = loadToolsCache();
|
|
114
|
-
cache[this.providerName] = fresh;
|
|
115
|
-
saveToolsCache(cache);
|
|
84
|
+
// Always fetch fresh tools from upstream — no disk cache. Providers can
|
|
85
|
+
// change/add/remove tools at any time.
|
|
86
|
+
this.tools = await this.discoverTools();
|
|
116
87
|
logger.info(`Connected to upstream for ${this.providerName}`, {
|
|
117
88
|
toolCount: this.tools.length,
|
|
118
89
|
currentKey: this.maskKey(this.currentKey)
|
|
119
90
|
});
|
|
120
91
|
}
|
|
121
92
|
registerHandlers() {
|
|
122
|
-
// tools/list —
|
|
93
|
+
// tools/list — fetch fresh from upstream. In-memory only, not persisted.
|
|
123
94
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
124
|
-
// If no tools yet (first ever run), do a full upstream connect now
|
|
125
95
|
if (this.tools.length === 0) {
|
|
126
96
|
await this.ensureConnected();
|
|
127
97
|
}
|
|
@@ -182,7 +152,7 @@ export class MCPProxy {
|
|
|
182
152
|
await this.connectUpstream(key);
|
|
183
153
|
}
|
|
184
154
|
}
|
|
185
|
-
const result = await this.upstreamClient.callTool(cleanRequest.params);
|
|
155
|
+
const result = await this.upstreamClient.callTool(cleanRequest.params, undefined, { timeout: 300000 });
|
|
186
156
|
// CHECK: Does the result look like an exhaustion error hidden inside HTTP 200?
|
|
187
157
|
const mcpError = this.extractMcpLevelError(result);
|
|
188
158
|
if (mcpError && this.detector.isExhausted(mcpError)) {
|
package/package.json
CHANGED