File size: 27,510 Bytes
de4564e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
#!/usr/bin/env python3
"""MCP over Streamable HTTP, mounted on the same FastAPI app that serves the paid routes.



Why this exists: today's measurement says x402 is not a marketplace. Across the top 18

buyers, 13 pay exactly ONE seller and the largest sends 5,876 transfers a day to a single

provider. Nothing on that network is bought because it was discovered in a catalog β€” every

large flow is one buyer wired into one provider. Our own 127 payers averaging 1.1 calls is

the signature of being CRAWLED, not of being bought from.



MCP is the one channel we own that produces the other shape. A human adds a server to their

client once and it is then used repeatedly, by them, for as long as it is useful. That is

the integration shape, arrived at deliberately rather than by discovery.



We already had five servers in the official MCP registry β€” and all five listed ONLY an

`ghcr.io` OCI image with `remotes: null`, i.e. Docker-or-nothing, while we run live HTTP

Spaces that advertised no MCP endpoint at all (`/mcp`, `/sse`, `/messages` were all 404).

This closes that gap so the registry entries can carry a URL.



The tools here are deliberately the FREE-UPSTREAM ones. Every backend behind them

(Open-Meteo, USGS, Nager.Date, World Bank, the multi-engine search) costs nothing per call,

so giving them away has no marginal cost, and adoption is the thing we do not have. The

crypto, security and LLM routes stay paid on the x402 side.



⚠ MOUNTING TRAP: `tokenguard_api/main.py` sweeps EVERY POST route into the paywall with no

exclusion list β€” deliberately, because 45 routes once escaped it by being forgotten. A

paywalled `/mcp` cannot work: MCP clients do not speak x402 and would get 402 on every

JSON-RPC frame. The mount MUST add these paths to the sweep's exclusion set.

"""
import json
import os
from typing import Any

from fastapi import APIRouter, Request, Response

router = APIRouter()

PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "tokenguard"
SERVER_VERSION = "1.2.0"

# Paths the x402 sweep must NOT paywall. Imported by main.py rather than duplicated there,
# so adding a route here cannot silently become a paid route over there.
#
# The discovery aliases below are here for the same reason MCP is: paywalling the CATALOGUE
# would be self-defeating. A crawler that must pay to learn our prices does not pay, it
# leaves. They need POST because that is the verb crawlers actually use β€” measured on
# contract-guard 2026-08-17, one access-log buffer carried 8,689 405s (more than its 7,657 402s) and
# every one was a discovery probe from a fleet of 262 distinct IPs POSTing to these exact
# paths. The routes already existed; they were registered GET-only, so FastAPI answered
# "405 Method Not Allowed" β€” a response that carries no price and no catalogue, which is the
# same failure as `x402-405-hides-the-paywall`, inverted. That fix taught "answer GET with
# the 402" and this is its mirror: answer POST with the catalogue.
# `/` and `/apis.json` are in here for the same measured reason as the x402 paths: that
# same buffer carried 924 `POST /` and 650 `HEAD /`, and `/apis.json` (the APIs.json discovery
# standard) has 128 lifetime `POST … 405` on contract-guard while its GET answers 200. Both
# only ever return a service descriptor, so answering them on every verb gives nothing away.
#
# The list below is the CLASS, not the instances that happened to be measured. Every entry
# is a descriptor endpoint: it takes no input, gives away no product, and exists to tell a
# machine what this service is and what it costs. There is no verb on which refusing one of
# them helps us, and `/pricing` or `/llms.txt` behind a paywall would be self-defeating in
# the same way a paywalled catalogue is.
_DISCOVERY_ALIASES = (
    "/", "/apis.json",
    # x402 catalogue, the paths the crawler fleet was measured POSTing to
    "/x402", "/x402-resources", "/discovery/resources", "/x402/discovery/resources",
    "/v1/x402/discovery/resources", "/v2/x402/discovery/resources",
    "/.well-known/x402", "/.well-known/x402.json", "/.well-known/x402-resources",
    "/.well-known/x402/discovery/resources",
    # generic API roots β€” also measured 405ing (POST /v1 x356, POST /api/v1 x355)
    "/api", "/api/v1", "/v1",
    # agent + LLM descriptors
    "/.well-known/agent.json", "/.well-known/agent-card.json", "/.well-known/agent-card",
    "/.well-known/api-catalog", "/.well-known/llms.txt",
    "/llms.txt", "/llms-full.txt", "/m2m-openapi.json",
    # commercial + operational descriptors
    "/pricing", "/terms", "/tos", "/privacy", "/health", "/selftest",
    "/robots.txt", "/sitemap.xml",
    # ⚠ Second pass, 2026-08-17. The first sweep covered the three main.py files and
    # missed the same class living in the mounted route MODULES. The prober fleet found
    # them immediately: over ~2.2h of log history, skill-audit still answered 17 405s and
    # tokenguard 29, every one of them a POST to a free diagnostic or descriptor below.
    # Fixing the class inside the files you happen to be reading is not fixing the class.
    "/world/selftest", "/llm/selftest", "/web/selftest", "/web/sitemap/selftest",
    "/sanctions/status", "/intel/x402/preview", "/upstreams", "/attestation/key",
    "/okx/status",
)
FREE_PATHS = ({"POST /mcp", "GET /mcp"}
              | {f"POST {p}" for p in _DISCOVERY_ALIASES}
              | {f"GET {p}" for p in _DISCOVERY_ALIASES})


def _tool(name, description, properties, required):
    # ⚠ Every tool description is text an agent reads while deciding what to call, on the one
    # channel we know is used: POST /mcp has carried 8,135 frames and a tool call is free.
    # `paid_catalogue` exists but an agent has to go looking for it; THESE are the tools that
    # actually get invoked. One sentence, on the tools that are already being used, is the
    # cheapest distribution we own β€” and unlike exposing paid routes as tools, it gives away
    # no product. Skipped on the catalogue tool itself, which already says all of this.
    if name != "paid_catalogue":
        description = (description.rstrip()
                       + " (Free. This server also sells a paid API β€” call `paid_catalogue`"
                         " for the routes and prices; x402 over USDC on Base, no signup.)")
    return {"name": name, "description": description,
            "inputSchema": {"type": "object", "properties": properties, "required": required}}


TOOLS = [
    _tool("paid_catalogue",
          "List the paid API routes this same server offers, with prices and which ones need "
          "no input. The MCP tools here are free and general-purpose; the paid routes are "
          "specialised (on-chain token safety, live DEX prices, wallet intel, supply-chain "
          "scans). Payment is x402 over USDC on Base β€” no account or API key. Takes no "
          "arguments.",
          {}, []),
    _tool("air_quality",
          "Current air quality for a place: PM2.5, PM10, ozone, NO2, SO2, CO and dust, plus "
          "the US and European AQI and the US AQI band ('Good', 'Unhealthy'). Takes a place "
          "name β€” no coordinates needed.",
          {"location": {"type": "string", "description": "Place name, e.g. 'Tokyo'"}},
          ["location"]),
    _tool("geocode",
          "Resolve a place name to coordinates, country, admin region, timezone, elevation "
          "and population.",
          {"name": {"type": "string", "description": "Place name to resolve"},
           "count": {"type": "integer", "description": "1-20 candidates (default 5)"}},
          ["name"]),
    _tool("earthquakes",
          "Recent earthquakes from the USGS feed β€” worldwide, or within a radius of a named "
          "place. Returns magnitude, depth, tsunami flag and felt reports.",
          {"min_magnitude": {"type": "number", "description": "Lower bound (default 4.5)"},
           "days": {"type": "integer", "description": "1-30 days back (default 1)"},
           "limit": {"type": "integer", "description": "1-100 events (default 20)"},
           "location": {"type": "string", "description": "Centre on a place name"},
           "radius_km": {"type": "number", "description": "Radius around location (default 500)"}},
          []),
    _tool("public_holidays",
          "Public holidays for a country and year, with local names and a past/upcoming flag.",
          {"country": {"type": "string", "description": "ISO 2-letter country code, e.g. JP"},
           "year": {"type": "integer", "description": "Calendar year (defaults to current)"}},
          ["country"]),
    _tool("country_indicator",
          "World Bank time series for a country: gdp, gdp_per_capita, population, inflation, "
          "unemployment, life_expectancy, co2_per_capita or internet_users.",
          {"country": {"type": "string", "description": "ISO 2- or 3-letter country code"},
           "indicator": {"type": "string", "description": "Alias above, or a World Bank code"},
           "years": {"type": "integer", "description": "1-60 most recent years (default 5)"}},
          ["country"]),
    _tool("elevation",
          "Ground elevation in metres for a place name.",
          {"location": {"type": "string", "description": "Place name"}},
          ["location"]),
    _tool("web_search",
          "Search the live web and return ranked title/url/snippet results, through an "
          "automatic multi-engine failover chain.",
          {"query": {"type": "string", "description": "Search query"},
           "max_results": {"type": "integer", "description": "1-25 (default 10)"}},
          ["query"]),
    _tool("read_url",
          "Fetch a URL and return its main content as clean Markdown, boilerplate stripped.",
          {"url": {"type": "string", "description": "Page to fetch"}},
          ["url"]),
]


# Host-specific tools. Both Spaces mount this module, and serving the same eight tools twice
# would just be two URLs for one product. skill-audit's distinctive asset is the malicious-
# pattern scanner, which is also the tool a human has an actual reason to install β€” so it
# registers that here rather than duplicating the data tools.
_EXTRA_TOOLS: list = []
_EXTRA_DISPATCH: list = []


def register_extra(tools, dispatch):
    """Add host-specific tools. `dispatch(name, args)` may be sync or async; it is only

    consulted for names it declared, so a failure here cannot shadow the built-ins."""
    _EXTRA_TOOLS.extend(tools)
    _EXTRA_DISPATCH.append((frozenset(t["name"] for t in tools), dispatch))


def set_identity(name, version=None):
    """Declare which server this host IS.



    This router is shared, so `SERVER_NAME` defaulting to "tokenguard" meant the

    skill-audit Space answered `initialize` with `serverInfo.name = "tokenguard"`

    (measured 2026-07-29 against the live remote). The MCP registry lists that URL as

    `skill-audit-mcp`, so a developer installing the scanner saw it announce itself as a

    different product β€” in the one funnel that the buyer-monogamy finding says actually

    wins customers. Every host that mounts this router should call it.

    """
    global SERVER_NAME, SERVER_VERSION
    SERVER_NAME = name
    if version:
        SERVER_VERSION = version


_ROUTE_FACTS: dict = {}


def _find_required(node, _depth=0):
    """Recursively look for the input `required` list inside a bazaar discovery extension.



    Returns (found, list). **The two halves matter separately**: a route that declares

    `required: []` is genuinely callable blind, while a route we simply could not read is not

    an answer at all. Collapsing those two into one empty list is what made the first version

    of the catalogue tell buyers all 131 routes needed no input.

    """
    if _depth > 8 or not isinstance(node, (dict, list)):
        return False, []
    if isinstance(node, dict):
        # ⚠ Must be the `required` belonging to the INPUT, not the first one anywhere in the
        # tree. The first version took whichever it met first β€” an outer schema's own
        # `required` β€” and reported 0 zero-input routes out of 131 when twelve of them take
        # nothing. Anchor on the "input" key instead of trusting depth-first order.
        # Dumped `declare_discovery_extension` rather than guessing a third time. The caller's
        # own fields live at bazaar.schema.properties.input.properties.BODY.required. The two
        # earlier attempts anchored one level too high, on the input ENVELOPE, whose required
        # list is ["type","bodyType","body"] β€” never empty, which is precisely why every route
        # came back "requires input". Anchor on `body`.
        body = (node.get("properties") or {}).get("body")
        # ⚠ Do NOT gate on body.get("type") == "object" β€” the SDK does not emit a `type` here.
        # That guess cost two live deploys. It was settled in one call by unit-testing the
        # extractor offline against a built extension, which is what should have happened
        # first: iterating on a live buyer-facing service is slow, and every cycle risks
        # shipping a confident wrong answer.
        if isinstance(body, dict) and ("properties" in body or "required" in body):
            r = body.get("required")
            # No `required` key on the body means no required fields β€” an ANSWER, not a miss.
            return True, [x for x in (r or []) if isinstance(x, str)]
        for v in node.values():
            found, out = _find_required(v, _depth + 1)
            if found:
                return True, out
    else:
        for v in node:
            found, out = _find_required(v, _depth + 1)
            if found:
                return True, out
    return False, []


def set_routes(routes: dict) -> None:
    """Let the app hand us its own x402 routes dict at startup.



    `/.well-known/x402` lists **bare URL strings** β€” no schema, no extensions β€” so the

    catalogue tool could never say which routes are callable with no input, which is exactly

    the fact that predicts conversion (25x, measured). The app already holds the real

    structure; this is the only place it can come from without probing 131 challenges inside

    a single tool call.

    """
    facts = {}
    for key, cfg in (routes or {}).items():
        path = key.split(" ", 1)[-1] if " " in key else key
        found, req = _find_required(getattr(cfg, "extensions", None))
        facts[path] = {"known": found, "required": req,
                       "description": getattr(cfg, "description", None)}
    _ROUTE_FACTS.clear()
    _ROUTE_FACTS.update(facts)


def _all_tools():
    return TOOLS + _EXTRA_TOOLS


async def _paid_catalogue() -> dict:
    """What this server also sells, read from its own live x402 manifest.



    Measured 2026-08-10: `POST /mcp` has carried **8,135 frames** β€” by far the largest

    engagement this operation has anywhere β€” and a tool call is FREE (verified: tools/call β†’

    geocode β†’ HTTP 200, real data, no payment challenge). It converts nothing, because nothing

    an agent can see here mentions that a paid, differentiated API exists on the same host.



    The instinct was to expose the paid routes AS tools. That is backwards: it would hand over

    the priced product for free. This does the opposite β€” it hands over the *catalogue*, which

    is the one thing an agent needs in order to become a buyer and the one thing that costs us

    nothing. Same lesson as spending the 402 body: the text is the channel, not the payload.



    Read from `/.well-known/x402` at call time rather than hardcoded, so a route added over

    there cannot silently go unadvertised here.

    """
    # ⚠ MUST be async, and this is not a style point. The first version used a synchronous
    # urllib call, which blocks the event loop β€” so the server was waiting on a request only
    # it could serve, and deadlocked itself until the timeout. It shipped and every call came
    # back "could not read … TimeoutError". A server fetching itself has to yield the loop.
    import httpx
    base = os.environ.get("PUBLIC_BASE_URL") or f"https://eltociear-{SERVER_NAME}.hf.space"
    try:
        async with httpx.AsyncClient(timeout=20) as c:
            res = (await c.get(base + "/.well-known/x402")).json().get("resources") or []
    except Exception as e:                                             # noqa: BLE001
        # Never fake a catalogue. An empty list would read as "nothing for sale".
        return {"error": f"could not read {base}/.well-known/x402: {type(e).__name__}",
                "hint": "fetch it directly; this tool only mirrors it"}
    rows = []
    for r_ in res:
        u = r_.get("resource") or r_.get("url") if isinstance(r_, dict) else r_
        if not isinstance(u, str):
            continue
        path = "/" + u.split("/", 3)[-1] if "//" in u else u
        f = _ROUTE_FACTS.get(path) or {}
        row = {"route": path, "url": u,
               "description": f.get("description")
               or (r_.get("description") if isinstance(r_, dict) else None)}
        if f.get("known"):
            row["requires_input"] = bool(f["required"])
            if f["required"]:
                row["required_fields"] = f["required"]
        else:
            row["requires_input"] = "unknown"
        rows.append(row)
    # ⚠ The first live run claimed `callable_with_no_input: 131 of 131`. It is not β€” nine of
    # them demand an argument. The nested path this reads is wrong for this manifest shape, so
    # every route came back with no required fields and that read as a definite "needs
    # nothing". Telling a buyer they can call /scan blind is worse than telling them nothing:
    # they try, they fail, they leave. If NOTHING resolves, we could not read it β€” say so.
    # ⚠ And it is NOT a parse bug to be fixed later: `resources` in this manifest is a list of
    # bare URL STRINGS β€” 131 of them, no description, no schema, no extensions. The richer
    # shape (bazaar info/schema) exists only in the 402 challenge itself, one route at a time.
    # So the requirement genuinely cannot be derived here, and the branch below is the correct
    # permanent answer rather than a stopgap. To do better, main.py has to hand its routes dict
    # to this module at startup; probing 131 challenges inside a tool call is not an option.
    # ⚠ Sanity-check the EXTRACTOR, not just the data. We know from measurement that both
    # kinds of route exist here β€” twelve take no input at all. So if every route it resolved
    # landed on the same side, the extractor is not discriminating; it is pattern-matching
    # something else and its confident answers are worthless. Two live deploys reported
    # "0 callable with no input" out of 86 resolved, which is how this was caught.
    #
    # Falling back to "unknown" loses a feature. Publishing "requires input" for a route that
    # takes none loses a buyer, who tries, fails and never returns. Fail to the honest side.
    resolved = [r_["requires_input"] for r_ in rows if r_["requires_input"] != "unknown"]
    readable = bool(resolved) and (True in resolved) and (False in resolved)
    if not readable:
        for r_ in rows:
            r_["requires_input"] = "unknown"
            r_.pop("required_fields", None)
    if not readable:
        return {
            "server": SERVER_NAME,
            "payment": "x402 v2, USDC on Base (eip155:8453). Send the payment header the 402 "
                       "challenge asks for; no account, no signup, no API key.",
            "note": "MCP tools on this server are free. The routes below are paid and are a "
                    "different, more specialised product.",
            "input_requirements": "could not be read from this manifest β€” GET any route's 402 "
                                  "challenge for its exact input schema before calling",
            "total": len(rows),
            "routes": rows,
        }
    blind = [r_ for r_ in rows if r_["requires_input"] is False]
    unknown = [r_ for r_ in rows if r_["requires_input"] == "unknown"]
    return {
        "server": SERVER_NAME,
        "payment": "x402 v2, USDC on Base (eip155:8453). Send the payment header the 402 "
                   "challenge asks for; no account, no signup, no API key.",
        "note": "MCP tools on this server are free. The routes below are paid and are a "
                "different, more specialised product.",
        "callable_with_no_input": len(blind),
        "requirements_unreadable": len(unknown),
        "total": len(rows),
        "start_here": [r_["url"] for r_ in blind[:8]],
        "routes": rows,
    }


async def _dispatch(name: str, args: dict) -> Any:
    """Run a tool by reusing the route handlers in-process β€” no HTTP round trip to ourselves."""
    import inspect
    # ⚠ Every MCP call is `POST /mcp 200` in the access log, so 8,135 frames of our largest
    # engagement are indistinguishable from each other. The question that matters β€” is the
    # FREE `audit_skill_text` cannibalising the PAID `/audit`, which has 2,709 challenges and
    # zero conversions? β€” cannot be answered from a log where both look the same.
    #
    # Name only. Never the arguments: they carry caller content, and a demand meter is not a
    # reason to start recording what people send us. The Space log stream is already read as
    # a demand oracle, so one line per call makes this channel measurable with no new tooling.
    print(f"MCPTOOL {name}", flush=True)
    if name == "paid_catalogue":
        return await _paid_catalogue()
    for names, fn in _EXTRA_DISPATCH:
        if name in names:
            out = fn(name, args)
            return await out if inspect.isawaitable(out) else out
    if name in ("air_quality", "elevation"):
        import worlddata_routes as W
        req = W.PointRequest(location=args.get("location"))
        return await (W.air_quality(req) if name == "air_quality" else W.elevation(req))
    if name == "geocode":
        import worlddata_routes as W
        return await W.geocode(W.GeocodeRequest(name=args.get("name"), count=args.get("count") or 5))
    if name == "earthquakes":
        import worlddata_routes as W
        return await W.earthquakes(W.QuakeRequest(
            min_magnitude=args.get("min_magnitude"), days=args.get("days"),
            limit=args.get("limit"), location=args.get("location"),
            radius_km=args.get("radius_km")))
    if name == "public_holidays":
        import worlddata_routes as W
        return await W.holidays(W.HolidayRequest(country=args.get("country"), year=args.get("year")))
    if name == "country_indicator":
        import worlddata_routes as W
        return await W.indicators(W.IndicatorRequest(
            country=args.get("country"), indicator=args.get("indicator") or "gdp",
            years=args.get("years") or 5))
    if name == "web_search":
        import webdata_routes as D
        return await D.web_search(D.SearchRequest(
            query=args.get("query"), max_results=args.get("max_results") or 10))
    if name == "read_url":
        import webdata_routes as D
        return await D.read_url(D.ReadRequest(url=args.get("url")))
    raise ValueError(f"unknown tool: {name}")


def _result(rid, payload):
    return {"jsonrpc": "2.0", "id": rid, "result": payload}


def _error(rid, code, message):
    return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}


@router.post("/mcp")
async def mcp_endpoint(request: Request):
    """MCP Streamable HTTP endpoint (JSON-RPC 2.0). FREE β€” never behind the x402 paywall."""
    try:
        body = await request.json()
    except Exception:
        return Response(json.dumps(_error(None, -32700, "parse error")),
                        media_type="application/json", status_code=400)

    # Batches are legal JSON-RPC; handle them so a compliant client is not rejected.
    single = not isinstance(body, list)
    frames = [body] if single else body
    out = []
    for frame in frames:
        rid = frame.get("id") if isinstance(frame, dict) else None
        method = (frame or {}).get("method")
        params = (frame or {}).get("params") or {}

        if method == "initialize":
            out.append(_result(rid, {
                "protocolVersion": PROTOCOL_VERSION,
                "capabilities": {"tools": {"listChanged": False}},
                "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
                "instructions": (
                    "Real-world reference data and web reading, free. Air quality, geocoding, "
                    "earthquakes, public holidays, World Bank indicators, elevation, web search "
                    "and URL-to-Markdown. Crypto, security and LLM endpoints on this host are "
                    "paid over x402 β€” see /openapi.json."),
            }))
        elif method in ("notifications/initialized", "notifications/cancelled"):
            continue  # notifications carry no id and MUST NOT get a response
        elif method == "ping":
            out.append(_result(rid, {}))
        elif method == "tools/list":
            out.append(_result(rid, {"tools": _all_tools()}))
        elif method == "tools/call":
            tname = params.get("name")
            targs = params.get("arguments") or {}
            try:
                data = await _dispatch(tname, targs)
                out.append(_result(rid, {
                    "content": [{"type": "text", "text": json.dumps(data, default=str)}],
                    "isError": False}))
            except Exception as e:
                # Tool failures are reported INSIDE the result, not as a JSON-RPC error β€”
                # a protocol-level error tells the client the server is broken, when in fact
                # one upstream refused one query and the model should see why and retry.
                out.append(_result(rid, {
                    "content": [{"type": "text",
                                 "text": f"{type(e).__name__}: {getattr(e, 'detail', e)}"}],
                    "isError": True}))
        else:
            out.append(_error(rid, -32601, f"method not found: {method}"))

    if not out:
        return Response(status_code=202)  # notification-only frame
    return Response(json.dumps(out[0] if single else out), media_type="application/json")


@router.get("/mcp")
async def mcp_info():
    """Human/browser-facing description. Clients POST here; GET just explains what this is."""
    return {"protocol": "mcp", "transport": "streamable-http",
            "protocolVersion": PROTOCOL_VERSION,
            "server": {"name": SERVER_NAME, "version": SERVER_VERSION},
            "tools": [t["name"] for t in _all_tools()],
            "usage": {"method": "POST", "content_type": "application/json",
                      "example": {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}},
            "cost": "free; the crypto/security/LLM routes on this host are x402-paid"}