@dyingc/brave-search-mcp-server 2.0.69 → 2.1.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 CHANGED
@@ -129,6 +129,48 @@ The server supports the following environment variables:
129
129
  - `BRAVE_MCP_RETRY_BASE_DELAY`: Base delay for retry in milliseconds (default: 1000, range: 100-60000)
130
130
  - `BRAVE_MCP_RETRY_MAX_DELAY`: Maximum delay for retry in milliseconds (default: 30000, range: 1000-300000)
131
131
 
132
+ ### Multiple API Keys
133
+
134
+ When using multiple API keys, the server automatically load balances requests across keys based on remaining monthly quota. Keys with more available quota are prioritized.
135
+
136
+ **Environment Variables for Multiple Keys:**
137
+
138
+ 1. `BRAVE_API_KEYS`: Comma-separated list of API keys
139
+ ```bash
140
+ export BRAVE_API_KEYS="key1,key2,key3"
141
+ ```
142
+
143
+ 2. Numbered variables (legacy):
144
+ ```bash
145
+ export BRAVE_API_KEY_1="key1"
146
+ export BRAVE_API_KEY_2="key2"
147
+ export BRAVE_API_KEY_3="key3"
148
+ ```
149
+
150
+ **State Persistence:**
151
+
152
+ The server persists API key quota state to `~/.brave-mcp/state.json` to maintain consistent load balancing across server restarts:
153
+
154
+ - State is saved after each API request
155
+ - On startup, state is loaded if the timestamp is within the current month
156
+ - State automatically resets at the beginning of each month (matches API quota cycle)
157
+ - If the state file is missing or corrupted, the server starts with default quota (2000 requests per key)
158
+
159
+ **State File Format:**
160
+ ```json
161
+ {
162
+ "version": 1,
163
+ "keys": {
164
+ "BSKa1234": {
165
+ "remaining": 1500,
166
+ "timestamp": "2025-02-03T10:00:00Z"
167
+ }
168
+ }
169
+ }
170
+ ```
171
+
172
+ **Note:** For security, only the first 8 characters of each API key are stored in the state file.
173
+
132
174
  ### Command Line Options
133
175
 
134
176
  ```bash
package/dist/config.js CHANGED
@@ -37,6 +37,18 @@ export const configSchema = z.object({
37
37
  .default(false)
38
38
  .describe('Whether the server should be stateless')
39
39
  .optional(),
40
+ apiKeySelectionStrategy: z
41
+ .enum(['greedy-max', 'greedy-min', 'soft-max'])
42
+ .default('soft-max')
43
+ .describe('API key selection strategy')
44
+ .optional(),
45
+ apiKeyTemperature: z
46
+ .number()
47
+ .min(0)
48
+ .max(5.0)
49
+ .default(1.0)
50
+ .describe('Temperature for soft-max strategy (0-5.0, 0 = hard limit)')
51
+ .optional(),
40
52
  });
41
53
  const state = {
42
54
  transport: 'stdio',
@@ -51,6 +63,8 @@ const state = {
51
63
  retryMaxAttempts: 5,
52
64
  retryBaseDelay: 1000,
53
65
  retryMaxDelay: 30000,
66
+ apiKeySelectionStrategy: 'soft-max',
67
+ apiKeyTemperature: 1.0,
54
68
  };
55
69
  export function isToolPermittedByUser(toolName) {
56
70
  return state.enabledTools.length > 0
@@ -101,6 +115,8 @@ export function getOptions() {
101
115
  .option('--retry-max-attempts <number>', 'Maximum retry attempts for rate-limited requests', process.env.BRAVE_MCP_MAX_RETRIES ?? '5')
102
116
  .option('--retry-base-delay <number>', 'Base delay for retry in milliseconds', process.env.BRAVE_MCP_RETRY_BASE_DELAY ?? '1000')
103
117
  .option('--retry-max-delay <number>', 'Maximum delay for retry in milliseconds', process.env.BRAVE_MCP_RETRY_MAX_DELAY ?? '30000')
118
+ .option('--api-key-selection-strategy <strategy>', 'API key selection strategy (greedy-max, greedy-min, soft-max)', process.env.BRAVE_API_KEY_SELECTION_STRATEGY ?? 'soft-max')
119
+ .option('--api-key-temperature <number>', 'Temperature for soft-max strategy (0-5.0, default: 1.0, 0 = hard limit)', process.env.BRAVE_API_KEY_TEMPERATURE ?? '1.0')
104
120
  .allowUnknownOption()
105
121
  .parse(process.argv);
106
122
  const options = program.opts();
@@ -150,6 +166,20 @@ export function getOptions() {
150
166
  return false;
151
167
  }
152
168
  options.retryMaxDelay = retryMaxDelay;
169
+ // Validate API key selection strategy
170
+ const validStrategies = ['greedy-max', 'greedy-min', 'soft-max'];
171
+ if (!validStrategies.includes(options.apiKeySelectionStrategy)) {
172
+ console.error(`Invalid --api-key-selection-strategy value: '${options.apiKeySelectionStrategy}'. Must be one of: ${validStrategies.join(', ')}`);
173
+ return false;
174
+ }
175
+ // Validate temperature (range 0-5.0)
176
+ const temperature = parseFloat(options.apiKeyTemperature);
177
+ if (isNaN(temperature) || temperature < 0 || temperature > 5.0) {
178
+ console.error(`Invalid --api-key-temperature value: '${options.apiKeyTemperature}'. Must be a number between 0 and 5.0`);
179
+ return false;
180
+ }
181
+ options.apiKeyTemperature = temperature;
182
+ options.apiKeySelectionStrategy = options.apiKeySelectionStrategy;
153
183
  if (options.transport === 'http') {
154
184
  if (options.port < 1 || options.port > 65535) {
155
185
  console.error(`Invalid --port value: '${options.port}'. Must be a valid port number between 1 and 65535.`);
@@ -174,6 +204,8 @@ export function getOptions() {
174
204
  state.retryMaxAttempts = options.retryMaxAttempts;
175
205
  state.retryBaseDelay = options.retryBaseDelay;
176
206
  state.retryMaxDelay = options.retryMaxDelay;
207
+ state.apiKeySelectionStrategy = options.apiKeySelectionStrategy;
208
+ state.apiKeyTemperature = options.apiKeyTemperature;
177
209
  state.ready = true;
178
210
  return options;
179
211
  }
package/dist/index.js CHANGED
@@ -8,8 +8,11 @@ async function main() {
8
8
  console.error('Invalid configuration');
9
9
  process.exit(1);
10
10
  }
11
- // Initialize API Key Manager with parsed keys
12
- initApiKeyManager(options.braveApiKeys);
11
+ // Initialize API Key Manager with parsed keys and selection strategy
12
+ initApiKeyManager(options.braveApiKeys, {
13
+ selectionStrategy: options.apiKeySelectionStrategy,
14
+ temperature: options.apiKeyTemperature,
15
+ });
13
16
  // default to stdio server unless http is explicitly requested
14
17
  if (options.transport === 'http') {
15
18
  httpServer.start();
@@ -2,17 +2,30 @@
2
2
  * API Key Manager for multi-key support with quota tracking
3
3
  * Designed for free tier keys (2000 requests/month per key)
4
4
  */
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import * as os from 'node:os';
5
8
  // Free tier constants
6
9
  const FREE_TIER_MONTHLY_QUOTA = 2000;
7
10
  const DEFAULT_PER_SECOND_LIMIT = 1;
11
+ // State file path
12
+ const STATE_FILE = path.join(os.homedir(), '.brave-mcp', 'state.json');
8
13
  class ApiKeyManager {
9
14
  keys;
10
- constructor(apiKeys, initialQuota = FREE_TIER_MONTHLY_QUOTA) {
15
+ selectionStrategy;
16
+ temperature;
17
+ constructor(apiKeys, options = {}) {
11
18
  this.keys = new Map();
19
+ const savedStates = this.loadState();
20
+ const { initialQuota = FREE_TIER_MONTHLY_QUOTA, selectionStrategy = 'soft-max', temperature = 1.0, } = options;
21
+ this.selectionStrategy = selectionStrategy;
22
+ this.temperature = temperature;
12
23
  for (const key of apiKeys) {
24
+ const keyHash = key.slice(0, 8);
25
+ const savedRemaining = savedStates.get(keyHash);
13
26
  this.keys.set(key, {
14
27
  key,
15
- remainingQuota: initialQuota,
28
+ remainingQuota: savedRemaining ?? initialQuota,
16
29
  monthLimit: FREE_TIER_MONTHLY_QUOTA,
17
30
  remainingSecond: DEFAULT_PER_SECOND_LIMIT,
18
31
  secondLimit: DEFAULT_PER_SECOND_LIMIT,
@@ -21,6 +34,59 @@ class ApiKeyManager {
21
34
  });
22
35
  }
23
36
  }
37
+ /**
38
+ * Load saved state from file, filtering for current month only
39
+ * Returns Map of keyHash -> remaining quota
40
+ */
41
+ loadState() {
42
+ try {
43
+ if (!fs.existsSync(STATE_FILE)) {
44
+ return new Map();
45
+ }
46
+ const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
47
+ const now = new Date();
48
+ const result = new Map();
49
+ for (const [keyHash, info] of Object.entries(data.keys)) {
50
+ const timestamp = new Date(info.timestamp);
51
+ // Only use state from current month
52
+ if (timestamp.getMonth() === now.getMonth() &&
53
+ timestamp.getFullYear() === now.getFullYear()) {
54
+ result.set(keyHash, info.remaining);
55
+ }
56
+ }
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ console.warn(`[ApiKeyManager] Failed to load state: ${err.message}`);
61
+ return new Map();
62
+ }
63
+ }
64
+ /**
65
+ * Save current state to file
66
+ */
67
+ saveState() {
68
+ try {
69
+ const dir = path.dirname(STATE_FILE);
70
+ if (!fs.existsSync(dir)) {
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ }
73
+ const data = {
74
+ version: 1,
75
+ keys: {},
76
+ };
77
+ for (const [key, state] of this.keys.entries()) {
78
+ const keyHash = key.slice(0, 8);
79
+ data.keys[keyHash] = {
80
+ remaining: state.remainingQuota,
81
+ timestamp: new Date().toISOString(),
82
+ };
83
+ }
84
+ fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2));
85
+ }
86
+ catch (err) {
87
+ console.warn(`[ApiKeyManager] Failed to save state: ${err.message}`);
88
+ }
89
+ }
24
90
  /**
25
91
  * Select the best available key based on remaining quota
26
92
  * Returns null if no valid keys available
@@ -28,20 +94,104 @@ class ApiKeyManager {
28
94
  selectBestKey() {
29
95
  // Filter valid keys with remaining quota and not in use
30
96
  const availableKeys = Array.from(this.keys.values()).filter((k) => k.isValid && k.remainingQuota > 0 && !k.inUse);
97
+ // No available keys
31
98
  if (availableKeys.length === 0) {
32
99
  return null;
33
100
  }
34
- // Sort by remaining quota (descending)
35
- availableKeys.sort((a, b) => b.remainingQuota - a.remainingQuota);
36
- // Get keys with max quota
37
- const maxQuota = availableKeys[0].remainingQuota;
38
- const topKeys = availableKeys.filter((k) => k.remainingQuota === maxQuota);
39
- // Random selection if tied
40
- const selected = topKeys.length > 1 ? topKeys[Math.floor(Math.random() * topKeys.length)] : topKeys[0];
41
- // Mark as in-use
101
+ // Single available key - return directly
102
+ if (availableKeys.length === 1) {
103
+ availableKeys[0].inUse = true;
104
+ return availableKeys[0].key;
105
+ }
106
+ let selected;
107
+ switch (this.selectionStrategy) {
108
+ case 'greedy-min':
109
+ selected = this.selectGreedyMin(availableKeys);
110
+ break;
111
+ case 'soft-max':
112
+ selected = this.selectKeyWithSoftMax(availableKeys, this.temperature);
113
+ break;
114
+ case 'greedy-max':
115
+ default:
116
+ selected = this.selectGreedyMax(availableKeys);
117
+ break;
118
+ }
119
+ // selected should never be null here since we checked availableKeys.length >= 2
120
+ if (!selected) {
121
+ return null;
122
+ }
42
123
  selected.inUse = true;
43
124
  return selected.key;
44
125
  }
126
+ /**
127
+ * Select key with maximum remaining quota (greedy-max)
128
+ */
129
+ selectGreedyMax(keys) {
130
+ keys.sort((a, b) => b.remainingQuota - a.remainingQuota);
131
+ const maxQuota = keys[0].remainingQuota;
132
+ const topKeys = keys.filter((k) => k.remainingQuota === maxQuota);
133
+ return topKeys.length > 1 ? topKeys[Math.floor(Math.random() * topKeys.length)] : topKeys[0];
134
+ }
135
+ /**
136
+ * Select key with minimum remaining quota (greedy-min)
137
+ */
138
+ selectGreedyMin(keys) {
139
+ keys.sort((a, b) => a.remainingQuota - b.remainingQuota);
140
+ const minQuota = keys[0].remainingQuota;
141
+ const bottomKeys = keys.filter((k) => k.remainingQuota === minQuota);
142
+ return bottomKeys.length > 1
143
+ ? bottomKeys[Math.floor(Math.random() * bottomKeys.length)]
144
+ : bottomKeys[0];
145
+ }
146
+ /**
147
+ * Calculate soft max probabilities for key selection
148
+ * Uses total normalization + standard soft max formula
149
+ */
150
+ calculateSoftMaxProbabilities(keys, temperature) {
151
+ // Calculate total quota for normalization
152
+ const totalQuota = keys.reduce((sum, k) => sum + k.remainingQuota, 0);
153
+ // Normalize by total (sum of normalized values = 1), then invert (lower quota = higher score)
154
+ // normalized_quota = quota / total_quota ∈ [0, 1]
155
+ // score = -normalized_quota ∈ [-1, 0], lower quota gives score closer to 0
156
+ const scores = keys.map((k) => -k.remainingQuota / totalQuota);
157
+ // Standard soft max: scale by temperature
158
+ const scaledScores = scores.map((s) => s / temperature);
159
+ // Numerical stability: subtract max value
160
+ const maxScore = Math.max(...scaledScores);
161
+ const expScores = scaledScores.map((s) => Math.exp(s - maxScore));
162
+ const sumExp = expScores.reduce((sum, exp) => sum + exp, 0);
163
+ const probabilities = expScores.map((exp) => exp / sumExp);
164
+ return keys.map((key, index) => ({
165
+ key,
166
+ probability: probabilities[index],
167
+ }));
168
+ }
169
+ /**
170
+ * Select key using soft max probability distribution
171
+ * temperature = 0: hard limit (equivalent to greedy-min)
172
+ */
173
+ selectKeyWithSoftMax(keys, temperature) {
174
+ // Empty array
175
+ if (keys.length === 0) {
176
+ return null;
177
+ }
178
+ // temperature = 0: hard limit, equivalent to greedy-min
179
+ if (temperature === 0) {
180
+ return this.selectGreedyMin(keys);
181
+ }
182
+ const keyProbabilities = this.calculateSoftMaxProbabilities(keys, temperature);
183
+ // Weighted random sampling (roulette wheel selection)
184
+ const random = Math.random();
185
+ let cumulativeProbability = 0;
186
+ for (const { key, probability } of keyProbabilities) {
187
+ cumulativeProbability += probability;
188
+ if (random <= cumulativeProbability) {
189
+ return key;
190
+ }
191
+ }
192
+ // Fallback to last key
193
+ return keyProbabilities[keyProbabilities.length - 1].key;
194
+ }
45
195
  /**
46
196
  * Release a key after request completion
47
197
  */
@@ -78,7 +228,8 @@ class ApiKeyManager {
78
228
  state.monthLimit = limit.perMonth;
79
229
  state.secondLimit = limit.perSecond;
80
230
  state.inUse = false;
81
- console.debug(`[ApiKeyManager] Key ${apiKey.slice(0, 8)}... quota: ${state.remainingQuota}/${state.monthLimit}`);
231
+ // Save state after update
232
+ this.saveState();
82
233
  }
83
234
  /**
84
235
  * Mark a key as invalid (e.g., 401 Unauthorized)
@@ -116,8 +267,8 @@ class ApiKeyManager {
116
267
  }
117
268
  // Singleton instance
118
269
  let apiKeyManagerInstance = null;
119
- export function initApiKeyManager(apiKeys) {
120
- apiKeyManagerInstance = new ApiKeyManager(apiKeys);
270
+ export function initApiKeyManager(apiKeys, options) {
271
+ apiKeyManagerInstance = new ApiKeyManager(apiKeys, options);
121
272
  }
122
273
  export function getApiKeyManager() {
123
274
  return apiKeyManagerInstance;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@dyingc/brave-search-mcp-server",
3
3
  "mcpName": "io.github.brave/brave-search-mcp-server",
4
4
  "private": false,
5
- "version": "2.0.69",
5
+ "version": "2.1.0",
6
6
  "description": "Brave Search MCP Server: web results, images, videos, rich results, AI summaries, and more.",
7
7
  "keywords": [
8
8
  "api",