mercury402-mcp 0.1.1 → 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 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
- The HTTP endpoint has no authentication. It binds to `127.0.0.1` by default, and the server refuses to start
133
- in paid mode on a non-loopback `MCP_HTTP_HOST`.
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 API source in this repo (`src/pricing.js`,
240
- `src/new-routes.js`, `src/ai-routes.js`, plus descriptions mirrored from `src/server.js`). A test fails if it
241
- drifts from `src/pricing.js`. `tsc` bakes the catalog into `dist/catalog.json`, so the published package never
242
- reads the repo at runtime; `prepublishOnly` regenerates the catalog, rebuilds, and runs the tests.
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
- // The HTTP endpoint has no auth: anyone who can reach it could spend the payer wallet.
21
- const loopback = ['127.0.0.1', '::1', 'localhost'].includes(config.httpHost);
22
- if (config.payerPrivateKey && !loopback) {
23
- 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.');
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
- await new Promise((resolve) => http.listen(config.httpPort, config.httpHost, resolve));
53
- log(`streamable HTTP listening on http://${config.httpHost}:${config.httpPort}/mcp`);
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.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.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",