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.
- package/package.json +1 -1
- 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.
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
required,
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
198
|
-
{
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|