search-mcp-rotator 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.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # Search MCP Rotator
2
+
3
+ ![Search MCP Rotator](./poster.png)
4
+
5
+ A local MCP (Model Context Protocol) proxy server that provides transparent API key rotation for search providers. When one key is exhausted, rate-limited, or returns any server-side error, the proxy automatically rotates to the next available key and retries the request — transparently, without the MCP client knowing a rotation occurred.
6
+
7
+ ## Features
8
+
9
+ - **Transparent Key Rotation**: Automatic failover between API keys
10
+ - **Multi-Provider Support**: Supports 9 search providers with different auth patterns
11
+ - **Flexible Rotation Strategies**: Round-robin, priority, and random selection
12
+ - **Circuit Breaker Pattern**: Prevents cascading failures
13
+ - **Configurable Cooldowns**: Smart recovery timing per provider
14
+ - **Strategy-as-Tool-Argument**: LLMs can choose rotation strategy per request
15
+ - **Comprehensive Error Detection**: Provider-specific exhaustion patterns
16
+
17
+ ## Supported Providers
18
+
19
+ | Provider | Auth Pattern | Specialty |
20
+ |----------|--------------|-----------|
21
+ | **Exa** | Bearer header | Neural/semantic web search, code search |
22
+ | **Firecrawl** | Bearer header | Web scraping, deep crawl, structured extraction |
23
+ | **Linkup** | Bearer header | Real-time web search, source-cited answers |
24
+ | **Bright Data** | Bearer header | 40+ scraping tools, Google SERP, Amazon |
25
+ | **Olostep** | Bearer header | Search + extract + AI answers with citations |
26
+ | **Tavily** | Query param | Real-time web search, extract, map, crawl |
27
+ | **Dappier** | Query param | Real-time news, finance, sports, weather |
28
+ | **Parallel** | Custom header | Highest-accuracy general web search |
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install
34
+ npm run build
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ Copy `config.example.json` to `config.json` and add your API keys:
40
+
41
+ ```json
42
+ {
43
+ "logLevel": "info",
44
+ "providers": {
45
+ "exa": {
46
+ "enabled": true,
47
+ "url": "https://mcp.exa.ai/mcp",
48
+ "authPattern": "bearer",
49
+ "keys": ["your_exa_key_1", "your_exa_key_2"],
50
+ "strategy": "round-robin",
51
+ "cooldownMs": 60000
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ### Environment Variable Overrides
58
+
59
+ Keys can be overridden via environment variables:
60
+
61
+ ```bash
62
+ export EXA_KEYS="key1,key2,key3"
63
+ export FIRECRAWL_KEYS="key1,key2"
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ Start a rotator for a specific provider:
69
+
70
+ ```bash
71
+ # Using built binary
72
+ node dist/index.js --provider=exa --config=config.json
73
+
74
+ # Or using npm script
75
+ npm run dev -- --provider=exa --config=config.json
76
+ ```
77
+
78
+ ## OpenCode Integration
79
+
80
+ Register each rotator as a local MCP server in `~/.config/opencode/opencode.json`:
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "exa-rotator": {
86
+ "type": "local",
87
+ "command": "node",
88
+ "args": [
89
+ "/path/to/search-mcp-rotator/dist/index.js",
90
+ "--provider=exa"
91
+ ],
92
+ "env": {
93
+ "MCP_ROTATOR_CONFIG": "/path/to/config.json"
94
+ },
95
+ "enabled": true
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ ## Rotation Strategies
102
+
103
+ Every tool call accepts an optional `strategy` parameter:
104
+
105
+ - **`round-robin`** (default): Distribute requests evenly across keys
106
+ - **`priority`**: Always use first healthy key, fallback to others
107
+ - **`random`**: Random key selection to avoid patterns
108
+
109
+ ```typescript
110
+ // Example tool call with strategy override
111
+ {
112
+ "query": "AI news",
113
+ "strategy": "priority" // Optional: overrides provider default
114
+ }
115
+ ```
116
+
117
+ ## Architecture
118
+
119
+ ```
120
+ OpenCode (MCP client)
121
+ │ stdio transport
122
+
123
+ ┌─────────────────────────┐
124
+ │ Search MCP Rotator Proxy │
125
+ │ (local stdio server) │
126
+ │ │
127
+ │ ┌─────────────────┐ │
128
+ │ │ KeyPool │ │
129
+ │ │ [key1, key2, │ │
130
+ │ │ key3, key4] │ │
131
+ │ │ activeIdx = 0 │ │
132
+ │ │ degraded: Map │ │
133
+ │ └─────────────────┘ │
134
+ │ │
135
+ │ On tool call: │
136
+ │ 1. pick active key │
137
+ │ 2. inject into request │
138
+ │ 3. forward upstream │
139
+ │ 4. on rate-limit: │
140
+ │ mark degraded │
141
+ │ rotate key │
142
+ │ retry same call │
143
+ └─────────────────────────┘
144
+ │ HTTP (Streamable HTTP transport)
145
+
146
+ Remote MCP Provider
147
+ (Exa / Firecrawl / etc.)
148
+ ```
149
+
150
+ ## Error Detection
151
+
152
+ The rotator detects exhaustion signals specific to each provider:
153
+
154
+ - **HTTP Status Codes**: 401, 402, 429, etc.
155
+ - **JSON-RPC Error Codes**: Provider-specific codes
156
+ - **Message Patterns**: "rate limit", "quota", "credits exhausted"
157
+ - **Special Cases**: Provider-specific error formats
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ # Install dependencies
163
+ npm install
164
+
165
+ # Development with auto-reload
166
+ npm run dev -- --provider=exa
167
+
168
+ # Type checking
169
+ npm run typecheck
170
+
171
+ # Build for production
172
+ npm run build
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,97 @@
1
+ {
2
+ "logLevel": "info",
3
+ "providers": {
4
+ "exa": {
5
+ "enabled": true,
6
+ "url": "https://mcp.exa.ai/mcp",
7
+ "authPattern": "bearer",
8
+ "keys": [
9
+ "exa_key_1",
10
+ "exa_key_2"
11
+ ],
12
+ "strategy": "round-robin",
13
+ "cooldownMs": 60000
14
+ },
15
+ "firecrawl": {
16
+ "enabled": true,
17
+ "url": "https://mcp.firecrawl.dev/mcp",
18
+ "authPattern": "bearer",
19
+ "keys": [
20
+ "fc-key_1",
21
+ "fc-key_2"
22
+ ],
23
+ "strategy": "round-robin",
24
+ "cooldownMs": 60000
25
+ },
26
+ "tavily": {
27
+ "enabled": true,
28
+ "url": "https://mcp.tavily.com/mcp/",
29
+ "authPattern": "queryparam",
30
+ "queryParamName": "tavilyApiKey",
31
+ "keys": [
32
+ "tvly-key_1",
33
+ "tvly-key_2"
34
+ ],
35
+ "strategy": "round-robin",
36
+ "cooldownMs": 60000
37
+ },
38
+ "linkup": {
39
+ "enabled": true,
40
+ "url": "https://mcp.linkup.so/mcp",
41
+ "authPattern": "bearer",
42
+ "keys": [
43
+ "linkup_key_1",
44
+ "linkup_key_2"
45
+ ],
46
+ "strategy": "round-robin",
47
+ "cooldownMs": 60000
48
+ },
49
+ "brightdata": {
50
+ "enabled": true,
51
+ "url": "https://mcp.brightdata.com/mcp",
52
+ "authPattern": "bearer",
53
+ "keys": [
54
+ "bd_key_1",
55
+ "bd_key_2"
56
+ ],
57
+ "strategy": "round-robin",
58
+ "cooldownMs": 60000
59
+ },
60
+ "olostep": {
61
+ "enabled": true,
62
+ "url": "https://mcp.olostep.com/mcp",
63
+ "authPattern": "bearer",
64
+ "keys": [
65
+ "olostep_key_1",
66
+ "olostep_key_2"
67
+ ],
68
+ "strategy": "round-robin",
69
+ "cooldownMs": 300000
70
+ },
71
+
72
+ "dappier": {
73
+ "enabled": true,
74
+ "url": "https://mcp.dappier.com/mcp",
75
+ "authPattern": "queryparam",
76
+ "queryParamName": "apiKey",
77
+ "keys": [
78
+ "dappier_key_1",
79
+ "dappier_key_2"
80
+ ],
81
+ "strategy": "round-robin",
82
+ "cooldownMs": 300000
83
+ },
84
+ "parallel": {
85
+ "enabled": true,
86
+ "url": "https://search.parallel.ai/mcp",
87
+ "authPattern": "customheader",
88
+ "headerName": "x-api-key",
89
+ "keys": [
90
+ "parallel_key_1",
91
+ "parallel_key_2"
92
+ ],
93
+ "strategy": "round-robin",
94
+ "cooldownMs": 60000
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,15 @@
1
+ import type { ProviderConfig, AuthInjectionResult } from './types.js';
2
+ export declare class AuthInjector {
3
+ private authPattern;
4
+ private headerName?;
5
+ private queryParamName?;
6
+ constructor(config: Pick<ProviderConfig, 'authPattern' | 'headerName' | 'queryParamName'>);
7
+ /**
8
+ * Inject key into HTTP headers or URL
9
+ */
10
+ inject(key: string, baseUrl: string): AuthInjectionResult;
11
+ /**
12
+ * For query-param providers: rebuild URL with new key (for rotation)
13
+ */
14
+ updateKey(currentUrl: string, newKey: string): string;
15
+ }
@@ -0,0 +1,47 @@
1
+ export class AuthInjector {
2
+ authPattern;
3
+ headerName;
4
+ queryParamName;
5
+ constructor(config) {
6
+ this.authPattern = config.authPattern;
7
+ this.headerName = config.headerName;
8
+ this.queryParamName = config.queryParamName;
9
+ }
10
+ /**
11
+ * Inject key into HTTP headers or URL
12
+ */
13
+ inject(key, baseUrl) {
14
+ const headers = {};
15
+ let url = baseUrl;
16
+ switch (this.authPattern) {
17
+ case 'bearer':
18
+ headers['Authorization'] = `Bearer ${key}`;
19
+ break;
20
+ case 'queryparam':
21
+ if (!this.queryParamName) {
22
+ throw new Error('queryParamName required for queryparam auth pattern');
23
+ }
24
+ const separator = url.includes('?') ? '&' : '?';
25
+ url = `${url}${separator}${this.queryParamName}=${key}`;
26
+ break;
27
+ case 'customheader':
28
+ if (!this.headerName) {
29
+ throw new Error('headerName required for customheader auth pattern');
30
+ }
31
+ headers[this.headerName] = key;
32
+ break;
33
+ }
34
+ return { url, headers };
35
+ }
36
+ /**
37
+ * For query-param providers: rebuild URL with new key (for rotation)
38
+ */
39
+ updateKey(currentUrl, newKey) {
40
+ if (this.authPattern !== 'queryparam' || !this.queryParamName) {
41
+ return currentUrl;
42
+ }
43
+ const url = new URL(currentUrl);
44
+ url.searchParams.set(this.queryParamName, newKey);
45
+ return url.toString();
46
+ }
47
+ }
@@ -0,0 +1,3 @@
1
+ import type { Config, ProviderConfig, AuthPattern, RotationStrategy, LogLevel } from './types.js';
2
+ export declare function loadConfig(filePath: string): Promise<Config>;
3
+ export type { Config, ProviderConfig, AuthPattern, RotationStrategy, LogLevel };
package/dist/config.js ADDED
@@ -0,0 +1,53 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { z } from 'zod';
3
+ const AuthPatternSchema = z.enum(['bearer', 'queryparam', 'customheader']);
4
+ const RotationStrategySchema = z.enum(['round-robin', 'priority', 'random']);
5
+ const LogLevelSchema = z.enum(['debug', 'info', 'warn', 'error']);
6
+ // JSON object keys are always strings — coerce them to numbers at runtime
7
+ const CooldownOverridesSchema = z.record(z.string(), z.number()).transform((obj) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [Number(k), v])));
8
+ const ExhaustionPatternSchema = z.object({
9
+ httpStatusCodes: z.array(z.number()).default([]),
10
+ jsonRpcErrorCodes: z.array(z.number()).default([]),
11
+ messagePatterns: z.array(z.string()).default([]),
12
+ cooldownOverrides: CooldownOverridesSchema.default({}),
13
+ hasRetryAfterHeader: z.boolean().default(false)
14
+ });
15
+ const ProviderConfigSchema = z.object({
16
+ enabled: z.boolean().default(true),
17
+ url: z.string().url(),
18
+ authPattern: AuthPatternSchema,
19
+ // Auth pattern specific fields
20
+ headerName: z.string().optional(), // For 'customheader'
21
+ queryParamName: z.string().optional(), // For 'queryparam'
22
+ keys: z.array(z.string()).min(1),
23
+ strategy: RotationStrategySchema.default('round-robin'),
24
+ cooldownMs: z.number().default(60000),
25
+ exhaustionPatterns: ExhaustionPatternSchema.optional()
26
+ });
27
+ const ConfigSchema = z.object({
28
+ logLevel: LogLevelSchema.default('info'),
29
+ providers: z.record(ProviderConfigSchema)
30
+ });
31
+ export async function loadConfig(filePath) {
32
+ // 1. Read config file
33
+ const fileContent = await readFile(filePath, 'utf-8');
34
+ const rawConfig = JSON.parse(fileContent);
35
+ // 2. Merge with env vars (keys can be overridden via env)
36
+ const mergedConfig = mergeWithEnv(rawConfig);
37
+ // 3. Validate with Zod
38
+ const config = ConfigSchema.parse(mergedConfig);
39
+ return config;
40
+ }
41
+ function mergeWithEnv(config) {
42
+ // Allow keys to be overridden via env vars like:
43
+ // EXA_KEYS="key1,key2,key3"
44
+ // FIRECRAWL_KEYS="key1,key2"
45
+ for (const providerName in config.providers) {
46
+ const envVarName = `${providerName.toUpperCase()}_KEYS`;
47
+ const envValue = process.env[envVarName];
48
+ if (envValue) {
49
+ config.providers[providerName].keys = envValue.split(',').map((k) => k.trim());
50
+ }
51
+ }
52
+ return config;
53
+ }
@@ -0,0 +1,91 @@
1
+ import type { ExhaustionError, ExhaustionPatternConfig } from './types.js';
2
+ export declare const PROVIDER_EXHAUSTION_CONFIG: {
3
+ readonly exa: {
4
+ readonly httpStatusCodes: readonly [401, 402, 429];
5
+ readonly jsonRpcErrorCodes: readonly [-32000];
6
+ readonly messagePatterns: readonly ["invalid api key", "no_more_credits", "api_key_budget_exceeded", "team_budget_exceeded", "credits exhausted", "payment required", "account credits", "spending budget", "rate limit", "too many requests", "you've exceeded your exa rate limit", "you've hit exa's free mcp rate limit", "quota"];
7
+ readonly cooldownOverrides: {
8
+ readonly 402: 3600000;
9
+ readonly 401: 31536000000;
10
+ };
11
+ readonly hasRetryAfterHeader: true;
12
+ };
13
+ readonly firecrawl: {
14
+ readonly httpStatusCodes: readonly [401, 402, 429, 500, 502, 503, 504];
15
+ readonly jsonRpcErrorCodes: readonly [];
16
+ readonly messagePatterns: readonly ["rate limit exceeded", "request rate limit exceeded", "concurrency limit reached", "payment required", "insufficient credits", "payment required to access", "unauthorized: invalid token", "unauthorized: token missing", "unauthorized", "retry after", "error creating server"];
17
+ readonly cooldownOverrides: {
18
+ readonly 402: 86400000;
19
+ readonly 401: 604800000;
20
+ };
21
+ readonly hasRetryAfterHeader: true;
22
+ };
23
+ readonly tavily: {
24
+ readonly httpStatusCodes: readonly [401, 429, 432, 433];
25
+ readonly jsonRpcErrorCodes: readonly [];
26
+ readonly messagePatterns: readonly ["authentication required", "unauthorized", "invalid api key", "missing or invalid", "excessive requests", "rate of requests", "usage limit", "pay-as-you-go", "paygo limit", "search failed"];
27
+ readonly cooldownOverrides: {
28
+ readonly 432: 86400000;
29
+ readonly 433: 3600000;
30
+ readonly 401: 31536000000;
31
+ };
32
+ readonly hasRetryAfterHeader: true;
33
+ };
34
+ readonly linkup: {
35
+ readonly httpStatusCodes: readonly [401, 403, 429];
36
+ readonly jsonRpcErrorCodes: readonly [-32000];
37
+ readonly messagePatterns: readonly ["unauthorized action", "api key is required", "insufficient", "too many requests", "out of credit", "credit", "rate limit"];
38
+ readonly cooldownOverrides: {};
39
+ readonly hasRetryAfterHeader: false;
40
+ };
41
+ readonly brightdata: {
42
+ readonly httpStatusCodes: readonly [401, 402, 407, 429];
43
+ readonly jsonRpcErrorCodes: readonly [];
44
+ readonly messagePatterns: readonly ["http 401: auth method is not supported", "http 401: token expired", "http 401", "5,000 request monthly limit", "monthly limit for bright data mcp", "usage limit", "zone has reached usage limit", "http 502", "http 429", "http 407", "account is suspended", "kyc required", "http 402"];
45
+ readonly cooldownOverrides: {};
46
+ readonly hasRetryAfterHeader: false;
47
+ };
48
+ readonly olostep: {
49
+ readonly httpStatusCodes: readonly [401, 402, 403];
50
+ readonly jsonRpcErrorCodes: readonly [];
51
+ readonly messagePatterns: readonly ["invalid_api_key", "invalid api key", "your api key is invalid", "credits exhausted", "payment required", "olostep api error: 401", "olostep api error: 402", "olostep api error: 403"];
52
+ readonly cooldownOverrides: {};
53
+ readonly hasRetryAfterHeader: false;
54
+ };
55
+ readonly dappier: {
56
+ readonly httpStatusCodes: readonly [401, 402];
57
+ readonly jsonRpcErrorCodes: readonly [];
58
+ readonly messagePatterns: readonly ["error: failed to retrieve real-time information", "error: failed to retrieve", "error: unable to retrieve", "error: authentication", "error: unauthorized", "missing authentication"];
59
+ readonly cooldownOverrides: {};
60
+ readonly hasRetryAfterHeader: false;
61
+ };
62
+ readonly parallel: {
63
+ readonly httpStatusCodes: readonly [401, 402, 429];
64
+ readonly jsonRpcErrorCodes: readonly [];
65
+ readonly messagePatterns: readonly ["oauth.v2.invalidapikey", "steps.oauth.v2.failedtoresolveapikey", "oauth.v2.apikeyexpired", "oauth.v2.apikeynotapproved", "invalid apikey", "failedtoresolveapikey", "invalid api key", "rate limit", "too many requests", "insufficient credit", "quota exceeded", "code\":16"];
66
+ readonly cooldownOverrides: {};
67
+ readonly hasRetryAfterHeader: false;
68
+ };
69
+ };
70
+ export declare class ExhaustionDetector {
71
+ private patterns;
72
+ private providerName;
73
+ constructor(providerName: string, customPatterns?: Partial<ExhaustionPatternConfig>);
74
+ /**
75
+ * Determine if an error indicates key exhaustion
76
+ */
77
+ isExhausted(error: ExhaustionError): boolean;
78
+ private isExaExhausted;
79
+ private isFirecrawlExhausted;
80
+ private isTavilyExhausted;
81
+ private isLinkupExhausted;
82
+ private isBrightDataExhausted;
83
+ private isOlostepExhausted;
84
+ private isDappierExhausted;
85
+ private isParallelExhausted;
86
+ private isGenericExhausted;
87
+ }
88
+ /**
89
+ * Extract cooldown duration from error (if provider specifies it)
90
+ */
91
+ export declare function extractCooldown(error: ExhaustionError, providerName: string, defaultCooldownMs: number): number;