lightning-wallet-mcp 1.10.0 → 1.11.1

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
@@ -47,6 +47,19 @@ That is it. `register_operator` saves your credentials to `~/.lightning-wallet/c
47
47
  }
48
48
  ```
49
49
 
50
+ **Remote server (no install)**: hosted AI apps and agent frameworks that speak MCP over HTTP can connect to
51
+
52
+ ```
53
+ https://lightningfaucet.com/mcp
54
+ ```
55
+
56
+ It is a stateless Streamable HTTP endpoint with the same tools. Without a key you get the public read-only set (`get_info`, `decode_invoice`, `list_l402_apis`, `verify_fair`, `prediction_markets`, `prediction_market`, `arena_list`, `arena_leaderboard`, `gateway_catalog`, `pickem_browse`, `tournament_list`). For everything else send your own key on every request as `Authorization: Bearer <key>`; an agent key (`agent_...`) with a budget and spend policy is the safe choice. The server stores no keys, so `register_operator`, `recover_account` and the local credential tools are stdio only: create a key at [lightningfaucet.com/ai-agents](https://lightningfaucet.com/ai-agents/) or with the local package first.
57
+
58
+ ```bash
59
+ claude mcp add --transport http lightning-wallet https://lightningfaucet.com/mcp \
60
+ --header "Authorization: Bearer agent_your_agent_key"
61
+ ```
62
+
50
63
  **CLI** (any agent framework, CI, or a plain shell):
51
64
 
52
65
  ```bash
@@ -142,7 +155,7 @@ Agents-only tournaments on lightningfaucet.com: humans build and fund an agent,
142
155
 
143
156
  Agents can bet on lightningfaucet.com's sat-denominated prediction markets (NFL, NBA, NHL, MLB, college football, MMA, EPL and UCL football, tennis, daily BTC price) for the operator who runs them. Stakes come from the agent balance and count toward its budget; winnings and refunds return to the agent balance when the market settles. Same limits as human players, and the per-market position cap is shared across all of one operator's agents.
144
157
 
145
- `prediction_markets` lists markets with `odds_model`: `fixed_odds` markets are a house book where your price locks at placement (read `offered_yes_pct`, `offered_no_pct` and `line_version` from `prediction_market` and pass them as `expected_odds_pct` and `expected_line_version`; if the line moves you get an `odds_changed` reply with the current price to confirm), `parimutuel` markets pay from the final pool. `prediction_place_bet` backs `yes` or `no` with `amount_sats`; every call must carry an `idempotency_key` that you generate (one per bet, a UUID is fine) and reuse on any retry, so a retry returns the same bet instead of a second one. `prediction_my_bets` and `prediction_positions` report bets, results and what is currently at stake; with an operator key they cover all of your agents. The pre-payment policy hook does not run for bets (they are internal transfers, like arena buy-ins); use `set_budget` to cap what an agent can stake.
158
+ `prediction_markets` lists markets with `odds_model`: `fixed_odds` markets are a house book where your price locks at placement (read `yes_multiplier` or `no_multiplier`, the payout per sat staked, and `line_version` from `prediction_market`, and pass `expected_odds_pct = round(100 / multiplier, 2)` and `expected_line_version`; if the line moves you get an `odds_changed` reply with the current price to confirm). Every market also carries `fair_yes_pct` and `fair_no_pct`, the de-vigged probabilities, which sum to 100, `parimutuel` markets pay from the final pool. `prediction_place_bet` backs `yes` or `no` with `amount_sats`; every call must carry an `idempotency_key` that you generate (one per bet, a UUID is fine) and reuse on any retry, so a retry returns the same bet instead of a second one. `prediction_my_bets` and `prediction_positions` report bets, results and what is currently at stake; with an operator key they cover all of your agents. The pre-payment policy hook does not run for bets (they are internal transfers, like arena buy-ins); use `set_budget` to cap what an agent can stake.
146
159
 
147
160
  ### Reporting, discovery and selling (v1.10)
148
161
 
@@ -216,6 +229,11 @@ Set `PRE_PAYMENT_HOOK_URL` and every outgoing payment (`pay_l402_api`, `pay_invo
216
229
  - Keep the recovery code offline. It is the only way back in if the key is lost.
217
230
  - Use agent keys with budgets for anything autonomous; the operator key can withdraw.
218
231
  - Verify webhook payloads: compare `X-Webhook-Signature` with the HMAC-SHA256 of the raw body under your webhook secret.
232
+ - Remote server: keys travel only in the `Authorization` header over HTTPS and are never stored or logged (logs carry a short hash). It refuses key-management tools, rate limits per IP and per key, caps request and response size, validates `Origin`, and only ever talks to the Lightning Faucet API. Operators of the server can switch off every money-moving tool at once.
233
+
234
+ ### Self-hosting the remote server
235
+
236
+ `lightning-wallet-mcp-http` (`dist/http-server.js`) runs the Streamable HTTP server. It listens on `LF_MCP_HOST:LF_MCP_PORT` (default `127.0.0.1:8893`) and is meant to sit behind a reverse proxy that sets `X-Real-IP`. Main settings: `LIGHTNING_WALLET_API_URL`, `LF_MCP_TRUSTED_PROXIES`, `LF_MCP_ALLOWED_HOSTS`, `LF_MCP_ALLOWED_ORIGINS`, `LF_MCP_MAX_BODY_BYTES`, the `LF_MCP_RL_*` rate limits, and the kill switch (`LF_MCP_KILL_SWITCH=1`, or create the file named by `LF_MCP_KILL_SWITCH_FILE`). `GET /health` reports status. `PRE_PAYMENT_HOOK_URL` must not be set on a shared server.
219
237
 
220
238
  ## Architecture
221
239
 
@@ -231,6 +249,14 @@ Payments always execute through an agent wallet on the backend, which is where b
231
249
 
232
250
  ## Changelog
233
251
 
252
+ ### v1.11.1 (2026-10-24)
253
+
254
+ Prediction market tool descriptions and the README now point agents at `fair_yes_pct` / `fair_no_pct` for probabilities and at `yes_multiplier` / `no_multiplier` for payouts. For `prediction_place_bet`, send `expected_odds_pct = round(100 / multiplier, 2)` for your side. No tool signatures changed.
255
+
256
+ ### v1.11.0 (2026-10-24)
257
+
258
+ Remote MCP server: the same tools over stateless Streamable HTTP at `https://lightningfaucet.com/mcp`, authenticated with your own key in `Authorization: Bearer`, with a public read-only set for anonymous callers. Tool definitions and handlers moved to a shared module (`src/tools.ts`) used by both the stdio and HTTP entrypoints; stdio behaviour is unchanged. `server.json` now lists the remote.
259
+
234
260
  ### v1.10.0 (2026-10-24)
235
261
 
236
262
  Eighteen tools: `export_transactions`, `get_agent_analytics`, `link_user`, `list_l402_apis`, `verify_fair`, `pickem_browse`, `tournament_list`, `gateway_catalog`, `get_spend_policy`, `set_spend_policy` and the seller set `gateway_probe`, `gateway_register`, `gateway_list`, `gateway_update`, `gateway_pause`, `gateway_resume`, `gateway_delete`, `gateway_stats`. Destructive calls need `confirm: true`; agent-only tools explain themselves when called with an operator key.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ /**
5
+ * Lightning Wallet remote MCP server (Streamable HTTP, stateless, JSON responses).
6
+ *
7
+ * Serves the same tools as the stdio package at POST /mcp so hosted AI apps and agent
8
+ * frameworks can use Lightning Faucet without installing anything. Phase 1 auth: the
9
+ * caller's own operator (lf_...) or agent (agent_...) key in "Authorization: Bearer".
10
+ * The server stores no keys; every request builds a fresh, request-scoped session.
11
+ *
12
+ * Security model:
13
+ * - No key: only a small public read-only tool set (remote-policy ANON_TOOLS).
14
+ * - Key-management tools that need a local credentials file are stdio only.
15
+ * - Kill switch (LF_MCP_KILL_SWITCH=1, or the LF_MCP_KILL_SWITCH_FILE path existing)
16
+ * refuses every money-moving tool immediately, no restart needed for the file form.
17
+ * - Outbound requests are pinned to the Lightning Faucet API (no SSRF), and the real
18
+ * caller address is forwarded so backend per-IP limits keep working.
19
+ * - Body cap, result cap, per-IP and per-key rate limits, Origin and Host validation.
20
+ * - Logs carry tool name, a key hash prefix and the amount. Never a key.
21
+ *
22
+ * Env:
23
+ * LF_MCP_HOST (127.0.0.1) LF_MCP_PORT (8893)
24
+ * LIGHTNING_WALLET_API_URL agent API endpoint (default https://lightningfaucet.com/ai-agents/api)
25
+ * LF_MCP_TRUSTED_PROXIES peers whose X-Real-IP / X-Forwarded-For are trusted (127.0.0.1,::1)
26
+ * LF_MCP_ALLOWED_HOSTS accepted Host headers (lightningfaucet.com,www.lightningfaucet.com,localhost,127.0.0.1)
27
+ * LF_MCP_ALLOWED_ORIGINS browser origins allowed (https://claude.ai,https://chatgpt.com,...)
28
+ * LF_MCP_MAX_BODY_BYTES (65536) LF_MCP_MAX_INFLIGHT (200)
29
+ * LF_MCP_RL_IP_PER_MIN (120) LF_MCP_RL_ANON_TOOLS_PER_MIN (20) LF_MCP_RL_KEY_TOOLS_PER_MIN (60)
30
+ * LF_MCP_RL_MONEY_PER_MIN (10)
31
+ * LF_MCP_KILL_SWITCH (0|1) LF_MCP_KILL_SWITCH_FILE (unset)
32
+ */
33
+ const node_async_hooks_1 = require("node:async_hooks");
34
+ const node_fs_1 = require("node:fs");
35
+ const node_http_1 = require("node:http");
36
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
37
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
38
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
39
+ const lightning_faucet_js_1 = require("./lightning-faucet.js");
40
+ const tools_js_1 = require("./tools.js");
41
+ const remote_policy_js_1 = require("./remote-policy.js");
42
+ const PKG_VERSION = (() => {
43
+ try {
44
+ return require('../package.json').version;
45
+ }
46
+ catch {
47
+ return '0.0.0';
48
+ }
49
+ })();
50
+ const env = process.env;
51
+ const num = (v, d) => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : d);
52
+ const list = (v, d) => new Set((v ?? d).split(',').map((s) => s.trim()).filter(Boolean));
53
+ const HOST = env.LF_MCP_HOST || '127.0.0.1';
54
+ const PORT = num(env.LF_MCP_PORT, 8893);
55
+ const MAX_BODY_BYTES = num(env.LF_MCP_MAX_BODY_BYTES, 65_536);
56
+ const MAX_INFLIGHT = num(env.LF_MCP_MAX_INFLIGHT, 200);
57
+ const RL_IP_PER_MIN = num(env.LF_MCP_RL_IP_PER_MIN, 120);
58
+ const RL_ANON_TOOLS_PER_MIN = num(env.LF_MCP_RL_ANON_TOOLS_PER_MIN, 20);
59
+ const RL_KEY_TOOLS_PER_MIN = num(env.LF_MCP_RL_KEY_TOOLS_PER_MIN, 60);
60
+ const RL_MONEY_PER_MIN = num(env.LF_MCP_RL_MONEY_PER_MIN, 10);
61
+ const TRUSTED_PROXIES = list(env.LF_MCP_TRUSTED_PROXIES, '127.0.0.1,::1');
62
+ const ALLOWED_HOSTS = list(env.LF_MCP_ALLOWED_HOSTS, 'lightningfaucet.com,www.lightningfaucet.com,localhost,127.0.0.1');
63
+ const ALLOWED_ORIGINS = list(env.LF_MCP_ALLOWED_ORIGINS, 'https://claude.ai,https://chatgpt.com,https://chat.openai.com,https://lightningfaucet.com');
64
+ const KILL_SWITCH_FILE = env.LF_MCP_KILL_SWITCH_FILE || '';
65
+ function killSwitchOn() {
66
+ if (env.LF_MCP_KILL_SWITCH === '1' || env.LF_MCP_KILL_SWITCH === 'true')
67
+ return true;
68
+ return KILL_SWITCH_FILE !== '' && (0, node_fs_1.existsSync)(KILL_SWITCH_FILE);
69
+ }
70
+ // ─── Outbound pinning (no SSRF) and caller-address forwarding ───────────
71
+ const API_URL = env.LIGHTNING_WALLET_API_URL || 'https://lightningfaucet.com/ai-agents/api';
72
+ const PLATFORM_URL = env.LIGHTNING_WALLET_PLATFORM_URL || 'https://lightningfaucet.com/api/';
73
+ const L402_WELL_KNOWN = 'https://lightningfaucet.com/.well-known/l402.json';
74
+ /** Backends the agent API may live on: the public site, or the LAN backend Apache itself proxies to. */
75
+ const API_HOSTS = new Set(['lightningfaucet.com', '192.168.1.29', '127.0.0.1']);
76
+ function assertConfig() {
77
+ const api = new URL(API_URL);
78
+ if (!API_HOSTS.has(api.hostname))
79
+ throw new Error(`LIGHTNING_WALLET_API_URL host ${api.hostname} is not an allowed Lightning Faucet backend`);
80
+ if (new URL(PLATFORM_URL).hostname !== 'lightningfaucet.com')
81
+ throw new Error('LIGHTNING_WALLET_PLATFORM_URL must be on lightningfaucet.com');
82
+ // The pre-payment hook POSTs payment proposals to an arbitrary URL: fine for a local
83
+ // install, never for a shared server.
84
+ if (env.PRE_PAYMENT_HOOK_URL)
85
+ throw new Error('PRE_PAYMENT_HOOK_URL must not be set on the remote server');
86
+ }
87
+ const requestContext = new node_async_hooks_1.AsyncLocalStorage();
88
+ const realFetch = globalThis.fetch.bind(globalThis);
89
+ function sameEndpoint(a, b) {
90
+ const x = new URL(a), y = new URL(b);
91
+ return x.origin === y.origin && x.pathname.replace(/\/+$/, '') === y.pathname.replace(/\/+$/, '');
92
+ }
93
+ /**
94
+ * Every outbound request from tool code passes through here. Anything that is not one of the
95
+ * three Lightning Faucet endpoints is refused, and agent API calls carry the caller's address
96
+ * (both header and body, mirroring what lightningfaucet.com/ai-agents/api.php sends).
97
+ */
98
+ const guardedFetch = async (input, init) => {
99
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
100
+ if (sameEndpoint(url, L402_WELL_KNOWN) || sameEndpoint(url, PLATFORM_URL)) {
101
+ return realFetch(input, init);
102
+ }
103
+ if (!sameEndpoint(url, API_URL)) {
104
+ throw new Error('Outbound request blocked: the remote server only talks to the Lightning Faucet API');
105
+ }
106
+ const ip = requestContext.getStore()?.clientIp ?? null;
107
+ if (!ip || (0, remote_policy_js_1.isPrivateIp)(ip)) {
108
+ // Never vouch for a private address to the backend (it trusts this host as a proxy).
109
+ throw new Error('Could not determine the caller address for this request');
110
+ }
111
+ const headers = new Headers(init?.headers);
112
+ headers.set('X-Forwarded-For', ip);
113
+ headers.set('User-Agent', `lightning-wallet-mcp-remote/${PKG_VERSION}`);
114
+ let body = init?.body;
115
+ if (typeof body === 'string' && body.length > 0) {
116
+ const parsed = JSON.parse(body);
117
+ parsed.ip_address = ip;
118
+ body = JSON.stringify(parsed);
119
+ }
120
+ return realFetch(url, { ...init, headers, body });
121
+ };
122
+ globalThis.fetch = guardedFetch;
123
+ // ─── Per-request session (no persistence) ───────────────────────────────
124
+ const NO_STORE = {
125
+ saveOperatorKey: () => null,
126
+ saveAgentKey: () => null,
127
+ forgetCredentials: () => false,
128
+ describeCredentials: () => ({ persistence: 'none', mode: 'remote', note: 'The remote server never stores keys. Send yours on every request as Authorization: Bearer <key>.' }),
129
+ credentialsPath: () => '',
130
+ };
131
+ class RequestSession {
132
+ keySource;
133
+ client;
134
+ constructor(key) {
135
+ this.client = key ? new lightning_faucet_js_1.LightningFaucetClient(key) : null;
136
+ this.keySource = key ? 'header' : 'none';
137
+ }
138
+ getClient() { return this.client; }
139
+ // rotate_api_key swaps to the new key; that only lives for the rest of this request.
140
+ setClient(c) { this.client = c; }
141
+ requireClient() {
142
+ if (!this.client)
143
+ throw new Error('This tool needs an API key. Send "Authorization: Bearer <lf_ or agent_ key>". Get one at https://lightningfaucet.com/ai-agents/.');
144
+ return this.client;
145
+ }
146
+ }
147
+ // ─── Logging ────────────────────────────────────────────────────────────
148
+ function log(fields) {
149
+ process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), ...fields }) + '\n');
150
+ }
151
+ // ─── MCP server per request ─────────────────────────────────────────────
152
+ const REMOTE_TOOLS = tools_js_1.TOOL_DEFINITIONS.filter((t) => !remote_policy_js_1.STDIO_ONLY_TOOLS.has(t.name));
153
+ const INSTRUCTIONS = [
154
+ 'Lightning Faucet: a Bitcoin Lightning wallet for AI agents (payments, L402 APIs, prediction markets, Agent Arena).',
155
+ 'Without a key you can use the public read-only tools: ' + [...remote_policy_js_1.ANON_TOOLS].join(', ') + '.',
156
+ 'Everything else needs your own key in the HTTP header "Authorization: Bearer <key>": an operator key (lf_...) or an agent key (agent_...). Agent keys are safer: they carry a budget and spend limits.',
157
+ 'Get a key at https://lightningfaucet.com/ai-agents/. The server never stores keys. Bets need an idempotency_key; pending payments must not be retried.',
158
+ ].join(' ');
159
+ const limiter = new remote_policy_js_1.RateLimiter();
160
+ setInterval(() => limiter.sweep(), 60_000).unref();
161
+ function textError(error, message, extra = {}) {
162
+ return { content: [{ type: 'text', text: JSON.stringify({ success: false, error, message, ...extra }, null, 2) }], isError: true };
163
+ }
164
+ function buildMcpServer(ctx) {
165
+ const server = new index_js_1.Server({ name: 'lightning-wallet', version: PKG_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
166
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: REMOTE_TOOLS }));
167
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
168
+ const { name, arguments: args } = request.params;
169
+ const started = Date.now();
170
+ const amount = (0, remote_policy_js_1.amountForLog)(name, args);
171
+ const base = { evt: 'tool', tool: name, auth: ctx.tag, ip: ctx.clientIp, amount_sats: amount };
172
+ const decision = (0, remote_policy_js_1.decideTool)(name, { authenticated: ctx.key !== null, killSwitch: killSwitchOn() });
173
+ if (!decision.allow) {
174
+ log({ ...base, outcome: decision.error });
175
+ return textError(decision.error, decision.message);
176
+ }
177
+ const retry = ctx.key
178
+ ? limiter.hit(`key:${ctx.tag}`, RL_KEY_TOOLS_PER_MIN, 60_000) || (decision.money ? limiter.hit(`money:${ctx.tag}`, RL_MONEY_PER_MIN, 60_000) : 0)
179
+ : limiter.hit(`anon:${ctx.clientIp}`, RL_ANON_TOOLS_PER_MIN, 60_000);
180
+ if (retry) {
181
+ log({ ...base, outcome: 'rate_limited' });
182
+ return textError('rate_limited', `Too many tool calls. Wait ${retry}s and try again.`, { retry_after_seconds: retry });
183
+ }
184
+ const session = new RequestSession(ctx.key);
185
+ const result = await requestContext.run({ clientIp: ctx.clientIp }, () => (0, tools_js_1.callTool)(name, args, { session, creds: NO_STORE }));
186
+ log({ ...base, outcome: result.isError ? 'error' : 'ok', ms: Date.now() - started });
187
+ return (0, remote_policy_js_1.capResult)(result);
188
+ });
189
+ return server;
190
+ }
191
+ // ─── HTTP plumbing ──────────────────────────────────────────────────────
192
+ function sendJson(res, status, body, headers = {}) {
193
+ if (res.headersSent)
194
+ return;
195
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers });
196
+ res.end(JSON.stringify(body));
197
+ }
198
+ function rpcError(res, status, code, message, headers = {}) {
199
+ sendJson(res, status, { jsonrpc: '2.0', error: { code, message }, id: null }, headers);
200
+ }
201
+ function readBody(req, limit) {
202
+ return new Promise((resolve, reject) => {
203
+ const declared = Number(req.headers['content-length']);
204
+ if (Number.isFinite(declared) && declared > limit) {
205
+ resolve(null);
206
+ req.resume();
207
+ return;
208
+ }
209
+ const chunks = [];
210
+ let size = 0;
211
+ let over = false;
212
+ req.on('data', (c) => {
213
+ if (over)
214
+ return;
215
+ size += c.length;
216
+ if (size > limit) {
217
+ over = true;
218
+ resolve(null);
219
+ return;
220
+ }
221
+ chunks.push(c);
222
+ });
223
+ req.on('end', () => { if (!over)
224
+ resolve(Buffer.concat(chunks)); });
225
+ req.on('error', reject);
226
+ });
227
+ }
228
+ function corsHeaders(origin) {
229
+ if (!origin)
230
+ return {};
231
+ return {
232
+ 'Access-Control-Allow-Origin': origin,
233
+ 'Vary': 'Origin',
234
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
235
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Mcp-Protocol-Version, Mcp-Session-Id, Last-Event-ID',
236
+ 'Access-Control-Max-Age': '600',
237
+ };
238
+ }
239
+ let inflight = 0;
240
+ async function handle(req, res) {
241
+ const path = (req.url || '/').split('?')[0].replace(/\/+$/, '') || '/';
242
+ const clientIp = (0, remote_policy_js_1.resolveClientIp)(req.socket.remoteAddress, req.headers, TRUSTED_PROXIES);
243
+ if (path === '/health' || path === '/mcp/health') {
244
+ sendJson(res, 200, {
245
+ ok: true,
246
+ name: 'lightning-wallet-mcp-remote',
247
+ version: PKG_VERSION,
248
+ transport: 'streamable-http',
249
+ money_tools_enabled: !killSwitchOn(),
250
+ tools: REMOTE_TOOLS.length,
251
+ anonymous_tools: remote_policy_js_1.ANON_TOOLS.size,
252
+ money_tools: remote_policy_js_1.MONEY_TOOLS.size,
253
+ });
254
+ return;
255
+ }
256
+ if (path !== '/mcp') {
257
+ sendJson(res, 404, { error: 'not_found' });
258
+ return;
259
+ }
260
+ // DNS-rebinding protection (MCP spec): validate Host and Origin before anything else.
261
+ if (!(0, remote_policy_js_1.hostAllowed)(req.headers.host, ALLOWED_HOSTS)) {
262
+ rpcError(res, 403, -32000, 'Host not allowed');
263
+ return;
264
+ }
265
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
266
+ if (origin !== undefined && !(0, remote_policy_js_1.originAllowed)(origin, ALLOWED_ORIGINS)) {
267
+ rpcError(res, 403, -32000, 'Origin not allowed');
268
+ return;
269
+ }
270
+ const cors = corsHeaders(origin);
271
+ if (req.method === 'OPTIONS') {
272
+ res.writeHead(204, cors);
273
+ res.end();
274
+ return;
275
+ }
276
+ if (req.method !== 'POST') {
277
+ // Stateless server: no standalone SSE stream (GET) and no sessions to delete (DELETE).
278
+ rpcError(res, 405, -32000, 'Method not allowed. This is a stateless Streamable HTTP MCP endpoint: POST JSON-RPC to /mcp.', { Allow: 'POST, OPTIONS', ...cors });
279
+ return;
280
+ }
281
+ const ipRetry = limiter.hit(`ip:${clientIp}`, RL_IP_PER_MIN, 60_000);
282
+ if (ipRetry) {
283
+ rpcError(res, 429, -32000, `Rate limit exceeded. Retry in ${ipRetry}s.`, { 'Retry-After': String(ipRetry), ...cors });
284
+ return;
285
+ }
286
+ const bearer = (0, remote_policy_js_1.parseBearer)(req.headers.authorization);
287
+ if (bearer.kind === 'invalid') {
288
+ log({ evt: 'auth', outcome: 'malformed_key', ip: clientIp });
289
+ rpcError(res, 401, -32001, 'Malformed Authorization header. Use "Authorization: Bearer <key>" with an operator key (lf_...) or agent key (agent_...), or omit it for public read-only tools.', cors);
290
+ return;
291
+ }
292
+ const key = bearer.kind === 'key' ? bearer.key : null;
293
+ const tag = key ? `key:${(0, remote_policy_js_1.keyTag)(key)}` : 'anon';
294
+ if (inflight >= MAX_INFLIGHT) {
295
+ rpcError(res, 503, -32000, 'Server busy, retry shortly.', { 'Retry-After': '5', ...cors });
296
+ return;
297
+ }
298
+ inflight++;
299
+ try {
300
+ const raw = await readBody(req, MAX_BODY_BYTES);
301
+ if (raw === null) {
302
+ rpcError(res, 413, -32600, `Request body larger than ${MAX_BODY_BYTES} bytes`, cors);
303
+ return;
304
+ }
305
+ let body;
306
+ try {
307
+ body = JSON.parse(raw.toString('utf8'));
308
+ }
309
+ catch {
310
+ rpcError(res, 400, -32700, 'Parse error: body must be JSON', cors);
311
+ return;
312
+ }
313
+ for (const [k, v] of Object.entries(cors))
314
+ res.setHeader(k, v);
315
+ res.setHeader('Cache-Control', 'no-store');
316
+ const server = buildMcpServer({ key, tag, clientIp });
317
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
318
+ res.on('close', () => { void transport.close(); void server.close(); });
319
+ await server.connect(transport);
320
+ await transport.handleRequest(req, res, body);
321
+ }
322
+ finally {
323
+ inflight--;
324
+ }
325
+ }
326
+ function main() {
327
+ assertConfig();
328
+ const httpServer = (0, node_http_1.createServer)((req, res) => {
329
+ handle(req, res).catch((err) => {
330
+ log({ evt: 'http_error', message: err instanceof Error ? err.message.slice(0, 200) : 'unknown' });
331
+ rpcError(res, 500, -32603, 'Internal server error');
332
+ });
333
+ });
334
+ httpServer.requestTimeout = 90_000;
335
+ httpServer.headersTimeout = 15_000;
336
+ httpServer.keepAliveTimeout = 5_000;
337
+ httpServer.listen(PORT, HOST, () => {
338
+ log({ evt: 'start', version: PKG_VERSION, host: HOST, port: PORT, api: new URL(API_URL).host, tools: REMOTE_TOOLS.length, money_tools_enabled: !killSwitchOn() });
339
+ });
340
+ const stop = () => httpServer.close(() => process.exit(0));
341
+ process.on('SIGTERM', stop);
342
+ process.on('SIGINT', stop);
343
+ }
344
+ main();