@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 +42 -0
- package/dist/config.js +32 -0
- package/dist/index.js +5 -2
- package/dist/utils/apiKeyManager.js +164 -13
- package/package.json +1 -1
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
|
-
|
|
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
|
-
//
|
|
35
|
-
availableKeys.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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",
|