mercury402-mcp 0.1.0 → 0.1.2
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 +20 -6
- package/dist/config.js +23 -0
- package/dist/http.js +228 -0
- package/dist/index.js +23 -43
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,8 +129,15 @@ claude mcp add mercury402 -- node /absolute/path/to/mercury402/mcp-server/dist/i
|
|
|
129
129
|
npx -y mercury402-mcp --http # http://127.0.0.1:3402/mcp (stateless, JSON responses)
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
Stateless streamable HTTP: `POST /mcp` speaks MCP, `GET /` describes the server, and `GET /healthz` is a local
|
|
133
|
+
health check. The HTTP endpoint has no authentication. It binds to `127.0.0.1` by default, checks the `Host`
|
|
134
|
+
header against an allowlist (loopback names plus `MCP_HTTP_ALLOWED_HOSTS`), rate-limits per client IP and
|
|
135
|
+
globally, and caps request bodies.
|
|
136
|
+
|
|
137
|
+
Paid mode over HTTP is loopback-only. The server refuses to start with `MERCURY402_PAYER_PRIVATE_KEY` set
|
|
138
|
+
together with a non-loopback `MCP_HTTP_HOST`, a public `MCP_HTTP_ALLOWED_HOSTS` entry, `MCP_HTTP_TRUST_PROXY`,
|
|
139
|
+
or `MCP_PUBLIC_URL`. In paid mode it also rejects browser requests (any `Origin` header). To serve it publicly
|
|
140
|
+
behind a reverse proxy in free mode, see [`deploy/HOSTED.md`](./deploy/HOSTED.md).
|
|
134
141
|
|
|
135
142
|
## Environment variables
|
|
136
143
|
|
|
@@ -141,6 +148,12 @@ in paid mode on a non-loopback `MCP_HTTP_HOST`.
|
|
|
141
148
|
| `MERCURY402_API_URL` | `https://api.mercury402.com` | API base URL (`https://mercury402.uk` also works) |
|
|
142
149
|
| `MERCURY402_TIMEOUT_MS` | `60000` | Per-request timeout (paid calls wait for on-chain settlement) |
|
|
143
150
|
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `127.0.0.1` / `3402` | HTTP transport bind address (only with `--http`) |
|
|
151
|
+
| `MCP_HTTP_ALLOWED_HOSTS` | unset | Comma-separated extra `Host` header names to accept (e.g. `mcp.example.com` behind a proxy). Loopback names are always accepted; anything else gets 403 |
|
|
152
|
+
| `MCP_HTTP_TRUST_PROXY` | `false` | Rate-limit on Cloudflare's `CF-Connecting-IP` instead of the socket address. Enable only behind cloudflared/Cloudflare: the header is spoofable otherwise. `X-Forwarded-For` is never used |
|
|
153
|
+
| `MCP_HTTP_RATE_LIMIT_PER_MIN` | `60` | `POST /mcp` requests per client IP per minute (`0` disables) |
|
|
154
|
+
| `MCP_HTTP_GLOBAL_RATE_LIMIT_PER_MIN` | `600` | `POST /mcp` requests per minute across all clients (`0` disables) |
|
|
155
|
+
| `MCP_HTTP_MAX_BODY_BYTES` | `65536` | Largest accepted request body; larger gets 413 |
|
|
156
|
+
| `MCP_PUBLIC_URL` | unset | Public `/mcp` URL that `GET /` advertises. Setting it marks the server as public, so paid mode refuses to start |
|
|
144
157
|
|
|
145
158
|
## Example tool calls
|
|
146
159
|
|
|
@@ -236,7 +249,8 @@ npm run generate:catalog # regenerate src/catalog.json after changing ../src/
|
|
|
236
249
|
npm pack --dry-run # inspect the publish tarball
|
|
237
250
|
```
|
|
238
251
|
|
|
239
|
-
The endpoint catalog (`src/catalog.json`) is generated from the
|
|
240
|
-
`src/
|
|
241
|
-
drifts from `src/pricing.js`. `tsc` bakes the catalog into `dist/catalog.json`, so the
|
|
242
|
-
reads the repo at runtime; `prepublishOnly` regenerates the catalog, rebuilds, and runs the
|
|
252
|
+
The endpoint catalog (`src/catalog.json`) is generated from `../src/catalog.js`, the same module that builds the
|
|
253
|
+
API's JSON manifest and `/llms.txt` (it reads `src/pricing.js`, `src/new-routes.js` and `src/ai-routes.js`). Tests
|
|
254
|
+
in both packages fail if it drifts from `src/pricing.js`. `tsc` bakes the catalog into `dist/catalog.json`, so the
|
|
255
|
+
published package never reads the repo at runtime; `prepublishOnly` regenerates the catalog, rebuilds, and runs the
|
|
256
|
+
tests.
|
package/dist/config.js
CHANGED
|
@@ -7,8 +7,25 @@ function num(value, fallback, name) {
|
|
|
7
7
|
throw new Error(`${name} must be a non-negative number`);
|
|
8
8
|
return n;
|
|
9
9
|
}
|
|
10
|
+
function bool(value, fallback, name) {
|
|
11
|
+
if (value === undefined || value.trim() === '')
|
|
12
|
+
return fallback;
|
|
13
|
+
const v = value.trim().toLowerCase();
|
|
14
|
+
if (['1', 'true', 'yes'].includes(v))
|
|
15
|
+
return true;
|
|
16
|
+
if (['0', 'false', 'no'].includes(v))
|
|
17
|
+
return false;
|
|
18
|
+
throw new Error(`${name} must be true or false`);
|
|
19
|
+
}
|
|
20
|
+
function list(value) {
|
|
21
|
+
return (value ?? '')
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((s) => s.trim().toLowerCase())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
}
|
|
10
26
|
export function loadConfig(env = process.env) {
|
|
11
27
|
const key = env.MERCURY402_PAYER_PRIVATE_KEY?.trim();
|
|
28
|
+
const publicUrl = env.MCP_PUBLIC_URL?.trim();
|
|
12
29
|
return {
|
|
13
30
|
apiUrl: (env.MERCURY402_API_URL?.trim() || DEFAULT_API_URL).replace(/\/+$/, ''),
|
|
14
31
|
payerPrivateKey: key ? key : undefined,
|
|
@@ -16,5 +33,11 @@ export function loadConfig(env = process.env) {
|
|
|
16
33
|
timeoutMs: num(env.MERCURY402_TIMEOUT_MS, 60_000, 'MERCURY402_TIMEOUT_MS'),
|
|
17
34
|
httpHost: env.MCP_HTTP_HOST?.trim() || '127.0.0.1',
|
|
18
35
|
httpPort: num(env.MCP_HTTP_PORT, 3402, 'MCP_HTTP_PORT'),
|
|
36
|
+
httpAllowedHosts: list(env.MCP_HTTP_ALLOWED_HOSTS),
|
|
37
|
+
httpTrustProxy: bool(env.MCP_HTTP_TRUST_PROXY, false, 'MCP_HTTP_TRUST_PROXY'),
|
|
38
|
+
httpRateLimitPerMin: num(env.MCP_HTTP_RATE_LIMIT_PER_MIN, 60, 'MCP_HTTP_RATE_LIMIT_PER_MIN'),
|
|
39
|
+
httpGlobalRateLimitPerMin: num(env.MCP_HTTP_GLOBAL_RATE_LIMIT_PER_MIN, 600, 'MCP_HTTP_GLOBAL_RATE_LIMIT_PER_MIN'),
|
|
40
|
+
httpMaxBodyBytes: num(env.MCP_HTTP_MAX_BODY_BYTES, 64 * 1024, 'MCP_HTTP_MAX_BODY_BYTES'),
|
|
41
|
+
publicUrl: publicUrl ? publicUrl : undefined,
|
|
19
42
|
};
|
|
20
43
|
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { CATALOG } from './catalog.js';
|
|
4
|
+
import { createMercuryServer, SERVER_NAME, SERVER_VERSION } from './server.js';
|
|
5
|
+
// Streamable HTTP transport, stateless: a fresh McpServer + transport per POST /mcp.
|
|
6
|
+
// Built to sit behind a reverse proxy (cloudflared) as a public, free-mode endpoint:
|
|
7
|
+
// Host allowlist (DNS rebinding), per-IP + global rate limits, a body cap, and CORS
|
|
8
|
+
// for browser-based MCP clients. Paid mode is loopback-only and refuses browsers.
|
|
9
|
+
/** Bind addresses only the local machine can reach. */
|
|
10
|
+
const LOOPBACK_BINDS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
11
|
+
/** Host header names that mean "reached over loopback"; always accepted. */
|
|
12
|
+
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
13
|
+
/** Tools registered by createMercuryServer(), advertised by GET /. Tested against tools/list. */
|
|
14
|
+
export const TOOL_NAMES = ['list_endpoints', 'get_endpoint_data'];
|
|
15
|
+
const CORS_HEADERS = {
|
|
16
|
+
'Access-Control-Allow-Origin': '*',
|
|
17
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
18
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID',
|
|
19
|
+
'Access-Control-Expose-Headers': 'Mcp-Session-Id, Mcp-Protocol-Version',
|
|
20
|
+
'Access-Control-Max-Age': '86400',
|
|
21
|
+
};
|
|
22
|
+
/** Lowercased hostname of a Host header, without the port ("[::1]:3402" -> "[::1]"). */
|
|
23
|
+
export function hostnameOf(hostHeader) {
|
|
24
|
+
const h = (hostHeader ?? '').trim().toLowerCase();
|
|
25
|
+
if (h.startsWith('[')) {
|
|
26
|
+
const end = h.indexOf(']');
|
|
27
|
+
return end === -1 ? h : h.slice(0, end + 1);
|
|
28
|
+
}
|
|
29
|
+
return h.split(':')[0];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Paid mode signs payments from a hot wallet, and HTTP has no auth: anyone who can
|
|
33
|
+
* reach the endpoint could spend it. So paid mode is allowed only on a loopback bind
|
|
34
|
+
* that nothing public points at.
|
|
35
|
+
*/
|
|
36
|
+
export function assertSafeHttpConfig(config, paid = !!config.payerPrivateKey) {
|
|
37
|
+
if (!paid)
|
|
38
|
+
return;
|
|
39
|
+
if (!LOOPBACK_BINDS.has(config.httpHost)) {
|
|
40
|
+
throw new Error('Refusing to serve paid mode over HTTP on a non-loopback host (MCP_HTTP_HOST). Use stdio or 127.0.0.1.');
|
|
41
|
+
}
|
|
42
|
+
const publicHost = config.httpAllowedHosts.map(hostnameOf).find((h) => !LOOPBACK_HOSTNAMES.includes(h));
|
|
43
|
+
if (publicHost) {
|
|
44
|
+
throw new Error(`Refusing to serve paid mode with a public MCP_HTTP_ALLOWED_HOSTS entry (${publicHost}): anyone who can reach it could spend the payer wallet.`);
|
|
45
|
+
}
|
|
46
|
+
if (config.httpTrustProxy) {
|
|
47
|
+
throw new Error('Refusing to serve paid mode with MCP_HTTP_TRUST_PROXY: a proxy in front of the server means others can reach it.');
|
|
48
|
+
}
|
|
49
|
+
if (config.publicUrl) {
|
|
50
|
+
throw new Error('Refusing to serve paid mode with MCP_PUBLIC_URL set: a public endpoint must run in free mode.');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Fixed-window counter per key. take() returns 0 when allowed, else seconds until the window resets. */
|
|
54
|
+
export class FixedWindowLimiter {
|
|
55
|
+
limit;
|
|
56
|
+
windowMs;
|
|
57
|
+
now;
|
|
58
|
+
maxKeys;
|
|
59
|
+
hits = new Map();
|
|
60
|
+
constructor(limit, windowMs = 60_000, now = Date.now, maxKeys = 10_000) {
|
|
61
|
+
this.limit = limit;
|
|
62
|
+
this.windowMs = windowMs;
|
|
63
|
+
this.now = now;
|
|
64
|
+
this.maxKeys = maxKeys;
|
|
65
|
+
}
|
|
66
|
+
take(key) {
|
|
67
|
+
if (this.limit <= 0)
|
|
68
|
+
return 0;
|
|
69
|
+
const t = this.now();
|
|
70
|
+
let entry = this.hits.get(key);
|
|
71
|
+
if (!entry || t - entry.start >= this.windowMs) {
|
|
72
|
+
if (!entry && this.hits.size >= this.maxKeys)
|
|
73
|
+
this.prune(t);
|
|
74
|
+
entry = { start: t, count: 0 };
|
|
75
|
+
this.hits.set(key, entry);
|
|
76
|
+
}
|
|
77
|
+
if (entry.count >= this.limit)
|
|
78
|
+
return Math.max(1, Math.ceil((entry.start + this.windowMs - t) / 1000));
|
|
79
|
+
entry.count++;
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
prune(t) {
|
|
83
|
+
for (const [k, e] of this.hits)
|
|
84
|
+
if (t - e.start >= this.windowMs)
|
|
85
|
+
this.hits.delete(k);
|
|
86
|
+
// Still full of live windows: drop them rather than grow without bound. The global limiter still applies.
|
|
87
|
+
if (this.hits.size >= this.maxKeys)
|
|
88
|
+
this.hits.clear();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Client IP for rate limiting. Behind cloudflared every connection comes from 127.0.0.1,
|
|
93
|
+
* so with trustProxy the CF-Connecting-IP header (set by Cloudflare's edge) is used.
|
|
94
|
+
* X-Forwarded-For is deliberately ignored: its first entry is client-controlled.
|
|
95
|
+
*/
|
|
96
|
+
export function clientIp(req, trustProxy) {
|
|
97
|
+
if (trustProxy) {
|
|
98
|
+
const cf = req.headers['cf-connecting-ip'];
|
|
99
|
+
if (typeof cf === 'string' && cf.trim())
|
|
100
|
+
return cf.trim();
|
|
101
|
+
}
|
|
102
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
103
|
+
}
|
|
104
|
+
class BodyTooLargeError extends Error {
|
|
105
|
+
}
|
|
106
|
+
async function readBody(req, maxBytes) {
|
|
107
|
+
const declared = Number(req.headers['content-length']);
|
|
108
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
109
|
+
throw new BodyTooLargeError();
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let size = 0;
|
|
112
|
+
for await (const chunk of req) {
|
|
113
|
+
size += chunk.length;
|
|
114
|
+
if (size > maxBytes)
|
|
115
|
+
throw new BodyTooLargeError();
|
|
116
|
+
chunks.push(chunk);
|
|
117
|
+
}
|
|
118
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
119
|
+
}
|
|
120
|
+
function rpcError(code, message) {
|
|
121
|
+
return { jsonrpc: '2.0', error: { code, message }, id: null };
|
|
122
|
+
}
|
|
123
|
+
function send(res, status, body, opts = {}) {
|
|
124
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...opts.headers });
|
|
125
|
+
res.end(opts.head ? undefined : JSON.stringify(body));
|
|
126
|
+
}
|
|
127
|
+
export function createHttpServer(config, options = {}) {
|
|
128
|
+
const deps = options.deps ?? {};
|
|
129
|
+
const paid = !!(deps.payer ?? config.payerPrivateKey);
|
|
130
|
+
assertSafeHttpConfig(config, paid);
|
|
131
|
+
const log = options.log ?? (() => { });
|
|
132
|
+
const allowedHosts = new Set([...LOOPBACK_HOSTNAMES, ...config.httpAllowedHosts.map(hostnameOf)]);
|
|
133
|
+
const perIp = new FixedWindowLimiter(config.httpRateLimitPerMin, 60_000, options.now);
|
|
134
|
+
const global = new FixedWindowLimiter(config.httpGlobalRateLimitPerMin, 60_000, options.now);
|
|
135
|
+
const stats = { mcp_requests: 0, rate_limited: 0, rejected_host: 0, rejected_origin: 0, too_large: 0 };
|
|
136
|
+
const info = {
|
|
137
|
+
name: 'mercury402-mcp',
|
|
138
|
+
server: SERVER_NAME,
|
|
139
|
+
version: SERVER_VERSION,
|
|
140
|
+
description: 'MCP server for Mercury402: discover and call pay-per-call financial data endpoints (x402, USDC on Base).',
|
|
141
|
+
transport: 'streamable-http',
|
|
142
|
+
stateless: true,
|
|
143
|
+
mcp_endpoint: config.publicUrl ?? '/mcp',
|
|
144
|
+
paid_mode: paid,
|
|
145
|
+
tools: [...TOOL_NAMES],
|
|
146
|
+
endpoint_count: CATALOG.count,
|
|
147
|
+
api_url: config.apiUrl,
|
|
148
|
+
local_install: 'npx -y mercury402-mcp',
|
|
149
|
+
docs: 'https://github.com/dudman1/mercury402/tree/master/mcp-server#readme',
|
|
150
|
+
};
|
|
151
|
+
async function handleMcp(req, res) {
|
|
152
|
+
const wait = perIp.take(clientIp(req, config.httpTrustProxy)) || global.take('*');
|
|
153
|
+
if (wait > 0) {
|
|
154
|
+
stats.rate_limited++;
|
|
155
|
+
return send(res, 429, rpcError(-32000, 'Too many requests'), { headers: { 'Retry-After': String(wait) } });
|
|
156
|
+
}
|
|
157
|
+
let body;
|
|
158
|
+
try {
|
|
159
|
+
const raw = await readBody(req, config.httpMaxBodyBytes);
|
|
160
|
+
body = raw ? JSON.parse(raw) : undefined;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (err instanceof BodyTooLargeError) {
|
|
164
|
+
stats.too_large++;
|
|
165
|
+
return send(res, 413, rpcError(-32000, `Request body exceeds ${config.httpMaxBodyBytes} bytes`), { headers: { Connection: 'close' } });
|
|
166
|
+
}
|
|
167
|
+
return send(res, 400, rpcError(-32700, 'Parse error'));
|
|
168
|
+
}
|
|
169
|
+
stats.mcp_requests++;
|
|
170
|
+
const server = createMercuryServer(config, deps);
|
|
171
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
172
|
+
res.on('close', () => {
|
|
173
|
+
void transport.close();
|
|
174
|
+
void server.close();
|
|
175
|
+
});
|
|
176
|
+
await server.connect(transport);
|
|
177
|
+
await transport.handleRequest(req, res, body);
|
|
178
|
+
}
|
|
179
|
+
async function handle(req, res) {
|
|
180
|
+
const method = req.method ?? 'GET';
|
|
181
|
+
const path = (req.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/';
|
|
182
|
+
if (!allowedHosts.has(hostnameOf(req.headers.host))) {
|
|
183
|
+
stats.rejected_host++;
|
|
184
|
+
return send(res, 403, rpcError(-32000, 'Forbidden: Host not allowed'));
|
|
185
|
+
}
|
|
186
|
+
if (paid && req.headers.origin !== undefined) {
|
|
187
|
+
stats.rejected_origin++;
|
|
188
|
+
return send(res, 403, rpcError(-32000, 'Forbidden: browser (Origin) requests are refused in paid mode'));
|
|
189
|
+
}
|
|
190
|
+
if (!paid)
|
|
191
|
+
for (const [k, v] of Object.entries(CORS_HEADERS))
|
|
192
|
+
res.setHeader(k, v);
|
|
193
|
+
if (path === '/mcp') {
|
|
194
|
+
if (method === 'POST')
|
|
195
|
+
return handleMcp(req, res);
|
|
196
|
+
if (method === 'OPTIONS') {
|
|
197
|
+
res.writeHead(204).end();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
return send(res, 405, rpcError(-32000, 'Method not allowed: this server is stateless, use POST'), { headers: { Allow: 'POST, OPTIONS' } });
|
|
201
|
+
}
|
|
202
|
+
if (path === '/' || path === '/healthz') {
|
|
203
|
+
if (method === 'OPTIONS') {
|
|
204
|
+
res.writeHead(204).end();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
208
|
+
return send(res, 405, { error: 'method_not_allowed' }, { headers: { Allow: 'GET, HEAD, OPTIONS' } });
|
|
209
|
+
}
|
|
210
|
+
// /healthz never calls the upstream API: it reports this process only.
|
|
211
|
+
const body = path === '/' ? info : { status: 'ok', version: SERVER_VERSION, paid_mode: paid, endpoint_count: CATALOG.count, stats };
|
|
212
|
+
return send(res, 200, body, { head: method === 'HEAD' });
|
|
213
|
+
}
|
|
214
|
+
return send(res, 404, { error: 'not_found', mcp_endpoint: '/mcp' });
|
|
215
|
+
}
|
|
216
|
+
const server = createServer((req, res) => {
|
|
217
|
+
handle(req, res).catch((err) => {
|
|
218
|
+
log(`HTTP request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
219
|
+
if (!res.headersSent)
|
|
220
|
+
send(res, 500, rpcError(-32603, 'Internal error'));
|
|
221
|
+
else
|
|
222
|
+
res.end();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
server.headersTimeout = 15_000;
|
|
226
|
+
server.requestTimeout = config.timeoutMs + 30_000;
|
|
227
|
+
return { server, stats };
|
|
228
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,56 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createServer } from 'node:http';
|
|
3
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
3
|
import { loadConfig } from './config.js';
|
|
4
|
+
import { createHttpServer } from './http.js';
|
|
6
5
|
import { createMercuryServer } from './server.js';
|
|
7
6
|
// stdout carries the MCP protocol in stdio mode: log to stderr only, and never log config values.
|
|
8
7
|
function log(msg) {
|
|
9
8
|
process.stderr.write(`[mercury402-mcp] ${msg}\n`);
|
|
10
9
|
}
|
|
11
|
-
async function readJson(req) {
|
|
12
|
-
const chunks = [];
|
|
13
|
-
for await (const chunk of req)
|
|
14
|
-
chunks.push(chunk);
|
|
15
|
-
const raw = Buffer.concat(chunks).toString('utf8');
|
|
16
|
-
return raw ? JSON.parse(raw) : undefined;
|
|
17
|
-
}
|
|
18
|
-
// Stateless streamable HTTP: a fresh server + transport per request.
|
|
19
10
|
async function startHttp(config) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
const http = createServer(async (req, res) => {
|
|
26
|
-
if (!req.url?.startsWith('/mcp')) {
|
|
27
|
-
res.writeHead(404).end();
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
if (req.method !== 'POST') {
|
|
31
|
-
res.writeHead(405, { Allow: 'POST' }).end();
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
const body = await readJson(req);
|
|
36
|
-
const server = createMercuryServer(config);
|
|
37
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
38
|
-
res.on('close', () => {
|
|
39
|
-
void transport.close();
|
|
40
|
-
void server.close();
|
|
41
|
-
});
|
|
42
|
-
await server.connect(transport);
|
|
43
|
-
await transport.handleRequest(req, res, body);
|
|
44
|
-
}
|
|
45
|
-
catch (err) {
|
|
46
|
-
log(`HTTP request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
47
|
-
if (!res.headersSent)
|
|
48
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
49
|
-
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Bad request' }, id: null }));
|
|
50
|
-
}
|
|
11
|
+
const { server } = createHttpServer(config, { log });
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
server.once('error', reject);
|
|
14
|
+
server.listen(config.httpPort, config.httpHost, resolve);
|
|
51
15
|
});
|
|
52
|
-
|
|
53
|
-
|
|
16
|
+
const addr = server.address();
|
|
17
|
+
const port = typeof addr === 'object' && addr ? addr.port : config.httpPort;
|
|
18
|
+
const hosts = ['loopback', ...config.httpAllowedHosts].join(', ');
|
|
19
|
+
const perIp = config.httpRateLimitPerMin ? `${config.httpRateLimitPerMin}/min per IP${config.httpTrustProxy ? ' (CF-Connecting-IP)' : ''}` : 'no per-IP limit';
|
|
20
|
+
const total = config.httpGlobalRateLimitPerMin ? `${config.httpGlobalRateLimitPerMin}/min total` : 'no global limit';
|
|
21
|
+
log(`streamable HTTP listening on http://${config.httpHost}:${port}/mcp; hosts: ${hosts}; ${perIp}, ${total}`);
|
|
22
|
+
// PM2 wait_ready: report "online" only once the port is bound.
|
|
23
|
+
if (typeof process.send === 'function')
|
|
24
|
+
process.send('ready');
|
|
25
|
+
// PM2 6.x stops apps with SIGINT, not SIGTERM: handle both. Requests are short (stateless JSON).
|
|
26
|
+
const shutdown = (signal) => {
|
|
27
|
+
log(`${signal} received, closing HTTP server`);
|
|
28
|
+
server.close(() => process.exit(0));
|
|
29
|
+
server.closeIdleConnections();
|
|
30
|
+
setTimeout(() => process.exit(0), 5_000).unref();
|
|
31
|
+
};
|
|
32
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
33
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
54
34
|
}
|
|
55
35
|
async function main() {
|
|
56
36
|
const config = loadConfig();
|
package/dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { CATALOG, resolveRequest } from './catalog.js';
|
|
|
4
4
|
import { MercuryClient } from './client.js';
|
|
5
5
|
import { createEvmPayer } from './payment.js';
|
|
6
6
|
export const SERVER_NAME = 'mercury402';
|
|
7
|
-
export const SERVER_VERSION = '0.1.
|
|
7
|
+
export const SERVER_VERSION = '0.1.2';
|
|
8
8
|
function json(value) {
|
|
9
9
|
return JSON.stringify(value, null, 2);
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury402-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "MCP server for Mercury402: discover and call 78 pay-per-call (x402, USDC on Base) financial data endpoints — Treasury, FRED, forex, yield spreads, breakeven inflation, macro composites",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mercury402",
|