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 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('Warming tools cache...\n');
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');
@@ -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
- state.currentKey = state.keyPool.next();
86
- const { url, headers } = state.authInjector.inject(state.currentKey, state.config.url);
87
- state.transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
88
- await state.client.connect(state.transport);
89
- state.connected = true;
90
- // Refresh tools from upstream and update cache
91
- const { tools } = await state.client.listTools();
92
- state.tools = tools;
93
- const cache = loadToolsCache();
94
- cache[state.name] = tools;
95
- saveToolsCache(cache);
96
- logger.info(`Connected to upstream for ${state.name}`, {
97
- toolCount: tools.length,
98
- currentKey: state.currentKey.slice(0, 8) + '...',
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 — serve all providers' tools from cache instantly
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
- return this.handleToolCall(state, parsed.toolName, request);
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
- else {
190
- const key = state.keyPool.next(strategyOverride);
191
- if (key !== state.currentKey)
192
- await this.reconnect(state, key);
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
- 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
- }
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((r, i) => process.stdout.write(` ✗ failed: ${r.reason}\n`));
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
- // Load cached tools so tools/list responds instantly without upstream call
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
- // Refresh tools from upstream and update cache
111
- const fresh = await this.discoverTools();
112
- this.tools = fresh;
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 — serve from cache instantly, connect upstream lazily on tool call
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "search-mcp-rotator",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Transparent API key rotation proxy for MCP search providers — Exa, Firecrawl, Tavily, Linkup, Bright Data, Olostep, Dappier, Parallel",
5
5
  "type": "module",
6
6
  "bin": {