blackbox-mcp 0.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/dist/index.js ADDED
@@ -0,0 +1,932 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { z } from 'zod';
6
+ import { ethers } from 'ethers';
7
+ import { loadConfig } from './config.js';
8
+ import { WalletManager } from './wallet.js';
9
+ import { BlackBoxAPI } from './api.js';
10
+ import { reconstructPrivateKey, verifyKeyMatchesAddress, createProofMessage, createWithdrawalSignature, createRelayWithdrawalSignature, } from './crypto.js';
11
+ const config = loadConfig();
12
+ const walletManager = new WalletManager(config.walletStorePath);
13
+ const api = new BlackBoxAPI(config);
14
+ const TREASURY_ABI = [
15
+ 'function deposit(address token, uint256 amount) payable',
16
+ 'function withdraw(address token, address payable recipient, uint256 amount, uint256 merkleRootId, bytes signature, bytes32[] merkleProof, uint256 keyIndex)',
17
+ ];
18
+ const ERC20_ABI = [
19
+ 'function approve(address spender, uint256 amount) returns (bool)',
20
+ 'function allowance(address owner, address spender) view returns (uint256)',
21
+ 'function balanceOf(address account) view returns (uint256)',
22
+ 'function decimals() view returns (uint8)',
23
+ 'function symbol() view returns (string)',
24
+ ];
25
+ // Helper: process keyshares into reconstructed keys
26
+ async function processKeyshares(successful, _failed, withdrawal_requests, threshold) {
27
+ const keys = [];
28
+ for (let wrIdx = 0; wrIdx < withdrawal_requests.length; wrIdx++) {
29
+ const keyshareData = successful.map(r => {
30
+ const ks = r.keyshares[wrIdx];
31
+ return {
32
+ nodeId: r.nodeId,
33
+ shareId: ks.share_id,
34
+ shareValue: ks.share_value,
35
+ keyIndex: ks.key_index,
36
+ address: ks.address,
37
+ merkleRootId: ks.merkle_root_id,
38
+ merkleProof: ks.merkle_proof || [],
39
+ denomination: ks.denomination,
40
+ chainName: ks.chain_name,
41
+ chainId: ks.chain_id,
42
+ treasuryAddress: ks.treasury_address,
43
+ tokenSymbol: ks.token_symbol,
44
+ tokenAddress: ks.token_address || ethers.constants.AddressZero,
45
+ tokenDecimals: ks.token_decimals || 18,
46
+ };
47
+ }).filter(ks => ks.shareId);
48
+ const expectedAddress = keyshareData[0].address;
49
+ const expectedKeyIndex = keyshareData[0].keyIndex;
50
+ for (const ks of keyshareData) {
51
+ if (ks.address !== expectedAddress)
52
+ throw new Error(`Nodes returned different addresses for key ${wrIdx}`);
53
+ if (ks.keyIndex !== expectedKeyIndex)
54
+ throw new Error(`Nodes returned different key indices for key ${wrIdx}`);
55
+ }
56
+ const { privateKeyHex } = reconstructPrivateKey(keyshareData, threshold);
57
+ if (!verifyKeyMatchesAddress(privateKeyHex, expectedAddress)) {
58
+ throw new Error(`Key reconstruction failed for withdrawal ${wrIdx}: address mismatch`);
59
+ }
60
+ keys.push({
61
+ index: wrIdx,
62
+ private_key: privateKeyHex,
63
+ address: expectedAddress,
64
+ key_index: expectedKeyIndex,
65
+ merkle_root_id: keyshareData[0].merkleRootId,
66
+ merkle_proof: keyshareData[0].merkleProof,
67
+ denomination: keyshareData[0].denomination,
68
+ chain_name: keyshareData[0].chainName,
69
+ chain_id: keyshareData[0].chainId,
70
+ treasury_address: keyshareData[0].treasuryAddress,
71
+ token_symbol: keyshareData[0].tokenSymbol,
72
+ token_address: keyshareData[0].tokenAddress,
73
+ token_decimals: keyshareData[0].tokenDecimals,
74
+ });
75
+ }
76
+ const firstResult = successful[0];
77
+ return {
78
+ content: [{
79
+ type: 'text',
80
+ text: JSON.stringify({
81
+ success: true,
82
+ keys_count: keys.length,
83
+ keys,
84
+ deposit_amount: firstResult.depositAmount,
85
+ claimed_amount: firstResult.claimedAmount,
86
+ remaining_deposit: firstResult.remainingDeposit,
87
+ nodes_responded: successful.length,
88
+ warning: 'These private keys are one-time-use withdrawal keys. Each can only be used once on-chain.',
89
+ }, null, 2),
90
+ }],
91
+ };
92
+ }
93
+ function createServer() {
94
+ const server = new McpServer({
95
+ name: 'blackbox-protocol',
96
+ version: '0.1.0',
97
+ });
98
+ // ─── Agent Guide Resource ───────────────────────────────────────────────────
99
+ const AGENT_GUIDE = `# BlackBox Protocol — Agent Workflow Guide
100
+
101
+ ## What is BlackBox?
102
+ A privacy protocol using Distributed Key Generation (DKG). You deposit tokens on one chain,
103
+ receive threshold-reconstructed private keys, and withdraw on any supported chain — breaking
104
+ the on-chain link between deposit and withdrawal.
105
+
106
+ ## Supported Chains & Denominations
107
+ Call \`get_supported_chains\` and \`get_available_denominations\` for current data. Typical setup:
108
+ - **Sepolia**: 0.001 ETH, 1 USDC
109
+ - **Base Sepolia**: 0.001 ETH, 1 USDC
110
+ - **Hyperliquid Testnet**: 0.01 HYPE, 1 USDC
111
+ - **BNB Testnet**: 0.01 TBNB, 1 USDC (IMPORTANT: BNB USDC has 18 decimals, not 6!)
112
+ - **Polygon Amoy**: 1 POL, 1 USDC
113
+ - **Solana Devnet**: 0.1 SOL, 1 USDC
114
+
115
+ ## Step-by-Step Workflow
116
+
117
+ ### 1. Setup (one-time)
118
+ - Call \`create_wallet\` with type "evm" (and optionally "solana" for Solana chains).
119
+ - Fund the wallet with native gas tokens (ETH, POL, HYPE, etc.) and any ERC20 tokens (USDC).
120
+ - The wallet is encrypted and persisted locally.
121
+
122
+ ### 2. Discover available options
123
+ - \`get_supported_chains\` — lists all chains with RPC URLs, treasury addresses, native currencies.
124
+ - \`get_chain_tokens\` — lists tokens on a specific chain (with addresses and decimals). ALWAYS check decimals — BNB USDC uses 18 decimals.
125
+ - \`get_available_denominations\` — lists EXACT valid deposit amounts. You MUST use these exact values.
126
+ - \`get_token_mappings\` — shows which tokens can be moved cross-chain (returns object keyed by token symbol).
127
+
128
+ ### 3. Deposit
129
+ - Call \`deposit\` with: wallet_name, password, chain_name, token (symbol like "USDC" or "ETH"), amount.
130
+ - You can pass a token symbol (e.g., "USDC") — it auto-resolves to the correct address and decimals.
131
+ - The tool handles ERC20 approval automatically.
132
+ - You MUST deposit an amount equal to the sum of denominations you plan to claim.
133
+ - You get back a transaction hash. Wait for it to confirm (2 blocks).
134
+
135
+ ### 4. Claim Keys
136
+ - Call \`claim_keys\` with: deposit_tx_hash, source_chain, and an array of withdrawal_requests.
137
+ - Each withdrawal_request specifies: { target_chain, token_symbol, denomination }.
138
+ - The total of all denominations must not exceed the deposit amount.
139
+ - Denominations MUST match registered values (e.g., "1" for USDC, "0.001" for ETH). Invalid denominations are rejected.
140
+ - You can split across multiple chains (e.g., deposit 2 USDC, claim 1 on Base + 1 on Sepolia).
141
+ - Token symbol in withdrawal_request must match what was deposited (e.g., deposit USDC, claim USDC — not ETH).
142
+ - For partial claims: use \`occurrence_offset\` to claim remaining balance in a second call.
143
+ - Re-claiming the same deposit returns the same key (idempotent).
144
+ - Returns reconstructed private keys, merkle proofs, and key indices for each withdrawal.
145
+
146
+ ### 5. Withdraw
147
+ **Option A: Direct on-chain withdrawal** (you pay gas) — RECOMMENDED
148
+ - Call \`withdraw_onchain\` with the key data from claim_keys.
149
+ - You need native gas on the target chain.
150
+ - The key can only be withdrawn on the chain it was claimed for (wrong chain will revert).
151
+
152
+ **Option B: Relay withdrawal** (gas-free)
153
+ - Call \`relay_withdraw\` with the key data.
154
+ - The tool checks if relay is enabled first.
155
+ - IMPORTANT: max_relayer_fee must be > 0 (default: 10% of amount). Setting it to 0 will fail.
156
+ - Use \`check_relay_status\` to poll the job until confirmed.
157
+
158
+ **Option C: Combined deposit + claim**
159
+ - Call \`deposit_and_claim\` to do both steps in one call (waits for confirmations internally).
160
+
161
+ ## Security Rules
162
+ - Only the depositor address can claim keys for their deposit.
163
+ - Claim requests must have a fresh timestamp (within minutes, not hours).
164
+ - Each key can only be used once (on-chain nullifier). Double-withdrawal reverts.
165
+ - Keys are chain-specific: claimed for base_sepolia cannot be used on sepolia.
166
+ - Token types are enforced: deposit USDC, must claim USDC (not ETH).
167
+ - Polygon chains require higher gas (handled automatically).
168
+ - Backend needs 2 block confirmations before issuing keyshares (handled automatically with retries).
169
+
170
+ ## Example: Cross-chain USDC transfer
171
+ 1. \`deposit\` 2 USDC on Sepolia (denomination must be whole numbers matching registered amounts)
172
+ 2. \`claim_keys\` with withdrawal_requests: [
173
+ { target_chain: "base_sepolia", token_symbol: "USDC", denomination: "1" },
174
+ { target_chain: "sepolia", token_symbol: "USDC", denomination: "1" }
175
+ ]
176
+ 3. \`withdraw_onchain\` for each key on its target chain
177
+
178
+ ## Error Handling
179
+ - "No merkle root found" — invalid denomination. Check \`get_available_denominations\`.
180
+ - "Total requested value exceeds deposit amount" — claim too much.
181
+ - "Deposit transaction was not sent by the requesting user" — wrong wallet.
182
+ - "Request timestamp is too old" — stale request, try again.
183
+ - "No token mapping found" — deposited token X but tried to claim token Y.
184
+ - If claim_keys fails with "needs more confirmations", it retries automatically (up to 3 times).
185
+ - If a withdrawal reverts, check that the key hasn't been used already (nullifier).
186
+ - If relay returns MaxRelayerFeeExceeded, increase max_relayer_fee.
187
+ - If relay returns "relay service is disabled", use withdraw_onchain instead.
188
+ `;
189
+ server.resource('agent_guide', 'blackbox://agent-guide', { mimeType: 'text/plain' }, async () => ({
190
+ contents: [{ uri: 'blackbox://agent-guide', text: AGENT_GUIDE, mimeType: 'text/plain' }],
191
+ }));
192
+ // ─── Wallet Tools ───────────────────────────────────────────────────────────
193
+ server.tool('create_wallet', 'Create a new wallet with secure random key. Supports EVM (Ethereum/Polygon/etc) and Solana. Returns address and private key (shown once). The key is stored encrypted.', {
194
+ name: z.string().describe('Name for the wallet (e.g., "trading-agent")'),
195
+ password: z.string().describe('Password to encrypt the stored private key'),
196
+ wallet_type: z.enum(['evm', 'solana']).optional().describe('Wallet type: "evm" (default) or "solana"'),
197
+ }, async ({ name, password, wallet_type }) => {
198
+ try {
199
+ const type = wallet_type || 'evm';
200
+ const { address, privateKey } = walletManager.createWallet(name, password, type);
201
+ return {
202
+ content: [{
203
+ type: 'text',
204
+ text: JSON.stringify({
205
+ success: true,
206
+ name,
207
+ wallet_type: type,
208
+ address,
209
+ private_key: privateKey,
210
+ warning: 'Save this private key securely. It will not be shown again. The encrypted copy is stored locally.',
211
+ }, null, 2),
212
+ }],
213
+ };
214
+ }
215
+ catch (e) {
216
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
217
+ }
218
+ });
219
+ server.tool('import_wallet', 'Import an existing EVM wallet from a private key.', {
220
+ name: z.string().describe('Name for the wallet'),
221
+ private_key: z.string().describe('Private key (hex, with or without 0x prefix)'),
222
+ password: z.string().describe('Password to encrypt the stored key'),
223
+ }, async ({ name, private_key, password }) => {
224
+ try {
225
+ const { address } = walletManager.importWallet(name, private_key, password);
226
+ return {
227
+ content: [{ type: 'text', text: JSON.stringify({ success: true, name, address }, null, 2) }],
228
+ };
229
+ }
230
+ catch (e) {
231
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
232
+ }
233
+ });
234
+ server.tool('list_wallets', 'List all stored wallets (names and addresses).', {}, async () => {
235
+ const wallets = walletManager.listWallets();
236
+ return {
237
+ content: [{ type: 'text', text: JSON.stringify({ wallets }, null, 2) }],
238
+ };
239
+ });
240
+ server.tool('get_balance', 'Get native and/or token balance for a wallet on a specific chain.', {
241
+ wallet_name: z.string().describe('Wallet name'),
242
+ password: z.string().describe('Wallet password'),
243
+ chain_name: z.string().describe('Chain name (e.g., "sepolia", "polygon-amoy")'),
244
+ token_address: z.string().optional().describe('ERC20 token contract address (omit for native balance only)'),
245
+ }, async ({ wallet_name, password, chain_name, token_address }) => {
246
+ try {
247
+ const chains = await api.getChains();
248
+ const chain = chains.find(c => c.chain_name === chain_name);
249
+ if (!chain)
250
+ return { content: [{ type: 'text', text: `Chain "${chain_name}" not found` }], isError: true };
251
+ if (!chain.rpc_url)
252
+ return { content: [{ type: 'text', text: `No RPC URL configured for ${chain_name}` }], isError: true };
253
+ const provider = new ethers.providers.JsonRpcProvider(chain.rpc_url);
254
+ const signer = walletManager.getSigner(wallet_name, password, provider);
255
+ const address = await signer.getAddress();
256
+ const nativeBalance = await provider.getBalance(address);
257
+ const result = {
258
+ address,
259
+ chain: chain_name,
260
+ native_balance: ethers.utils.formatEther(nativeBalance),
261
+ native_currency: chain.native_currency || 'ETH',
262
+ };
263
+ if (token_address) {
264
+ const token = new ethers.Contract(token_address, ERC20_ABI, provider);
265
+ const [balance, decimals, symbol] = await Promise.all([
266
+ token.balanceOf(address),
267
+ token.decimals(),
268
+ token.symbol(),
269
+ ]);
270
+ result.token_balance = ethers.utils.formatUnits(balance, decimals);
271
+ result.token_symbol = symbol;
272
+ result.token_address = token_address;
273
+ }
274
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
275
+ }
276
+ catch (e) {
277
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
278
+ }
279
+ });
280
+ // ─── Protocol Discovery Tools ───────────────────────────────────────────────
281
+ server.tool('get_supported_chains', 'Get all chains supported by the BlackBox protocol, including RPC URLs, treasury addresses, and supported tokens.', {}, async () => {
282
+ try {
283
+ const chains = await api.getChains();
284
+ return { content: [{ type: 'text', text: JSON.stringify({ chains }, null, 2) }] };
285
+ }
286
+ catch (e) {
287
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
288
+ }
289
+ });
290
+ server.tool('get_chain_tokens', 'Get tokens configured for a specific chain, including contract addresses and supported denominations.', {
291
+ chain: z.string().describe('Chain name (e.g., "sepolia", "polygon-amoy")'),
292
+ }, async ({ chain }) => {
293
+ try {
294
+ const tokens = await api.getTokens(chain);
295
+ return { content: [{ type: 'text', text: JSON.stringify({ chain, tokens }, null, 2) }] };
296
+ }
297
+ catch (e) {
298
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
299
+ }
300
+ });
301
+ server.tool('get_available_denominations', 'Get available denominations (amounts) that can be deposited/withdrawn for a chain. Each denomination has a merkle root registered on-chain.', {
302
+ chain: z.string().optional().describe('Chain name to filter (omit for all chains)'),
303
+ }, async ({ chain }) => {
304
+ try {
305
+ const roots = await api.getMerkleRoots(chain);
306
+ // Deduplicate by denomination+tokenSymbol
307
+ const seen = new Set();
308
+ const unique = roots.filter(r => {
309
+ const key = `${r.denomination}-${r.token_symbol}-${r.chain_name}`;
310
+ if (seen.has(key))
311
+ return false;
312
+ seen.add(key);
313
+ return true;
314
+ });
315
+ return { content: [{ type: 'text', text: JSON.stringify({ denominations: unique }, null, 2) }] };
316
+ }
317
+ catch (e) {
318
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
319
+ }
320
+ });
321
+ server.tool('get_token_mappings', 'Get cross-chain token mappings showing which tokens can be used across different chains. Returns an object keyed by token symbol (e.g., ETH, USDC) with chain-pair mappings.', {}, async () => {
322
+ try {
323
+ const data = await api.getTokenMappings();
324
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
325
+ }
326
+ catch (e) {
327
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
328
+ }
329
+ });
330
+ // ─── Deposit Tool ───────────────────────────────────────────────────────────
331
+ server.tool('deposit', 'Deposit funds into the BlackBox treasury contract. For ERC20 tokens, handles approval automatically. Returns the deposit transaction hash. You can pass either a token symbol (e.g., "USDC") or a token address.', {
332
+ wallet_name: z.string().describe('Wallet name to deposit from'),
333
+ password: z.string().describe('Wallet password'),
334
+ chain_name: z.string().describe('Chain to deposit on (e.g., "sepolia", "base_sepolia")'),
335
+ amount: z.string().describe('Amount to deposit (human-readable, e.g., "1"). Must match a registered denomination — use get_available_denominations to see valid amounts.'),
336
+ token: z.string().optional().describe('Token symbol (e.g., "USDC", "ETH") or ERC20 contract address. Omit for native token.'),
337
+ }, async ({ wallet_name, password, chain_name, amount, token }) => {
338
+ try {
339
+ const chains = await api.getChains();
340
+ const chain = chains.find(c => c.chain_name === chain_name);
341
+ if (!chain)
342
+ return { content: [{ type: 'text', text: `Chain "${chain_name}" not found. Available: ${chains.map(c => c.chain_name).join(', ')}` }], isError: true };
343
+ if (!chain.treasury_address)
344
+ return { content: [{ type: 'text', text: `No treasury address for ${chain_name}` }], isError: true };
345
+ // Resolve token symbol to address if needed
346
+ let tokenAddress = ethers.constants.AddressZero;
347
+ let tokenDecimals = 18;
348
+ let tokenSymbol = chain.native_currency || 'ETH';
349
+ const isNativeSymbol = !token || token === ethers.constants.AddressZero ||
350
+ token.toUpperCase() === (chain.native_currency || 'ETH').toUpperCase();
351
+ if (!isNativeSymbol) {
352
+ // Could be a symbol or address
353
+ const tokens = await api.getTokens(chain_name);
354
+ if (token.startsWith('0x') && token.length === 42) {
355
+ // It's an address
356
+ tokenAddress = token;
357
+ const found = tokens.find((t) => (t.Address || t.address)?.toLowerCase() === token.toLowerCase());
358
+ if (found) {
359
+ tokenDecimals = found.Decimals || found.decimals || 18;
360
+ tokenSymbol = found.Symbol || found.symbol || 'ERC20';
361
+ }
362
+ else {
363
+ // Read decimals from contract
364
+ const erc20 = new ethers.Contract(token, ERC20_ABI, new ethers.providers.JsonRpcProvider(chain.rpc_url));
365
+ tokenDecimals = await erc20.decimals();
366
+ }
367
+ }
368
+ else {
369
+ // It's a symbol — resolve to address
370
+ const found = tokens.find((t) => (t.Symbol || t.symbol)?.toUpperCase() === token.toUpperCase());
371
+ if (!found) {
372
+ return { content: [{ type: 'text', text: `Token "${token}" not found on ${chain_name}. Available: ${tokens.map((t) => t.Symbol || t.symbol).join(', ')}` }], isError: true };
373
+ }
374
+ tokenAddress = found.Address || found.address;
375
+ tokenDecimals = found.Decimals || found.decimals || 18;
376
+ tokenSymbol = found.Symbol || found.symbol;
377
+ }
378
+ }
379
+ const isNative = tokenAddress === ethers.constants.AddressZero;
380
+ const provider = new ethers.providers.JsonRpcProvider(chain.rpc_url);
381
+ const signer = walletManager.getSigner(wallet_name, password, provider);
382
+ const treasury = new ethers.Contract(chain.treasury_address, TREASURY_ABI, signer);
383
+ // Polygon chains need minimum 30 gwei priority fee
384
+ const gasOverrides = { gasLimit: 300000 };
385
+ const polygonIds = new Set([137, 80001, 80002]);
386
+ if (polygonIds.has(chain.chain_id)) {
387
+ const minTip = ethers.utils.parseUnits('30', 'gwei');
388
+ const feeData = await provider.getFeeData();
389
+ gasOverrides.maxPriorityFeePerGas = minTip;
390
+ gasOverrides.maxFeePerGas = feeData.maxFeePerGas?.gt(minTip) ? feeData.maxFeePerGas : minTip.mul(2);
391
+ }
392
+ let tx;
393
+ if (isNative) {
394
+ const amountWei = ethers.utils.parseEther(amount);
395
+ tx = await treasury.deposit(ethers.constants.AddressZero, amountWei, {
396
+ value: amountWei,
397
+ ...gasOverrides,
398
+ });
399
+ }
400
+ else {
401
+ const amountWei = ethers.utils.parseUnits(amount, tokenDecimals);
402
+ const erc20 = new ethers.Contract(tokenAddress, ERC20_ABI, signer);
403
+ // Check and approve if needed
404
+ const currentAllowance = await erc20.allowance(await signer.getAddress(), chain.treasury_address);
405
+ if (currentAllowance.lt(amountWei)) {
406
+ const approveTx = await erc20.approve(chain.treasury_address, amountWei, gasOverrides);
407
+ await approveTx.wait();
408
+ }
409
+ tx = await treasury.deposit(tokenAddress, amountWei, gasOverrides);
410
+ }
411
+ const receipt = await tx.wait();
412
+ return {
413
+ content: [{
414
+ type: 'text',
415
+ text: JSON.stringify({
416
+ success: true,
417
+ tx_hash: receipt.transactionHash,
418
+ chain: chain_name,
419
+ amount,
420
+ token_symbol: tokenSymbol,
421
+ token_address: tokenAddress,
422
+ token_decimals: tokenDecimals,
423
+ block_number: receipt.blockNumber,
424
+ explorer_url: chain.block_explorer
425
+ ? `${chain.block_explorer.replace(/\/+$/, '')}/tx/${receipt.transactionHash}`
426
+ : undefined,
427
+ }, null, 2),
428
+ }],
429
+ };
430
+ }
431
+ catch (e) {
432
+ return { content: [{ type: 'text', text: `Deposit failed: ${e.message}` }], isError: true };
433
+ }
434
+ });
435
+ // ─── Key Claiming Tool ──────────────────────────────────────────────────────
436
+ server.tool('claim_keys', 'Claim withdrawal keys from DKG nodes for a deposit. Signs a proof message, requests keyshares from all nodes, and reconstructs the private keys via Lagrange interpolation. Returns the reconstructed keys with all data needed for withdrawal.', {
437
+ wallet_name: z.string().describe('Wallet name (must match the depositor address)'),
438
+ password: z.string().describe('Wallet password'),
439
+ deposit_tx_hash: z.string().describe('Transaction hash of the deposit'),
440
+ source_chain: z.string().describe('Chain where deposit was made (e.g., "sepolia")'),
441
+ withdrawal_requests: z.array(z.object({
442
+ target_chain: z.string().describe('Chain to withdraw on'),
443
+ token_symbol: z.string().describe('Token symbol (e.g., "ETH", "USDC")'),
444
+ denomination: z.string().describe('Amount denomination (e.g., "1", "0.1")'),
445
+ })).describe('What to withdraw. Total must not exceed deposit amount.'),
446
+ occurrence_offset: z.number().optional().describe('Number of keys already claimed for this deposit (default: 0)'),
447
+ }, async ({ wallet_name, password, deposit_tx_hash, source_chain, withdrawal_requests, occurrence_offset }) => {
448
+ try {
449
+ const privateKey = walletManager.getPrivateKey(wallet_name, password);
450
+ const wallet = new ethers.Wallet(privateKey);
451
+ const userAddress = wallet.address;
452
+ const offset = occurrence_offset || 0;
453
+ // Wait for confirmations before requesting keyshares
454
+ // Backend requires 2 confirmations for EVM chains
455
+ const chains = await api.getChains();
456
+ const chain = chains.find(c => c.chain_name === source_chain);
457
+ if (chain?.rpc_url) {
458
+ const provider = new ethers.providers.JsonRpcProvider(chain.rpc_url);
459
+ const receipt = await provider.getTransactionReceipt(deposit_tx_hash);
460
+ if (receipt) {
461
+ const currentBlock = await provider.getBlockNumber();
462
+ const confirmations = currentBlock - receipt.blockNumber;
463
+ if (confirmations < 2) {
464
+ const blocksNeeded = 2 - confirmations;
465
+ // Wait ~5s per block (conservative estimate)
466
+ const waitMs = blocksNeeded * 5000 + 3000;
467
+ await new Promise(resolve => setTimeout(resolve, waitMs));
468
+ }
469
+ }
470
+ }
471
+ // Retry logic: attempt up to 3 times with 10s delays for confirmation issues
472
+ let lastError = '';
473
+ for (let attempt = 0; attempt < 3; attempt++) {
474
+ const timestamp = Math.floor(Date.now() / 1000);
475
+ const proofMessage = createProofMessage(deposit_tx_hash, source_chain, withdrawal_requests, userAddress, timestamp);
476
+ const signature = await wallet.signMessage(proofMessage);
477
+ const spendRequestId = ethers.utils.id(`${deposit_tx_hash}:${source_chain}:${offset}:${timestamp}`);
478
+ const results = await api.requestKeyshares({
479
+ depositTxHash: deposit_tx_hash,
480
+ sourceChain: source_chain,
481
+ withdrawalRequests: withdrawal_requests,
482
+ userAddress,
483
+ signature,
484
+ timestamp,
485
+ occurrenceOffset: offset,
486
+ spendRequestId,
487
+ });
488
+ const successful = results.filter(r => r.success && r.keyshares && r.keyshares.length > 0);
489
+ const failed = results.filter(r => !r.success);
490
+ if (successful.length >= config.threshold) {
491
+ // Success - proceed to key reconstruction (code below)
492
+ return await processKeyshares(successful, failed, withdrawal_requests, config.threshold);
493
+ }
494
+ // Check if it's a confirmation issue - worth retrying
495
+ const errors = failed.map(f => f.error || '').join('; ');
496
+ lastError = errors;
497
+ const isConfirmationIssue = errors.includes('confirmation');
498
+ if (!isConfirmationIssue || attempt === 2) {
499
+ // Provide helpful error context
500
+ let hint = '';
501
+ if (errors.includes('No merkle root found'))
502
+ hint = '\nHint: The denomination is not registered. Use get_available_denominations to find valid amounts.';
503
+ else if (errors.includes('exceeds deposit amount'))
504
+ hint = '\nHint: Total withdrawal amount exceeds what was deposited.';
505
+ else if (errors.includes('not sent by the requesting user'))
506
+ hint = '\nHint: Only the depositor wallet can claim keys for their deposit.';
507
+ else if (errors.includes('timestamp'))
508
+ hint = '\nHint: Request timestamp expired. Try again immediately.';
509
+ else if (errors.includes('No token mapping'))
510
+ hint = '\nHint: Token mismatch — you must claim the same token type you deposited.';
511
+ else if (errors.includes('insufficient peer evaluations'))
512
+ hint = '\nHint: DKG P2P exchange failed — some nodes may be unreachable. Try again.';
513
+ return {
514
+ content: [{ type: 'text', text: `Key claim failed: only ${successful.length}/${config.threshold} nodes responded. Errors: ${errors}${hint}` }],
515
+ isError: true,
516
+ };
517
+ }
518
+ // Wait before retry
519
+ await new Promise(resolve => setTimeout(resolve, 10000));
520
+ }
521
+ return {
522
+ content: [{ type: 'text', text: `Key claim failed after 3 attempts: ${lastError}` }],
523
+ isError: true,
524
+ };
525
+ }
526
+ catch (e) {
527
+ return { content: [{ type: 'text', text: `Key claim failed: ${e.message}` }], isError: true };
528
+ }
529
+ });
530
+ // ─── Withdrawal Tools ───────────────────────────────────────────────────────
531
+ server.tool('withdraw_onchain', 'Execute an on-chain withdrawal using a claimed key. Signs the withdrawal message with the one-time key and submits the transaction to the treasury contract.', {
532
+ wallet_name: z.string().describe('Wallet name to pay gas from'),
533
+ password: z.string().describe('Wallet password'),
534
+ recipient: z.string().describe('Address to receive the withdrawn funds'),
535
+ withdrawal_key: z.string().describe('Private key from claim_keys result'),
536
+ token_address: z.string().describe('Token contract address (zero address for native)'),
537
+ amount: z.string().describe('Amount to withdraw (human-readable)'),
538
+ token_decimals: z.number().describe('Token decimals'),
539
+ merkle_root_id: z.number().describe('Merkle root ID from claim_keys result'),
540
+ merkle_proof: z.array(z.string()).describe('Merkle proof from claim_keys result'),
541
+ key_index: z.number().describe('Key index from claim_keys result'),
542
+ chain_name: z.string().describe('Chain to withdraw on'),
543
+ treasury_address: z.string().describe('Treasury contract address from claim_keys result'),
544
+ }, async (params) => {
545
+ try {
546
+ const chains = await api.getChains();
547
+ const chain = chains.find(c => c.chain_name === params.chain_name);
548
+ if (!chain)
549
+ return { content: [{ type: 'text', text: `Chain "${params.chain_name}" not found` }], isError: true };
550
+ const provider = new ethers.providers.JsonRpcProvider(chain.rpc_url);
551
+ const gasSigner = walletManager.getSigner(params.wallet_name, params.password, provider);
552
+ const network = await provider.getNetwork();
553
+ const amountWei = ethers.utils.parseUnits(params.amount, params.token_decimals);
554
+ // Sign with the one-time withdrawal key
555
+ const withdrawalSig = await createWithdrawalSignature(params.withdrawal_key, params.recipient, params.token_address, amountWei, params.merkle_root_id, params.key_index, network.chainId);
556
+ const merkleProof = params.merkle_proof.map(p => p.startsWith('0x') ? p : '0x' + p);
557
+ // Polygon chains need minimum 25 gwei priority fee
558
+ const wGasOverrides = { gasLimit: 300000 };
559
+ const polygonChainIds = new Set([137, 80001, 80002]);
560
+ if (polygonChainIds.has(network.chainId)) {
561
+ const minTip = ethers.utils.parseUnits('30', 'gwei');
562
+ const feeData = await provider.getFeeData();
563
+ wGasOverrides.maxPriorityFeePerGas = minTip;
564
+ wGasOverrides.maxFeePerGas = feeData.maxFeePerGas?.gt(minTip) ? feeData.maxFeePerGas : minTip.mul(2);
565
+ }
566
+ const treasury = new ethers.Contract(params.treasury_address, TREASURY_ABI, gasSigner);
567
+ const tx = await treasury.withdraw(params.token_address, params.recipient, amountWei, params.merkle_root_id, withdrawalSig, merkleProof, params.key_index, wGasOverrides);
568
+ const receipt = await tx.wait();
569
+ return {
570
+ content: [{
571
+ type: 'text',
572
+ text: JSON.stringify({
573
+ success: true,
574
+ tx_hash: receipt.transactionHash,
575
+ block_number: receipt.blockNumber,
576
+ chain: params.chain_name,
577
+ recipient: params.recipient,
578
+ amount: params.amount,
579
+ explorer_url: chain.block_explorer
580
+ ? `${chain.block_explorer.replace(/\/+$/, '')}/tx/${receipt.transactionHash}`
581
+ : undefined,
582
+ }, null, 2),
583
+ }],
584
+ };
585
+ }
586
+ catch (e) {
587
+ return { content: [{ type: 'text', text: `Withdrawal failed: ${e.message}` }], isError: true };
588
+ }
589
+ });
590
+ server.tool('relay_withdraw', 'Submit a gas-free withdrawal via the relay service. The relayer submits the transaction on your behalf. Use this when the agent wallet has no gas on the target chain.', {
591
+ withdrawal_key: z.string().describe('Private key from claim_keys result'),
592
+ recipient: z.string().describe('Address to receive funds'),
593
+ token_address: z.string().describe('Token contract address'),
594
+ amount: z.string().describe('Amount in base units (wei)'),
595
+ token_decimals: z.number().describe('Token decimals'),
596
+ merkle_root_id: z.number().describe('Merkle root ID'),
597
+ merkle_proof: z.array(z.string()).describe('Merkle proof'),
598
+ key_index: z.number().describe('Key index'),
599
+ chain_name: z.string().describe('Chain name'),
600
+ chain_type: z.string().optional().describe('Chain type: "evm" or "solana" (default: "evm")'),
601
+ max_relayer_fee: z.string().optional().describe('Max fee willing to pay the relayer in base units (e.g., "500000" for 0.5 USDC). Must be > 0 to cover gas. Defaults to 10% of amount.'),
602
+ }, async (params) => {
603
+ try {
604
+ const chains = await api.getChains();
605
+ const chain = chains.find(c => c.chain_name === params.chain_name);
606
+ if (!chain)
607
+ return { content: [{ type: 'text', text: `Chain not found` }], isError: true };
608
+ const provider = new ethers.providers.JsonRpcProvider(chain.rpc_url);
609
+ const network = await provider.getNetwork();
610
+ // Check relay status before attempting
611
+ const relayInfoResp = await api.getRelayInfo();
612
+ const relayInfo = relayInfoResp.info || relayInfoResp;
613
+ if (relayInfo.enabled === false) {
614
+ return { content: [{ type: 'text', text: `Relay service is currently disabled. Use withdraw_onchain instead (requires gas on target chain).` }], isError: true };
615
+ }
616
+ const amountWei = ethers.utils.parseUnits(params.amount, params.token_decimals);
617
+ // Default maxRelayerFee to 10% of amount if not specified (must be > 0 to cover gas)
618
+ const defaultFee = amountWei.div(10);
619
+ const maxRelayerFee = params.max_relayer_fee
620
+ ? ethers.BigNumber.from(params.max_relayer_fee)
621
+ : (defaultFee.gt(0) ? defaultFee : ethers.BigNumber.from(1));
622
+ const relayerAddress = relayInfo.evm_relayer_address || ethers.constants.AddressZero;
623
+ const withdrawalSig = await createRelayWithdrawalSignature(params.withdrawal_key, params.recipient, params.token_address, amountWei, params.merkle_root_id, params.key_index, network.chainId, relayerAddress, maxRelayerFee);
624
+ const merkleProof = params.merkle_proof.map(p => p.startsWith('0x') ? p : '0x' + p);
625
+ const result = await api.relayWithdraw({
626
+ chain: params.chain_name,
627
+ chainType: params.chain_type || 'evm',
628
+ recipient: params.recipient,
629
+ amount: amountWei.toString(),
630
+ token: params.token_address,
631
+ signature: withdrawalSig,
632
+ merkleProof,
633
+ merkleRootId: params.merkle_root_id,
634
+ keyIndex: params.key_index,
635
+ maxRelayerFee: maxRelayerFee.toString(),
636
+ });
637
+ return {
638
+ content: [{
639
+ type: 'text',
640
+ text: JSON.stringify({
641
+ success: true,
642
+ job_id: result.job_id,
643
+ message: 'Relay withdrawal submitted. Use check_relay_status to track progress.',
644
+ ...result,
645
+ }, null, 2),
646
+ }],
647
+ };
648
+ }
649
+ catch (e) {
650
+ return { content: [{ type: 'text', text: `Relay withdrawal failed: ${e.message}` }], isError: true };
651
+ }
652
+ });
653
+ server.tool('check_relay_status', 'Check the status of a relay withdrawal job.', {
654
+ job_id: z.string().describe('Job ID from relay_withdraw result'),
655
+ }, async ({ job_id }) => {
656
+ try {
657
+ const result = await api.getRelayStatus(job_id);
658
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
659
+ }
660
+ catch (e) {
661
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
662
+ }
663
+ });
664
+ // ─── Utility Tools ──────────────────────────────────────────────────────────
665
+ server.tool('check_health', 'Check health and status of the BlackBox DKG network.', {}, async () => {
666
+ try {
667
+ const health = await api.getHealth();
668
+ return { content: [{ type: 'text', text: JSON.stringify(health, null, 2) }] };
669
+ }
670
+ catch (e) {
671
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
672
+ }
673
+ });
674
+ server.tool('get_leaderboard', 'Get the depositor leaderboard showing top depositors with their stats.', {
675
+ limit: z.number().optional().describe('Max entries to return (default: 100, max: 100)'),
676
+ }, async ({ limit }) => {
677
+ try {
678
+ const result = await api.getLeaderboard(limit || 100);
679
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
680
+ }
681
+ catch (e) {
682
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
683
+ }
684
+ });
685
+ server.tool('get_relay_info', 'Get relay service info including supported chains, fees, and whether the relayer is enabled.', {}, async () => {
686
+ try {
687
+ const info = await api.getRelayInfo();
688
+ return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
689
+ }
690
+ catch (e) {
691
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
692
+ }
693
+ });
694
+ server.tool('get_swap_quote', 'Get a swap quote from the relay service for cross-chain token swaps.', {
695
+ asset_in: z.string().describe('Input asset identifier (e.g., "USDC_sepolia")'),
696
+ asset_out: z.string().describe('Output asset identifier (e.g., "USDC_base_sepolia")'),
697
+ amount: z.string().describe('Exact input amount in base units (e.g., "1000000" for 1 USDC)'),
698
+ swap_type: z.string().optional().describe('Swap type: "EXACT_IN" (default) or "EXACT_OUT"'),
699
+ }, async ({ asset_in, asset_out, amount, swap_type }) => {
700
+ try {
701
+ const quote = await api.getSwapQuote({
702
+ assetIn: asset_in,
703
+ assetOut: asset_out,
704
+ amount,
705
+ swapType: swap_type || 'EXACT_IN',
706
+ dry: true,
707
+ });
708
+ return { content: [{ type: 'text', text: JSON.stringify(quote, null, 2) }] };
709
+ }
710
+ catch (e) {
711
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
712
+ }
713
+ });
714
+ // ─── Compound Tools ─────────────────────────────────────────────────────────
715
+ server.tool('deposit_and_claim', 'Full flow: deposit funds into treasury, then immediately claim withdrawal keys. Combines deposit + claim_keys in one step.', {
716
+ wallet_name: z.string().describe('Wallet name'),
717
+ password: z.string().describe('Wallet password'),
718
+ chain_name: z.string().describe('Chain to deposit on'),
719
+ amount: z.string().describe('Amount to deposit (must match a registered denomination)'),
720
+ token: z.string().optional().describe('Token symbol (e.g., "USDC", "ETH") or ERC20 address. Omit for native token.'),
721
+ withdrawal_requests: z.array(z.object({
722
+ target_chain: z.string(),
723
+ token_symbol: z.string(),
724
+ denomination: z.string(),
725
+ })).describe('What denominations to claim as keys. Total must equal deposit amount.'),
726
+ }, async ({ wallet_name, password, chain_name, amount, token, withdrawal_requests }) => {
727
+ try {
728
+ // Step 1: Deposit
729
+ const chains = await api.getChains();
730
+ const chain = chains.find(c => c.chain_name === chain_name);
731
+ if (!chain)
732
+ return { content: [{ type: 'text', text: `Chain "${chain_name}" not found` }], isError: true };
733
+ // Resolve token
734
+ let tokenAddress = ethers.constants.AddressZero;
735
+ let tokenDecimals = 18;
736
+ const isNativeSymbol = !token || token === ethers.constants.AddressZero ||
737
+ token.toUpperCase() === (chain.native_currency || 'ETH').toUpperCase();
738
+ if (!isNativeSymbol) {
739
+ const tokens = await api.getTokens(chain_name);
740
+ if (token.startsWith('0x') && token.length === 42) {
741
+ tokenAddress = token;
742
+ const found = tokens.find((t) => (t.Address || t.address)?.toLowerCase() === token.toLowerCase());
743
+ tokenDecimals = found ? (found.Decimals || found.decimals || 18) : 18;
744
+ }
745
+ else {
746
+ const found = tokens.find((t) => (t.Symbol || t.symbol)?.toUpperCase() === token.toUpperCase());
747
+ if (!found)
748
+ return { content: [{ type: 'text', text: `Token "${token}" not found on ${chain_name}` }], isError: true };
749
+ tokenAddress = found.Address || found.address;
750
+ tokenDecimals = found.Decimals || found.decimals || 18;
751
+ }
752
+ }
753
+ const isNative = tokenAddress === ethers.constants.AddressZero;
754
+ const provider = new ethers.providers.JsonRpcProvider(chain.rpc_url);
755
+ const signer = walletManager.getSigner(wallet_name, password, provider);
756
+ const treasury = new ethers.Contract(chain.treasury_address, TREASURY_ABI, signer);
757
+ // Polygon gas
758
+ const gasOverrides = { gasLimit: 300000 };
759
+ const polygonIds = new Set([137, 80001, 80002]);
760
+ if (polygonIds.has(chain.chain_id)) {
761
+ const minTip = ethers.utils.parseUnits('30', 'gwei');
762
+ const feeData = await provider.getFeeData();
763
+ gasOverrides.maxPriorityFeePerGas = minTip;
764
+ gasOverrides.maxFeePerGas = feeData.maxFeePerGas?.gt(minTip) ? feeData.maxFeePerGas : minTip.mul(2);
765
+ }
766
+ let tx;
767
+ if (isNative) {
768
+ const amountWei = ethers.utils.parseEther(amount);
769
+ tx = await treasury.deposit(ethers.constants.AddressZero, amountWei, { value: amountWei, ...gasOverrides });
770
+ }
771
+ else {
772
+ const amountWei = ethers.utils.parseUnits(amount, tokenDecimals);
773
+ const erc20 = new ethers.Contract(tokenAddress, ERC20_ABI, signer);
774
+ const currentAllowance = await erc20.allowance(await signer.getAddress(), chain.treasury_address);
775
+ if (currentAllowance.lt(amountWei)) {
776
+ const approveTx = await erc20.approve(chain.treasury_address, amountWei, gasOverrides);
777
+ await approveTx.wait();
778
+ }
779
+ tx = await treasury.deposit(tokenAddress, amountWei, gasOverrides);
780
+ }
781
+ const depositReceipt = await tx.wait();
782
+ const depositTxHash = depositReceipt.transactionHash;
783
+ // Step 2: Wait for confirmations then claim keys
784
+ // Wait 15s for block confirmations (backend requires 2)
785
+ await new Promise(resolve => setTimeout(resolve, 15000));
786
+ const walletPk = walletManager.getPrivateKey(wallet_name, password);
787
+ const wallet = new ethers.Wallet(walletPk);
788
+ const userAddress = wallet.address;
789
+ // Retry up to 3 times for confirmation issues
790
+ let claimResult = null;
791
+ for (let attempt = 0; attempt < 3; attempt++) {
792
+ const timestamp = Math.floor(Date.now() / 1000);
793
+ const proofMessage = createProofMessage(depositTxHash, chain_name, withdrawal_requests, userAddress, timestamp);
794
+ const signature = await wallet.signMessage(proofMessage);
795
+ const spendRequestId = ethers.utils.id(`${depositTxHash}:${chain_name}:0:${timestamp}`);
796
+ const results = await api.requestKeyshares({
797
+ depositTxHash,
798
+ sourceChain: chain_name,
799
+ withdrawalRequests: withdrawal_requests,
800
+ userAddress,
801
+ signature,
802
+ timestamp,
803
+ occurrenceOffset: 0,
804
+ spendRequestId,
805
+ });
806
+ const successful = results.filter(r => r.success && r.keyshares && r.keyshares.length > 0);
807
+ if (successful.length >= config.threshold) {
808
+ claimResult = await processKeyshares(successful, [], withdrawal_requests, config.threshold);
809
+ break;
810
+ }
811
+ const errors = results.filter(r => !r.success).map(f => f.error || '').join('; ');
812
+ if (!errors.includes('confirmation') || attempt === 2) {
813
+ return {
814
+ content: [{
815
+ type: 'text',
816
+ text: JSON.stringify({
817
+ deposit_success: true,
818
+ deposit_tx_hash: depositTxHash,
819
+ claim_success: false,
820
+ claim_error: `Only ${successful.length}/${config.threshold} nodes responded. ${errors}`,
821
+ note: 'Deposit succeeded but key claim failed. You can retry claim_keys with the deposit_tx_hash.',
822
+ }, null, 2),
823
+ }],
824
+ };
825
+ }
826
+ await new Promise(resolve => setTimeout(resolve, 10000));
827
+ }
828
+ if (!claimResult) {
829
+ return {
830
+ content: [{ type: 'text', text: JSON.stringify({ deposit_success: true, deposit_tx_hash: depositTxHash, claim_success: false, note: 'Claim failed after retries' }) }],
831
+ };
832
+ }
833
+ // Merge deposit info into claim result
834
+ const claimData = JSON.parse(claimResult.content[0].text);
835
+ return {
836
+ content: [{
837
+ type: 'text',
838
+ text: JSON.stringify({
839
+ success: true,
840
+ deposit_tx_hash: depositTxHash,
841
+ keys_count: claimData.keys_count,
842
+ keys: claimData.keys,
843
+ remaining_deposit: claimData.remaining_deposit,
844
+ explorer_url: chain.block_explorer
845
+ ? `${chain.block_explorer.replace(/\/+$/, '')}/tx/${depositTxHash}`
846
+ : undefined,
847
+ }, null, 2),
848
+ }],
849
+ };
850
+ }
851
+ catch (e) {
852
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
853
+ }
854
+ });
855
+ return server;
856
+ }
857
+ // ─── Start Server ───────────────────────────────────────────────────────────
858
+ async function main() {
859
+ const mode = process.env.MCP_TRANSPORT || 'stdio';
860
+ if (mode === 'http') {
861
+ const http = await import('node:http');
862
+ const crypto = await import('node:crypto');
863
+ const port = parseInt(process.env.PORT || '3001');
864
+ const sessions = new Map();
865
+ const httpServer = http.createServer(async (req, res) => {
866
+ // CORS headers
867
+ res.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*');
868
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
869
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
870
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
871
+ if (req.method === 'OPTIONS') {
872
+ res.writeHead(204);
873
+ res.end();
874
+ return;
875
+ }
876
+ // Health check
877
+ if (req.url === '/health') {
878
+ res.writeHead(200, { 'Content-Type': 'application/json' });
879
+ res.end(JSON.stringify({ status: 'ok', transport: 'streamable-http', sessions: sessions.size }));
880
+ return;
881
+ }
882
+ if (req.url !== '/mcp') {
883
+ res.writeHead(404);
884
+ res.end('Not found. Use /mcp for MCP endpoint or /health for health check.');
885
+ return;
886
+ }
887
+ // Check for existing session
888
+ const sessionId = req.headers['mcp-session-id'];
889
+ if (sessionId && sessions.has(sessionId)) {
890
+ const transport = sessions.get(sessionId);
891
+ await transport.handleRequest(req, res);
892
+ return;
893
+ }
894
+ if (sessionId && !sessions.has(sessionId)) {
895
+ res.writeHead(404, { 'Content-Type': 'application/json' });
896
+ res.end(JSON.stringify({ error: 'Session not found. Start a new session without mcp-session-id header.' }));
897
+ return;
898
+ }
899
+ // Only POST can initialize a new session
900
+ if (req.method !== 'POST') {
901
+ res.writeHead(405, { 'Content-Type': 'application/json' });
902
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST to initialize a session.' }));
903
+ return;
904
+ }
905
+ // New session
906
+ const transport = new StreamableHTTPServerTransport({
907
+ sessionIdGenerator: () => crypto.randomUUID(),
908
+ });
909
+ transport.onclose = () => {
910
+ const sid = transport.sessionId;
911
+ if (sid)
912
+ sessions.delete(sid);
913
+ };
914
+ const sessionServer = createServer();
915
+ await sessionServer.connect(transport);
916
+ await transport.handleRequest(req, res);
917
+ const sid = transport.sessionId;
918
+ if (sid)
919
+ sessions.set(sid, transport);
920
+ });
921
+ httpServer.listen(port, () => {
922
+ console.error(`BlackBox MCP server running on http://0.0.0.0:${port}/mcp`);
923
+ console.error(`Health check: http://0.0.0.0:${port}/health`);
924
+ });
925
+ }
926
+ else {
927
+ const transport = new StdioServerTransport();
928
+ await createServer().connect(transport);
929
+ }
930
+ }
931
+ main().catch(console.error);
932
+ //# sourceMappingURL=index.js.map