first-light-mcp 1.0.0 → 1.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +126 -303
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "first-light-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Model Context Protocol server exposing the First Light / BTX explorer chain-intelligence API as native, self-describing AI tools. Neutral on-chain facts only — every result carries its caveat.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -5,12 +5,18 @@
5
5
  * A thin, stateless Model Context Protocol (stdio) server that turns the First
6
6
  * Light / BTX explorer REST API into native, self-describing AI tools.
7
7
  *
8
+ * SELF-DISCOVERING: at startup it fetches the live API CATALOG
9
+ * (GET /api/_catalog) — the single source of truth for the AI-facing surface —
10
+ * and builds its tools from it. Add an endpoint to the catalog and a new tool
11
+ * appears here with NO code change or republish. If the catalog is unreachable
12
+ * it falls back to an embedded snapshot so the server always works.
13
+ *
8
14
  * It holds no chain data and makes no auth decisions of its own: every request
9
- * is forwarded to the live API (https://api-explorer.btxbyronbay.com/api) with
10
- * the caller's `FIRST_LIGHT_TOKEN` as `Authorization: Bearer <token>`. Every gate
11
- * (401 / 403 / 429) is decided downstream by the API. Every result is returned
12
- * with its neutral `caveat` intact — provable on-chain facts only, never identity,
13
- * ownership, holdings, or a risk verdict.
15
+ * is forwarded to the live API with the caller's `FIRST_LIGHT_TOKEN` as
16
+ * `Authorization: Bearer <token>`. Every gate (401 / 403 / 429) is decided
17
+ * downstream. Every result is returned with its neutral `caveat` intact
18
+ * provable on-chain facts only, never identity, ownership, holdings, or a risk
19
+ * verdict.
14
20
  */
15
21
 
16
22
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -29,17 +35,14 @@ const API_BASE = (
29
35
  ).replace(/\/+$/, "");
30
36
  const TOKEN = process.env.FIRST_LIGHT_TOKEN || "";
31
37
  const NAME = "first-light-mcp";
32
- const VERSION = "1.0.0";
38
+ const VERSION = "1.1.0";
33
39
  const REQUEST_TIMEOUT_MS = Number(process.env.FIRST_LIGHT_TIMEOUT_MS || 30000);
40
+ const CATALOG_TIMEOUT_MS = Number(process.env.FIRST_LIGHT_CATALOG_TIMEOUT_MS || 8000);
34
41
 
35
- // The neutral-facts contract, injected on any payload that lacks its own caveat
36
- // (e.g. some public endpoints) so no tool result ever leaves without one.
37
42
  const CANONICAL_CAVEAT =
38
43
  "Neutral on-chain facts only. No entity names, identity, ownership, holdings, " +
39
44
  "or risk/taint claims are asserted or implied.";
40
45
 
41
- // Framing string attached to EVERY tool result, so the model reasons over the
42
- // observation as an observation — never as an accusation, verdict, or advice.
43
46
  const FRAMING =
44
47
  "This is an on-chain observation, not a statement of identity, ownership, " +
45
48
  "counterparty, holdings, risk, or a recommendation. Do not present it as an " +
@@ -47,7 +50,7 @@ const FRAMING =
47
50
 
48
51
  const SERVER_INSTRUCTIONS = [
49
52
  "First Light MCP exposes the BTX (Byron Bay pool) explorer chain-intelligence API",
50
- "as native tools. It is a stateless translation layer over the live REST API.",
53
+ "as native tools, auto-discovered from the live API catalog (/api/_catalog).",
51
54
  "",
52
55
  "NEUTRAL-FACTS CONTRACT (hard product principle): every tool returns provable",
53
56
  "on-chain observations ONLY. Never present a result as an identity, ownership,",
@@ -61,244 +64,106 @@ const SERVER_INSTRUCTIONS = [
61
64
  ].join("\n");
62
65
 
63
66
  // ---------------------------------------------------------------------------
64
- // Tool registryone tool = one question. Each entry:
65
- // name, gated (needs a token), description (LLM-facing, ends with neutrality),
66
- // inputSchema (JSON Schema), build(args) -> { path, query }
67
+ // Embedded fallback catalog used ONLY if GET /api/_catalog is unreachable at
68
+ // startup. The LIVE catalog is authoritative; this is a resilience snapshot.
69
+ // Each entry: { tool, path (with {param}), tier, desc, params:[{name,loc,type,
70
+ // required,enum,desc}] }.
67
71
  // ---------------------------------------------------------------------------
68
72
 
69
- const S = (props = {}, required = []) => ({
70
- type: "object",
71
- properties: props,
72
- required,
73
- additionalProperties: false,
74
- });
73
+ const FALLBACK_ENDPOINTS = [
74
+ { tool: "network_glance", path: "/network/glance", tier: "public", desc: "Network glance: height, block interval/target, difficulty, circulating supply, hashrate, last-block timing.", params: [] },
75
+ { tool: "network_supply", path: "/network/supply", tier: "public", desc: "Circulating/issued supply, emission schedule, halving context.", params: [] },
76
+ { tool: "get_block", path: "/block/{ident}", tier: "public", desc: "Block by decimal height or 64-hex hash: header, tx list, metadata.", params: [{ name: "ident", loc: "path", type: "string", required: true, desc: "Block height (decimal) or 64-hex block hash." }] },
77
+ { tool: "get_tx", path: "/tx/{txid}", tier: "public", desc: "Transaction with prevouts resolved to source addresses & values.", params: [{ name: "txid", loc: "path", type: "string", required: true, desc: "64-hex transaction id." }] },
78
+ { tool: "get_address", path: "/address/{addr}", tier: "public", desc: "Address page: received/sent/balance + paginated tx rows. Not an ownership claim.", params: [{ name: "addr", loc: "path", type: "string", required: true, desc: "BTX address." }, { name: "page", loc: "query", type: "integer", required: false, desc: "Page (0-based)." }] },
79
+ { tool: "blocks_latest", path: "/blocks/latest", tier: "public", desc: "Latest N confirmed blocks, newest first.", params: [{ name: "n", loc: "query", type: "integer", required: false, desc: "How many (1-100)." }] },
80
+ { tool: "txs_latest", path: "/txs/latest", tier: "public", desc: "Latest N confirmed transactions, newest first.", params: [{ name: "n", loc: "query", type: "integer", required: false, desc: "How many (1-100)." }] },
81
+ { tool: "search", path: "/search", tier: "public", desc: "Resolve a height, block hash, txid, or address to the right entity.", params: [{ name: "q", loc: "query", type: "string", required: true, desc: "Height, block hash, txid, or address." }] },
82
+ { tool: "mempool", path: "/mempool", tier: "public", desc: "Mempool summary + recent unconfirmed entries.", params: [] },
83
+ { tool: "market_summary", path: "/market/summary", tier: "public", desc: "Real traded price from live venue(s) (SafeTrade): bid/ask/mid/last/index. Thin single venue; distinct from the /price model.", params: [] },
84
+ { tool: "supply_demand", path: "/supply-demand", tier: "public", desc: "Coin-age & balance cohorts, net-flow, HODL waves, optionally priced. price=model|market.", params: [{ name: "window", loc: "query", type: "string", required: false, desc: "24h,7d,30d,1y,all." }, { name: "price", loc: "query", type: "string", required: false, enum: ["model", "market"], desc: "Price source." }] },
85
+ { tool: "wbtx_summary", path: "/wbtx/summary", tier: "public", desc: "Multi-chain wBTX bridged supply + reserve/backing signal. TESTNET only.", params: [] },
86
+ { tool: "wbtx_reconcile", path: "/wbtx/reconcile", tier: "public", desc: "wBTX peg reconciliation: supply re-derived from the mint/redeem flow vs on-chain totalSupply + attested reserve. TESTNET.", params: [{ name: "chain", loc: "query", type: "integer", required: false, desc: "Chain id filter." }] },
87
+ { tool: "trace", path: "/trace/{txid}", tier: "public", desc: "Bounded fund tracer forward/back from a txid. Proportional flow share on provable edges — never an ownership claim.", params: [{ name: "txid", loc: "path", type: "string", required: true, desc: "64-hex txid." }, { name: "dir", loc: "query", type: "string", required: false, enum: ["forward", "back"], desc: "Direction." }, { name: "hops", loc: "query", type: "integer", required: false, desc: "Max hops." }] },
88
+ { tool: "cluster", path: "/cluster/{addr}", tier: "pro", desc: "Co-spend set: addresses that were inputs together with this one. NOT proof of common ownership.", params: [{ name: "addr", loc: "path", type: "string", required: true, desc: "BTX address." }, { name: "format", loc: "query", type: "string", required: false, desc: "Format selector." }] },
89
+ { tool: "liquidity", path: "/pro/liquidity", tier: "pro", desc: "PRO. Liquidity/float funnel: recently-moved supply estimate vs single-venue book depth. A float proxy, not price discovery.", params: [] },
90
+ { tool: "usage", path: "/pro/usage", tier: "enterprise", desc: "Enterprise. Usage mix: on-chain value bucketed into neutral activity categories with trends.", params: [{ name: "window", loc: "query", type: "string", required: false, desc: "Lookback." }] },
91
+ ];
75
92
 
76
- const TOOLS = [
77
- // ---- Public chain -------------------------------------------------------
78
- {
79
- name: "network_glance",
80
- gated: false,
81
- description:
82
- "Network glance: current height, block interval/target, difficulty, circulating supply, hashrate (matrix ops), last-block timing. Use for a fast 'how is the BTX chain doing right now' summary. A neutral network observation.",
83
- inputSchema: S(),
84
- build: () => ({ path: "/network/glance" }),
85
- },
86
- {
87
- name: "get_block",
88
- gated: false,
89
- description:
90
- "Block detail by height OR block hash. Pass a decimal height (e.g. '155314') or a 64-hex block hash. Returns header, tx list, and metadata. A neutral on-chain fact.",
91
- inputSchema: S(
92
- {
93
- ident: {
94
- type: "string",
95
- description: "Block height (decimal) or 64-hex block hash.",
96
- },
97
- },
98
- ["ident"]
99
- ),
100
- build: (a) => ({ path: `/block/${encodeURIComponent(a.ident)}` }),
101
- },
102
- {
103
- name: "get_tx",
104
- gated: false,
105
- description:
106
- "Transaction detail for a txid, with prevouts (inputs) resolved to their source addresses and values. A neutral on-chain fact.",
107
- inputSchema: S(
108
- { txid: { type: "string", description: "64-hex transaction id." } },
109
- ["txid"]
110
- ),
111
- build: (a) => ({ path: `/tx/${encodeURIComponent(a.txid)}` }),
112
- },
113
- {
114
- name: "get_address",
115
- gated: false,
116
- description:
117
- "Address page: aggregate totals (received/sent/balance) plus a paginated list of the address's transaction rows. `page` is optional (0-based). A neutral on-chain fact, not an ownership or identity claim.",
118
- inputSchema: S(
119
- {
120
- addr: { type: "string", description: "BTX address." },
121
- page: {
122
- type: "integer",
123
- minimum: 0,
124
- description: "Optional page number for the tx rows (0-based).",
125
- },
126
- },
127
- ["addr"]
128
- ),
129
- build: (a) => ({
130
- path: `/address/${encodeURIComponent(a.addr)}`,
131
- query: { page: a.page },
132
- }),
133
- },
134
- {
135
- name: "blocks_latest",
136
- gated: false,
137
- description:
138
- "The latest N confirmed blocks (most recent first). `n` is optional. A neutral on-chain fact.",
139
- inputSchema: S({
140
- n: {
141
- type: "integer",
142
- minimum: 1,
143
- maximum: 100,
144
- description: "How many recent blocks to return (default server-side).",
145
- },
146
- }),
147
- build: (a) => ({ path: "/blocks/latest", query: { n: a.n } }),
148
- },
149
- {
150
- name: "txs_latest",
151
- gated: false,
152
- description:
153
- "The latest N confirmed transactions (most recent first). `n` is optional. A neutral on-chain fact.",
154
- inputSchema: S({
155
- n: {
156
- type: "integer",
157
- minimum: 1,
158
- maximum: 100,
159
- description: "How many recent transactions to return (default server-side).",
160
- },
161
- }),
162
- build: (a) => ({ path: "/txs/latest", query: { n: a.n } }),
163
- },
164
- {
165
- name: "search",
166
- gated: false,
167
- description:
168
- "Universal search over the chain: resolves a query that is a block height, block hash, txid, or address to the right entity. Use when you have an identifier but don't know its type. A neutral lookup.",
169
- inputSchema: S(
170
- {
171
- q: {
172
- type: "string",
173
- description: "Height, block hash, txid, or address to resolve.",
174
- },
175
- },
176
- ["q"]
177
- ),
178
- build: (a) => ({ path: "/search", query: { q: a.q } }),
179
- },
180
- {
181
- name: "network_supply",
182
- gated: false,
183
- description:
184
- "Emission & halving analytic: circulating/issued supply, emission schedule, halving context. A neutral supply arithmetic observation.",
185
- inputSchema: S(),
186
- build: () => ({ path: "/network/supply" }),
187
- },
188
- {
189
- name: "mempool",
190
- gated: false,
191
- description:
192
- "Mempool summary plus recent unconfirmed entries (size, fee context, sample of pending txs). A neutral node-vantage observation of the current mempool.",
193
- inputSchema: S(),
194
- build: () => ({ path: "/mempool" }),
195
- },
93
+ // ---------------------------------------------------------------------------
94
+ // Catalog fetch + tool building
95
+ // ---------------------------------------------------------------------------
196
96
 
197
- // ---- Market / data ------------------------------------------------------
198
- {
199
- name: "market_summary",
200
- gated: false,
201
- description:
202
- "Real market price summary from live exchange venue(s) (SafeTrade): best bid/ask, mid, last trade, index price, venue count. NOTE the quote is a USD stablecoin (~1:1 with USD, a label not a USD feed) and the market is thin/newly-listed — treat the price as indicative, not a deep-liquidity mark. Distinct from `/price` (the banked model price). Read the caveat.",
203
- inputSchema: S(),
204
- build: () => ({ path: "/market/summary" }),
205
- },
206
- {
207
- name: "liquidity",
208
- gated: true,
209
- description:
210
- "PRO/Enterprise. On-chain liquidity funnel: an ESTIMATE of plausibly-available (recently-moved unspent) supply versus single-venue order-book depth/spread/volume. A proxy for available float, NOT a claim any of it is offered for sale, and NOT price discovery. Read the caveat.",
211
- inputSchema: S(),
212
- build: () => ({ path: "/pro/liquidity" }),
213
- },
214
- {
215
- name: "usage",
216
- gated: true,
217
- description:
218
- "PRO/Enterprise. BTX usage view: how transparent on-chain value is being used, bucketed into neutral activity categories (e.g. exchange-flow, mined-and-held) with trends. BTX is mostly mined-and-held; categories that are 'not yet measurable' say so explicitly. Neutral activity aggregates, not identity. Read the caveat.",
219
- inputSchema: S(),
220
- build: () => ({ path: "/pro/usage" }),
221
- },
222
- {
223
- name: "wbtx_summary",
224
- gated: false,
225
- description:
226
- "wBTX bridge summary across both chains: bridged supply, reserve/backing signal, freshness, bridge address. NOTE: testnet data only — no mainnet, no real backing; the reserve badge is a mechanical backing-vs-supply + freshness signal, not an audit. Read the caveat.",
227
- inputSchema: S(),
228
- build: () => ({ path: "/wbtx/summary" }),
229
- },
230
- {
231
- name: "supply_demand",
232
- gated: false,
233
- description:
234
- "On-chain supply map: coin-age & balance cohorts, net-flow, HODL waves — optionally priced. `window` selects the lookback; `price` chooses 'model' (banked model price) or 'market' (live exchange price) for any USD-denominated figures. A neutral supply-structure observation, not trading advice.",
235
- inputSchema: S({
236
- window: {
237
- type: "string",
238
- description: "Lookback window (e.g. '24h', '7d', '30d', '1y', 'all').",
239
- },
240
- price: {
241
- type: "string",
242
- enum: ["model", "market"],
243
- description: "Price source for USD figures: 'model' or 'market'.",
244
- },
245
- }),
246
- build: (a) => ({
247
- path: "/supply-demand",
248
- query: { window: a.window, price: a.price },
249
- }),
250
- },
97
+ async function fetchCatalogEndpoints() {
98
+ try {
99
+ const controller = new AbortController();
100
+ const timer = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS);
101
+ const res = await fetch(`${API_BASE}/_catalog`, {
102
+ headers: { Accept: "application/json", "User-Agent": `${NAME}/${VERSION}` },
103
+ signal: controller.signal,
104
+ });
105
+ clearTimeout(timer);
106
+ if (!res.ok) return null;
107
+ const j = await res.json();
108
+ if (j && Array.isArray(j.endpoints) && j.endpoints.length) return j.endpoints;
109
+ } catch {
110
+ /* fall through to fallback */
111
+ }
112
+ return null;
113
+ }
251
114
 
252
- // ---- Provenance (PRO) ---------------------------------------------------
253
- {
254
- name: "trace",
255
- gated: false, // public tracer per API; token forwarded when present for deeper tiers
256
- description:
257
- "Bounded fund tracer over the spend index: follows value forward or backward from a txid across a limited number of hops. `dir` = 'forward' (where funds went) or 'back' (where funds came from); `hops` bounds depth (clamped server-side to your tier). 'Traceable' is a proportional flow share on provable edges, NEVER an ownership, sender, or counterparty assertion.",
258
- inputSchema: S(
259
- {
260
- txid: { type: "string", description: "64-hex transaction id to trace from." },
261
- dir: {
262
- type: "string",
263
- enum: ["forward", "back"],
264
- description: "Trace direction: 'forward' or 'back'.",
265
- },
266
- hops: {
267
- type: "integer",
268
- minimum: 1,
269
- description: "Max hops to traverse (clamped to your tier).",
270
- },
271
- },
272
- ["txid"]
273
- ),
274
- build: (a) => ({
275
- path: `/trace/${encodeURIComponent(a.txid)}`,
276
- query: { dir: a.dir, hops: a.hops },
277
- }),
278
- },
279
- {
280
- name: "cluster",
281
- gated: true,
282
- description:
283
- "PRO/Enterprise. Co-spend set: other addresses that appeared together as inputs in the same transaction(s) as the queried address. This is a provable on-chain co-occurrence and is explicitly NOT proof of common ownership — never state or imply the addresses share an owner. `format` optionally selects the response shape.",
284
- inputSchema: S(
285
- {
286
- addr: { type: "string", description: "BTX address to build the co-spend set for." },
287
- format: {
288
- type: "string",
289
- description: "Optional response format selector supported by the API.",
290
- },
291
- },
292
- ["addr"]
293
- ),
294
- build: (a) => ({
295
- path: `/cluster/${encodeURIComponent(a.addr)}`,
296
- query: { format: a.format },
297
- }),
298
- },
299
- ];
115
+ function isGated(ep) {
116
+ if (typeof ep.gated === "boolean") return ep.gated;
117
+ return ep.tier === "pro" || ep.tier === "enterprise";
118
+ }
119
+
120
+ function inputSchemaFor(ep) {
121
+ const properties = {};
122
+ const required = [];
123
+ for (const p of ep.params || []) {
124
+ const s = { type: p.type || "string" };
125
+ if (p.enum) s.enum = p.enum;
126
+ if (p.desc) s.description = p.desc;
127
+ properties[p.name] = s;
128
+ if (p.required) required.push(p.name);
129
+ }
130
+ return { type: "object", properties, required, additionalProperties: false };
131
+ }
300
132
 
301
- const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
133
+ function buildReq(ep, args) {
134
+ let path = ep.path;
135
+ const query = {};
136
+ for (const p of ep.params || []) {
137
+ const v = (args || {})[p.name];
138
+ if (p.loc === "path") {
139
+ path = path.replace(`{${p.name}}`, encodeURIComponent(v == null ? "" : v));
140
+ } else if (v !== undefined && v !== null && v !== "") {
141
+ query[p.name] = v;
142
+ }
143
+ }
144
+ return { path, query };
145
+ }
146
+
147
+ function endpointsToTools(endpoints) {
148
+ const tools = [];
149
+ const byName = new Map();
150
+ for (const ep of endpoints) {
151
+ const name = ep.mcpTool || ep.tool;
152
+ if (!name) continue; // ops/meta endpoints without a tool name aren't exposed
153
+ const gated = isGated(ep);
154
+ const description =
155
+ (gated ? "[PRO/Enterprise token required] " : "") +
156
+ (ep.desc || ep.summary || name);
157
+ const tool = { name, gated, description, inputSchema: inputSchemaFor(ep), ep };
158
+ tools.push(tool);
159
+ byName.set(name, tool);
160
+ }
161
+ return { tools, byName };
162
+ }
163
+
164
+ // Populated in main() before the transport connects.
165
+ let TOOLS = [];
166
+ let TOOL_BY_NAME = new Map();
302
167
 
303
168
  // ---------------------------------------------------------------------------
304
169
  // HTTP client + neutral-facts wrapping
@@ -312,7 +177,6 @@ function buildUrl(path, query) {
312
177
  return url;
313
178
  }
314
179
 
315
- /** Wrap an API payload so the caveat is unavoidable and framing is always present. */
316
180
  function wrapObservation(payload) {
317
181
  let caveat = CANONICAL_CAVEAT;
318
182
  if (payload && typeof payload === "object" && !Array.isArray(payload)) {
@@ -323,15 +187,11 @@ function wrapObservation(payload) {
323
187
  return { observation: payload, caveat, framing: FRAMING };
324
188
  }
325
189
 
326
- /** Perform the GET and return a structured result object (never throws for HTTP status). */
327
- async function apiGet(tool, args) {
328
- const { path, query } = tool.build(args);
190
+ async function apiGet(ep, args) {
191
+ const { path, query } = buildReq(ep, args);
329
192
  const url = buildUrl(path, query);
330
193
 
331
- const headers = {
332
- Accept: "application/json",
333
- "User-Agent": `${NAME}/${VERSION}`,
334
- };
194
+ const headers = { Accept: "application/json", "User-Agent": `${NAME}/${VERSION}` };
335
195
  if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
336
196
 
337
197
  const controller = new AbortController();
@@ -363,62 +223,21 @@ async function apiGet(tool, args) {
363
223
  json = { raw: bodyText };
364
224
  }
365
225
 
366
- // Map the API status codes to a clean, model-legible error surface.
367
226
  if (res.status === 401) {
368
- return {
369
- isError: true,
370
- payload: {
371
- error: "auth",
372
- detail: TOKEN
373
- ? "The First Light API rejected the token (invalid, revoked, or expired). Check FIRST_LIGHT_TOKEN."
374
- : "This tool needs a PRO/Enterprise token. Set the FIRST_LIGHT_TOKEN environment variable to your First Light Bearer key.",
375
- endpoint: path,
376
- },
377
- };
227
+ return { isError: true, payload: { error: "auth", detail: TOKEN ? "The First Light API rejected the token (invalid, revoked, or expired). Check FIRST_LIGHT_TOKEN." : "This tool needs a PRO/Enterprise token. Set the FIRST_LIGHT_TOKEN environment variable to your First Light Bearer key.", endpoint: path } };
378
228
  }
379
229
  if (res.status === 403) {
380
- return {
381
- isError: true,
382
- payload: {
383
- error: "tier",
384
- detail:
385
- "This tool needs a higher tier than your token allows (PRO/Enterprise). " +
386
- ((json && (json.detail || json.error)) || "Contact the operator to upgrade."),
387
- endpoint: path,
388
- },
389
- };
230
+ return { isError: true, payload: { error: "tier", detail: "This tool needs a higher tier than your token allows (PRO/Enterprise). " + ((json && (json.detail || json.error)) || "Contact the operator to upgrade."), endpoint: path } };
390
231
  }
391
232
  if (res.status === 429) {
392
233
  const retryAfter = res.headers.get("retry-after");
393
- return {
394
- isError: true,
395
- payload: {
396
- error: "rate_limited",
397
- detail: "Per-key rate limit exceeded; back off and retry.",
398
- retryAfter: retryAfter ? Number(retryAfter) : undefined,
399
- endpoint: path,
400
- },
401
- };
234
+ return { isError: true, payload: { error: "rate_limited", detail: "Per-key rate limit exceeded; back off and retry.", retryAfter: retryAfter ? Number(retryAfter) : undefined, endpoint: path } };
402
235
  }
403
236
  if (res.status === 400) {
404
- return {
405
- isError: true,
406
- payload: {
407
- error: "bad_request",
408
- detail: (json && (json.detail || json.error)) || "Invalid request parameters.",
409
- endpoint: path,
410
- },
411
- };
237
+ return { isError: true, payload: { error: "bad_request", detail: (json && (json.detail || json.error)) || "Invalid request parameters.", endpoint: path } };
412
238
  }
413
239
  if (!res.ok) {
414
- return {
415
- isError: true,
416
- payload: {
417
- error: "http_" + res.status,
418
- detail: (json && (json.detail || json.error)) || bodyText || res.statusText,
419
- endpoint: path,
420
- },
421
- };
240
+ return { isError: true, payload: { error: "http_" + res.status, detail: (json && (json.detail || json.error)) || bodyText || res.statusText, endpoint: path } };
422
241
  }
423
242
 
424
243
  return { isError: false, payload: wrapObservation(json) };
@@ -436,8 +255,7 @@ const server = new Server(
436
255
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
437
256
  tools: TOOLS.map((t) => ({
438
257
  name: t.name,
439
- description:
440
- (t.gated ? "[PRO/Enterprise token required] " : "") + t.description,
258
+ description: t.description,
441
259
  inputSchema: t.inputSchema,
442
260
  })),
443
261
  }));
@@ -451,8 +269,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
451
269
  content: [{ type: "text", text: JSON.stringify({ error: "unknown_tool", detail: `No tool named '${name}'.` }, null, 2) }],
452
270
  };
453
271
  }
454
-
455
- const result = await apiGet(tool, args || {});
272
+ const result = await apiGet(tool.ep, args || {});
456
273
  return {
457
274
  isError: result.isError,
458
275
  content: [{ type: "text", text: JSON.stringify(result.payload, null, 2) }],
@@ -460,11 +277,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
460
277
  });
461
278
 
462
279
  async function main() {
280
+ const fetched = await fetchCatalogEndpoints();
281
+ const endpoints = fetched || FALLBACK_ENDPOINTS;
282
+ const source = fetched ? "live catalog" : "embedded fallback";
283
+ const built = endpointsToTools(endpoints);
284
+ TOOLS = built.tools;
285
+ TOOL_BY_NAME = built.byName;
286
+
463
287
  const transport = new StdioServerTransport();
464
288
  await server.connect(transport);
465
- // Log to stderr only — stdout is the MCP JSON-RPC channel.
466
289
  console.error(
467
- `${NAME} v${VERSION} ready · API ${API_BASE} · token ${TOKEN ? "present" : "absent (public tools only)"} · ${TOOLS.length} tools`
290
+ `${NAME} v${VERSION} ready · API ${API_BASE} · token ${TOKEN ? "present" : "absent (public tools only)"} · ${TOOLS.length} tools (${source})`
468
291
  );
469
292
  }
470
293