zeambridge 2.0.0__py3-none-any.whl

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 (54) hide show
  1. zeam_bridge/__init__.py +1233 -0
  2. zeam_bridge/engine/__init__.py +5 -0
  3. zeam_bridge/engine/abi.py +266 -0
  4. zeam_bridge/engine/batch_settlement.py +334 -0
  5. zeam_bridge/engine/bazaar.py +115 -0
  6. zeam_bridge/engine/core.py +285 -0
  7. zeam_bridge/engine/discovery.py +76 -0
  8. zeam_bridge/engine/env.py +27 -0
  9. zeam_bridge/engine/files.py +82 -0
  10. zeam_bridge/engine/gate/__init__.py +9 -0
  11. zeam_bridge/engine/gate/admission.py +152 -0
  12. zeam_bridge/engine/gate/credits.py +159 -0
  13. zeam_bridge/engine/gate/exact.py +189 -0
  14. zeam_bridge/engine/gate/http.py +17 -0
  15. zeam_bridge/engine/gate/jsonx.py +121 -0
  16. zeam_bridge/engine/gate/meter.py +92 -0
  17. zeam_bridge/engine/gate/store.py +48 -0
  18. zeam_bridge/engine/keys.py +131 -0
  19. zeam_bridge/engine/messages.py +111 -0
  20. zeam_bridge/engine/meter.py +563 -0
  21. zeam_bridge/engine/php.py +157 -0
  22. zeam_bridge/engine/pricing.py +282 -0
  23. zeam_bridge/engine/rpc.py +74 -0
  24. zeam_bridge/engine/runtime.py +684 -0
  25. zeam_bridge/engine/settlement/__init__.py +18 -0
  26. zeam_bridge/engine/settlement/cache.py +82 -0
  27. zeam_bridge/engine/settlement/chain.py +97 -0
  28. zeam_bridge/engine/settlement/channels.py +342 -0
  29. zeam_bridge/engine/settlement/claims.py +159 -0
  30. zeam_bridge/engine/settlement/config.py +193 -0
  31. zeam_bridge/engine/settlement/gas_quote.py +147 -0
  32. zeam_bridge/engine/settlement/jsonx.py +70 -0
  33. zeam_bridge/engine/settlement/mpp.py +505 -0
  34. zeam_bridge/engine/settlement/payout.py +52 -0
  35. zeam_bridge/engine/settlement/reason.py +33 -0
  36. zeam_bridge/engine/settlement/refunds.py +780 -0
  37. zeam_bridge/engine/settlement/relay.py +172 -0
  38. zeam_bridge/engine/settlement/server.py +924 -0
  39. zeam_bridge/engine/settlement/store.py +184 -0
  40. zeam_bridge/engine/settlement/verify.py +450 -0
  41. zeam_bridge/engine/settlement/withdrawals.py +44 -0
  42. zeam_bridge/engine/splits.py +116 -0
  43. zeam_bridge/engine/typed_data.py +88 -0
  44. zeam_bridge/engine/wire.py +9 -0
  45. zeam_bridge/grants.py +68 -0
  46. zeam_bridge/old_name.py +14 -0
  47. zeam_pass/__init__.py +5 -0
  48. zeam_pass.py +5 -0
  49. zeambridge-2.0.0.dist-info/METADATA +344 -0
  50. zeambridge-2.0.0.dist-info/RECORD +54 -0
  51. zeambridge-2.0.0.dist-info/WHEEL +5 -0
  52. zeambridge-2.0.0.dist-info/licenses/LICENSE +202 -0
  53. zeambridge-2.0.0.dist-info/licenses/NOTICE +4 -0
  54. zeambridge-2.0.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1233 @@
1
+ import asyncio
2
+ import base64
3
+ import contextvars
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ import functools
6
+ import http
7
+ import inspect
8
+ import io
9
+ import json
10
+ import math
11
+ import re
12
+ import secrets
13
+ import threading
14
+ import urllib.parse
15
+
16
+ from .engine import DEFAULT_CREDITS, DEFAULT_RELAY, DEFAULT_RPC, GRANTED_BY, Engine, setting
17
+ from .engine.bazaar import bazaar
18
+ from .engine.discovery import CACHE_CONTROL, discovery
19
+ from .engine.meter import bought_ms, time_micro, time_rate, time_text
20
+ from .engine.pricing import FreeLimit, Hangup, caller, deadline_ms, hearing, metered, price_of, running, stopped, unhearing, units, usd
21
+ from .engine.pricing import describe as describe_price
22
+ from .engine.settlement import Channels, StateFile
23
+ from .engine.settlement.mpp import Credential, McpPayment, MppError, Receipt
24
+ from .engine.wire import stringify
25
+ from .grants import gate_proof, key_address, new_key, sign_grant
26
+
27
+ META_PAYMENT = "x402/payment"
28
+ META_RESPONSE = "x402/payment-response"
29
+ META_GRANTED_BY = "zeam-pass/granted-by"
30
+ META_PRICE = "zeam-pass/price"
31
+ META_LINE = "zeam-pass/line"
32
+ META_METER = "zeam-pass/meter"
33
+ LINE_OPS = ("open", "prove", "on", "off", "status", "close")
34
+ CHANNEL = re.compile(r"^0x[0-9a-fA-F]{64}$")
35
+ MCP_SESSION = re.compile(r"^mcp\.[A-Za-z0-9_-]{16}\Z")
36
+ MCP_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
37
+ MAX_BODY = 1024 * 1024
38
+ PROBLEM_JSON = "application/problem+json"
39
+
40
+
41
+ def _problem(payload):
42
+ return isinstance(payload, dict) and isinstance(payload.get("type"), str) and payload["type"].startswith("https://paymentauth.org/problems/")
43
+
44
+
45
+ CORS = {"access-control-allow-origin": "*", "access-control-expose-headers": "PAYMENT-REQUIRED, PAYMENT-RESPONSE, WWW-Authenticate, Payment-Receipt, X-Pass-Granted-By, X-Pass-Ms-Remaining, X-Pass-Ms-Elapsed"}
46
+ PREFLIGHT = {**CORS, "access-control-allow-methods": "GET, POST, OPTIONS",
47
+ "access-control-allow-headers": "content-type, payment-signature, x-payment, authorization, payment-authorization, x-line, x-grant, mcp-protocol-version, mcp-session-id",
48
+ "access-control-max-age": "600"}
49
+ HOW = {"paywall": "Paid per call with x402 or MPP", "gate": "Admitted keys only, proven by a zero-value x402 signature; nothing is paid",
50
+ "both": "Admitted keys only, paid per call with x402 or MPP"}
51
+ RESULT_NOT_JSON = "the result is not valid JSON (a non-finite number or a non-JSON value); nothing was charged"
52
+ TYPES = {"string": (str,), "integer": (int,), "number": (int, float), "boolean": (bool,),
53
+ "object": (dict,), "array": (list,), "null": (type(None),)}
54
+
55
+ __all__ = ["Bridge", "Pass", "Engine", "Hangup", "deadline_ms", "gate_proof", "key_address", "new_key", "setting", "sign_grant", "stopped", "units"]
56
+
57
+
58
+ def _b64(obj):
59
+ return base64.b64encode(json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode()).decode()
60
+
61
+
62
+ def _unb64(value):
63
+ try:
64
+ return json.loads(base64.b64decode(value).decode())
65
+ except Exception:
66
+ return None
67
+
68
+
69
+ def _reject_constant(name):
70
+ raise ValueError(f"{name} is not a JSON number")
71
+
72
+
73
+ def _finite(text):
74
+ value = float(text)
75
+ if not math.isfinite(value):
76
+ raise ValueError(f"{text} is out of range")
77
+ return value
78
+
79
+
80
+ def _faithful(value):
81
+ if value is None or isinstance(value, (str, bool, int)):
82
+ return True
83
+ if isinstance(value, float):
84
+ return math.isfinite(value)
85
+ if isinstance(value, (list, tuple)):
86
+ return all(_faithful(v) for v in value)
87
+ if isinstance(value, dict):
88
+ return all(isinstance(k, str) and _faithful(v) for k, v in value.items())
89
+ return False
90
+
91
+
92
+ def _serializes(value):
93
+ try:
94
+ json.dumps(value, allow_nan=False)
95
+ except (TypeError, ValueError, RecursionError):
96
+ return False
97
+ return _faithful(value)
98
+
99
+
100
+ def _loads(raw):
101
+ return json.loads(raw, parse_constant=_reject_constant, parse_float=_finite)
102
+
103
+
104
+ def _is(value, kind):
105
+ if kind == "integer" and isinstance(value, float):
106
+ return value.is_integer()
107
+ if isinstance(value, bool) and kind != "boolean":
108
+ return False
109
+ return isinstance(value, TYPES.get(kind, (object,)))
110
+
111
+
112
+ def _number(value):
113
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
114
+
115
+
116
+ def _check_value(key, spec, value):
117
+ kinds = spec.get("type")
118
+ kinds = [kinds] if isinstance(kinds, str) else kinds if isinstance(kinds, list) else []
119
+ if kinds and not any(_is(value, k) for k in kinds):
120
+ return f"argument {key} must be {' or '.join(map(str, kinds))}"
121
+ if isinstance(spec.get("enum"), list) and value not in spec["enum"]:
122
+ return f"argument {key} must be one of {stringify(spec['enum'])}"
123
+ if _number(value):
124
+ if _number(spec.get("minimum")) and value < spec["minimum"]:
125
+ return f"argument {key} must be at least {spec['minimum']}"
126
+ if _number(spec.get("maximum")) and value > spec["maximum"]:
127
+ return f"argument {key} must be at most {spec['maximum']}"
128
+ if _number(spec.get("multipleOf")) and spec["multipleOf"] > 0 and value % spec["multipleOf"] != 0:
129
+ return f"argument {key} must be a multiple of {spec['multipleOf']}"
130
+ if isinstance(value, str):
131
+ if _number(spec.get("minLength")) and len(value) < spec["minLength"]:
132
+ return f"argument {key} must be at least {spec['minLength']} characters"
133
+ if _number(spec.get("maxLength")) and len(value) > spec["maxLength"]:
134
+ return f"argument {key} must be at most {spec['maxLength']} characters"
135
+ return None
136
+
137
+
138
+ def _check_object(schema, args, prefix, nested):
139
+ required = schema.get("required") if isinstance(schema.get("required"), list) else []
140
+ missing = [k for k in required if k not in args]
141
+ if missing:
142
+ return f"missing required argument: {prefix}{missing[0]}"
143
+ props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
144
+ for key, value in args.items():
145
+ spec = props.get(key)
146
+ if not isinstance(spec, dict):
147
+ continue
148
+ wrong = _check_value(prefix + str(key), spec, value)
149
+ if wrong:
150
+ return wrong
151
+ if nested and isinstance(value, dict):
152
+ wrong = _check_object(spec, value, f"{prefix}{key}.", False)
153
+ if wrong:
154
+ return wrong
155
+ return None
156
+
157
+
158
+ def _accepted(fn, args):
159
+ try:
160
+ params = inspect.signature(fn).parameters.values()
161
+ except (TypeError, ValueError):
162
+ return args
163
+ if any(p.kind == p.VAR_KEYWORD for p in params):
164
+ return args
165
+ names = {p.name for p in params if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)}
166
+ return {k: v for k, v in args.items() if k in names}
167
+
168
+
169
+ def _validate(schema, args):
170
+ if not isinstance(args, dict):
171
+ return "the arguments must be an object"
172
+ return _check_object(schema if isinstance(schema, dict) else {}, args, "", True)
173
+
174
+
175
+ def _said(out):
176
+ content = out.get("content") if isinstance(out, dict) else None
177
+ texts = [c["text"] for c in content if isinstance(c, dict) and isinstance(c.get("text"), str)] if isinstance(content, list) else []
178
+ return "\n".join(texts) or "the tool reported an error"
179
+
180
+
181
+ def _text(obj):
182
+ return [{"type": "text", "text": obj if isinstance(obj, str) else stringify(obj)}]
183
+
184
+
185
+ def _reason(status):
186
+ try:
187
+ return http.HTTPStatus(status).phrase
188
+ except ValueError:
189
+ return "Status"
190
+
191
+
192
+ def _first(headers, names):
193
+ for n in names:
194
+ v = headers.get(n)
195
+ if isinstance(v, str) and v.strip() != "":
196
+ return v.strip()
197
+ return ""
198
+
199
+
200
+ def _out_of_time(on_line, ms=None):
201
+ if on_line:
202
+ return {"error": "out_of_time", "message": "the line ran out of time during the call. Its time is spent. Buy time: buy_time."}
203
+ return {"error": "out_of_time", "message": f"the call ran past the {ms} ms its price buys. Nothing was charged. Buy time and call on a line."}
204
+
205
+
206
+ def _hung_up(on_line):
207
+ billed = "The line is billed for the time the call ran, nothing after." if on_line else "Nothing was charged."
208
+ return {"error": "client_gone", "message": f"the caller hung up or canceled the call, so it was stopped. {billed}"}
209
+
210
+
211
+ def _call_key(ident):
212
+ if isinstance(ident, str):
213
+ return "string", ident
214
+ if isinstance(ident, (int, float)) and not isinstance(ident, bool):
215
+ return "number", float(ident)
216
+ return None
217
+
218
+
219
+ def _int(value):
220
+ try:
221
+ return int(float(value)) if value is not None and not isinstance(value, bool) else 0
222
+ except (TypeError, ValueError, OverflowError):
223
+ return 0
224
+
225
+
226
+ def _reading(meta):
227
+ return {"x-pass-ms-remaining": str(meta["msRemaining"]), "x-pass-ms-elapsed": str(meta["elapsedMs"])}
228
+
229
+
230
+ def _metered(out, meta):
231
+ if not meta:
232
+ return out
233
+ return {**out, "_meta": {**(out["_meta"] if isinstance(out.get("_meta"), dict) else {}), META_METER: meta}}
234
+
235
+
236
+ def _failure(body):
237
+ return {"isError": True, "structuredContent": body, "content": _text(body)}
238
+
239
+
240
+ def _own_status(body):
241
+ s = body.get("status") if isinstance(body, dict) else None
242
+ return s if isinstance(s, int) and not isinstance(s, bool) and 400 <= s <= 599 and s != 402 else 500
243
+
244
+
245
+ def _failed(out, tool):
246
+ if isinstance(out.get("structuredContent"), dict):
247
+ return out
248
+ return {**out, "isError": True, "structuredContent": {"error": "tool_failed", "tool": tool, "message": _said(out)}}
249
+
250
+
251
+ def _settled_meta(headers):
252
+ meta = {}
253
+ settled = _unb64(headers["PAYMENT-RESPONSE"]) if headers.get("PAYMENT-RESPONSE") else None
254
+ if settled:
255
+ meta[META_RESPONSE] = settled
256
+ if isinstance(headers.get(GRANTED_BY), str):
257
+ meta[META_GRANTED_BY] = headers[GRANTED_BY]
258
+ if isinstance(headers.get("Payment-Receipt"), str):
259
+ try:
260
+ meta[McpPayment.RECEIPT] = Receipt.parse(headers["Payment-Receipt"])
261
+ except MppError:
262
+ pass
263
+ return meta
264
+
265
+
266
+ def _mpp_credential(headers):
267
+ offered = _first(headers, ("payment-authorization", "authorization"))
268
+ return offered if Credential.is_one(offered) else ""
269
+
270
+
271
+ def _mpp_meta(s):
272
+ try:
273
+ required = McpPayment.required(s["headers"].get("WWW-Authenticate"), s["body"])
274
+ except MppError:
275
+ required = None
276
+ return {} if required is None else {"_meta": {McpPayment.REQUIRED: required}}
277
+
278
+
279
+ class _RpcError(Exception):
280
+ def __init__(self, code, message):
281
+ super().__init__(message)
282
+ self.code = code
283
+
284
+
285
+ class Bridge:
286
+
287
+ def __init__(self, name, payout=None, mode="paywall", price=None, site=None, admit=None, relay=DEFAULT_RELAY,
288
+ credits=DEFAULT_CREDITS, rpc=DEFAULT_RPC, state_dir=None, server_name=None, version="2.0.0", on_empty="refuse",
289
+ fee_recipient=None, credit_issuer=None, refund_url=None, tick_seconds=60, relay_transport=None,
290
+ credits_http=None, settings=None, contact=None, payout_is_fee_recipient=False, prices=None, free=None, free_limit=None, time=None):
291
+ self.name = name
292
+ self.server_name = server_name or name
293
+ self.version = version
294
+ self.site = site.rstrip("/") if site else None
295
+ self.engine = Engine(name, payout, mode=mode, price=price, site=self.site, admit=admit, relay=relay, credits=credits,
296
+ rpc=rpc, state_dir=state_dir, fee_recipient=fee_recipient, credit_issuer=credit_issuer,
297
+ on_empty=on_empty, refund_url=refund_url, relay_transport=relay_transport,
298
+ credits_http=credits_http, settings=settings, tick_seconds=tick_seconds, contact=contact,
299
+ payout_is_fee_recipient=payout_is_fee_recipient, prices=prices, free=free, free_limit=free_limit, time=time)
300
+ self._free_limit = FreeLimit(self.engine.free_limit, self.engine._now_ms)
301
+ self._free_file = StateFile(self.engine.state_dir, "free.json") if self.engine.free_limit else None
302
+ self._tools = {}
303
+ self._calling = {}
304
+ self._calls = threading.Lock()
305
+ self.time = self.engine.time
306
+ self.meter = self.engine.meter
307
+ if self.time:
308
+ self._builtins()
309
+ self.engine.start()
310
+
311
+ def _builtins(self):
312
+ t = self.time
313
+ meter = self.meter
314
+
315
+ def buy_time(ms=None, blocks=None):
316
+ n = bought_ms(t, {"ms": ms, "blocks": blocks})
317
+ box = running() or {}
318
+ cid = box.get("channelId")
319
+ st = meter.status(cid)
320
+ return {"channelId": cid, "boughtMs": n, "msRemaining": st["msRemaining"] + n,
321
+ "paidUSD": usd(time_micro(t, n))}
322
+
323
+ def line(**args):
324
+ return self._line_tool(args, "")
325
+
326
+ self._tools["buy_time"] = {
327
+ "name": "buy_time", "builtin": "buy_time", "run": buy_time, "price": None, "unit": None, "free": False,
328
+ "description": f"Buys line time: {time_rate(t)}, for the channel that pays. Then open a line.",
329
+ "inputSchema": {"type": "object", "properties": {"ms": {
330
+ "type": "integer", "minimum": t["rateMs"], **({"multipleOf": t["rateMs"]} if t["rateMs"] > 1 else {}), **({"maximum": t["maxMs"]} if t.get("maxMs") else {}),
331
+ "description": f"milliseconds of line time; default {t['unitMs']}. Buying again adds time"}}},
332
+ }
333
+ self._tools["line"] = {
334
+ "name": "line", "builtin": "line", "run": line, "price": None, "unit": None, "free": True,
335
+ "description": ("A line spends bought time without a payment per call. op: open {channelId} returns a message to sign "
336
+ "with the payer key; prove {channelId, nonce, signature} returns the credential; on, off, status, close {credential}."),
337
+ "inputSchema": {"type": "object", "properties": {"op": {"enum": list(LINE_OPS)}, "channelId": {"type": "string"}, "nonce": {"type": "string"},
338
+ "signature": {"type": "string"}, "credential": {"type": "string"}}, "required": ["op"]},
339
+ }
340
+
341
+ @property
342
+ def mode(self):
343
+ return self.engine.mode
344
+
345
+ def tick(self):
346
+ return self.engine.tick()
347
+
348
+ def status(self):
349
+ return self.engine.status()
350
+
351
+ def payout_now(self):
352
+ return self.engine.payout_now()
353
+
354
+ def tool(self, name=None, description="", input_schema=None, price=None, unit=None, free=False, meter=None, output_schema=None):
355
+ def register(fn):
356
+ t = {
357
+ "name": name or fn.__name__,
358
+ "description": description or (fn.__doc__ or "").strip(),
359
+ "inputSchema": input_schema or {"type": "object"},
360
+ "run": fn,
361
+ "price": price,
362
+ "unit": unit,
363
+ "free": free is True,
364
+ }
365
+ if isinstance(output_schema, dict):
366
+ t["outputSchema"] = output_schema
367
+ if (self._tools.get(t["name"]) or {}).get("builtin"):
368
+ raise ValueError(f"bridge: {t['name']} is a built-in tool")
369
+ if meter is not None and meter != "time":
370
+ raise ValueError('bridge: meter is "time"')
371
+ if meter == "time":
372
+ if not self.time or self._is_free(t):
373
+ raise ValueError(f"bridge: {t['name']} meters time; set the seller's time option, and do not make it free")
374
+ t["meter"] = "time"
375
+ if not self._is_free(t) and self.mode != "gate" and (self._own_price(t) or not callable(self.engine.prices)):
376
+ self._price_for(t)
377
+ self._tools[t["name"]] = t
378
+ return fn
379
+ return register
380
+
381
+ def _is_free(self, tool):
382
+ return tool.get("free") is True or tool["name"] in self.engine.free
383
+
384
+ @staticmethod
385
+ def _own_price(tool):
386
+ return tool.get("price") is not None or tool.get("unit") is not None
387
+
388
+ def _asked(self, tool, args):
389
+ if tool.get("builtin") != "buy_time" or not isinstance(args, dict) or "ms" in args:
390
+ return args
391
+ blocks = args.get("blocks")
392
+ if isinstance(blocks, float) and blocks.is_integer():
393
+ blocks = int(blocks)
394
+ if isinstance(blocks, int) and not isinstance(blocks, bool) and 1 <= blocks <= 9007199254740991:
395
+ return {**args, "ms": blocks * self.time["unitMs"]}
396
+ return args
397
+
398
+ def _price_for(self, tool, args=None):
399
+ if tool.get("builtin") == "buy_time":
400
+ ms = bought_ms(self.time, args)
401
+ return {"micro": time_micro(self.time, ms), "unitMicro": None, "ms": ms}
402
+ fallback = self.engine.price_micro
403
+ prices = self.engine.prices
404
+ if self._own_price(tool):
405
+ return price_of({"price": tool.get("price"), "unit": tool.get("unit")}, fallback)
406
+ if callable(prices):
407
+ return price_of(prices(tool["name"], args if args is not None else {}), fallback)
408
+ return price_of(prices.get(tool["name"]) if isinstance(prices, dict) else None, fallback)
409
+
410
+ def _price_tag(self, tool):
411
+ if tool.get("builtin") == "line":
412
+ return {"usd": "0", "per": "call", "free": True}
413
+ if tool.get("builtin") == "buy_time":
414
+ return {"usd": usd(self.time["rateMicro"]), "per": "ms", "ms": self.time["rateMs"], **({"maxMs": self.time["maxMs"]} if self.time.get("maxMs") else {})}
415
+ if tool.get("meter") == "time" and not (callable(self.engine.prices) and not self._own_price(tool)):
416
+ p = self._price_for(tool)
417
+ return {"per": "time", "usd": usd(self.time["rateMicro"]), "ms": self.time["rateMs"],
418
+ "callUSD": usd(p["micro"]), "callMs": self.meter.call_ms(p["micro"])}
419
+ if self._is_free(tool):
420
+ return describe_price(None, free=True, per_hour=self.engine.free_limit)
421
+ if self.mode == "gate":
422
+ return {"usd": "0", "per": "call"}
423
+ if not self._own_price(tool) and callable(self.engine.prices):
424
+ return describe_price(None, varies=True)
425
+ return describe_price(self._price_for(tool))
426
+
427
+ def _listing(self, base=""):
428
+ if self.mode == "gate":
429
+ return {}
430
+ out = {"prices": {t["name"]: self._price_tag(t) for t in self._tools.values()}}
431
+ if self.time:
432
+ out["time"] = time_text(self.time, base)
433
+ return out
434
+
435
+ def tools(self):
436
+ return [self._describe(t) for t in self._tools.values()]
437
+
438
+ def _describe(self, tool):
439
+ return {"name": tool["name"], "description": tool["description"], "inputSchema": tool["inputSchema"], "_meta": {META_PRICE: self._price_tag(tool)}}
440
+
441
+ def _public(self, url):
442
+ if not self.site or not url:
443
+ return url
444
+ called, site = urllib.parse.urlsplit(url), urllib.parse.urlsplit(self.site)
445
+ return urllib.parse.urlunsplit((site.scheme, site.netloc, called.path, called.query, ""))
446
+
447
+ def _run(self, tool, args):
448
+ try:
449
+ out = tool["run"](**_accepted(tool["run"], args))
450
+ except TypeError as e:
451
+ return False, f"bad arguments: {e}", 400
452
+ except Exception as e:
453
+ return False, str(e), 500
454
+ if isinstance(out, dict) and out.get("isError") is True:
455
+ return False, out, 500
456
+ if not _serializes(out):
457
+ return False, RESULT_NOT_JSON, 500
458
+ return True, out, None
459
+
460
+ def _within(self, ms, tool, args, more=None):
461
+ result = {}
462
+ ctx = contextvars.copy_context()
463
+ mine = Hangup(caller())
464
+ ctx.run(hearing, mine)
465
+ wake = threading.Event()
466
+
467
+ def work():
468
+ try:
469
+ result["out"] = self._run(tool, args)
470
+ finally:
471
+ wake.set()
472
+
473
+ mine.listen(wake.set)
474
+ if not mine.gone:
475
+ threading.Thread(target=ctx.run, args=(work,), name=f"zeam-bridge-{tool['name']}", daemon=True).start()
476
+ while not mine.gone:
477
+ wake.wait(None if ms is None else min(max(0, ms) / 1000, threading.TIMEOUT_MAX))
478
+ if wake.is_set():
479
+ break
480
+ ms = more() if more is not None else 0
481
+ if not ms > 0:
482
+ break
483
+ mine.leave()
484
+ out = result.get("out")
485
+ if out is None:
486
+ mine.hang_up()
487
+ return out
488
+
489
+ @staticmethod
490
+ def _gone(out):
491
+ heard = caller()
492
+ return heard is not None and heard.gone and (out is None or not out[0])
493
+
494
+ def _bounded(self, ms, tool, args):
495
+ deadline = None if ms is None else self.engine._now_ms() + ms
496
+ box = running()
497
+ if box is not None and deadline is not None:
498
+ box["deadline"] = deadline
499
+ out = self._within(ms, tool, args)
500
+ if self._gone(out):
501
+ return False, {**_hung_up(False), "tool": tool["name"]}, 499
502
+ if deadline is not None and (out is None or (not out[0] and self.engine._now_ms() >= deadline)):
503
+ return False, _out_of_time(False, ms), 402
504
+ return out
505
+
506
+ def _bought(self, tool, args):
507
+ if tool.get("builtin") != "buy_time":
508
+ return None
509
+ ms = bought_ms(self.time, args)
510
+
511
+ def stated(hold, value):
512
+ if not isinstance(value, dict) or "msRemaining" not in value:
513
+ return value
514
+ try:
515
+ return {**value, "msRemaining": self.meter.status(hold["channelId"])["msRemaining"]}
516
+ except Exception:
517
+ return value
518
+
519
+ return {"credit": lambda hold: self.meter.credit(hold["channelId"], ms, hold["pendingId"]),
520
+ "undo": lambda hold: self.meter.uncredit(hold["channelId"], ms, hold["pendingId"]),
521
+ "stated": stated}
522
+
523
+ def _serve(self, tool, args, url, payment, grant, refund_url=None, price=None, via="http", credential=""):
524
+ resource = Engine.resource(url, tool["description"])
525
+ if tool.get("meter") == "time" and price is not None:
526
+ bound = self.meter.call_ms(price["micro"])
527
+ run = lambda: self._bounded(bound, tool, args)
528
+ elif caller() is not None:
529
+ run = lambda: self._bounded(None, tool, args)
530
+ else:
531
+ run = lambda: self._run(tool, args)
532
+ base = re.sub(r"/refund$", "", refund_url or "")
533
+ return self.engine.serve(tool["name"], resource, payment, grant, run, refund_url, price, self._listing(base), self._bought(tool, args), bazaar(tool, via), credential)
534
+
535
+ @staticmethod
536
+ def _timed_out(s):
537
+ return s.get("failed") and s["status"] == 402 and isinstance(s["body"], dict) and s["body"].get("error") == "out_of_time"
538
+
539
+ @staticmethod
540
+ def _hung(s):
541
+ return s.get("failed") and s["status"] == 499 and isinstance(s["body"], dict) and s["body"].get("error") == "client_gone"
542
+
543
+ def _free(self, tool, args, client, line=""):
544
+ if tool.get("builtin") == "line":
545
+ out = self._line_tool(args, line)
546
+ return {"ok": not out.get("isError"), "body": out, "status": 500}
547
+ who = client or ""
548
+ take = self._free_limit.take_saved(self._free_file, tool["name"], who) if self._free_file else self._free_limit.take(tool["name"], who)
549
+ if not take["ok"]:
550
+ return {"limited": FreeLimit.refusal(self.engine.free_limit, take["retryAfter"]), "retryAfter": take["retryAfter"]}
551
+ (ok, out, status), _units = metered(lambda: self._run(tool, args))
552
+ return {"ok": ok, "body": out, "status": status}
553
+
554
+ def _on_line(self, tool, args, credential, refund_url=None):
555
+ meter = self.meter
556
+ channel_id = meter.line(credential)
557
+ if not channel_id:
558
+ return {"status": 403, "body": {"error": "line_unknown", "message": 'no open line with that credential. Open one: POST {"op":"open","channelId":"0x…"} to this seller\'s line route, the address its 402 time line names.'}}
559
+ try:
560
+ ch = self.engine.parts()["server"].channels.judged(channel_id)
561
+ except Exception:
562
+ ch = None
563
+ if not ch:
564
+ return {"status": 403, "body": {"error": "line_unknown", "message": "no channel for this line."}}
565
+ if Channels.refunding_wait_ms(ch, meter._now()) > 0:
566
+ secs = Channels.refunding_retry_secs(self.engine.parts()["cfg"], ch, meter._now())
567
+ return {"status": 402, "body": {"error": "refund_settling", "message": f"a refund is settling on this channel, retry in {secs}s.", "retry_after_seconds": secs}}
568
+ if _int(ch.get("withdrawRequestedAt")) > 0:
569
+ return {"status": 402, "body": {"error": "channel_leaving", "message": "this channel is withdrawing. Open a new channel."}}
570
+ heard = caller()
571
+ if heard is not None and heard.gone:
572
+ return {"status": 499, "body": _hung_up(True)}
573
+ ident = secrets.token_hex(8)
574
+ b = meter.begin(channel_id, ident)
575
+ if not b["ok"]:
576
+ ms_remaining = meter.status(channel_id)["msRemaining"]
577
+ if b["code"] == "too_many_calls":
578
+ return {"status": 429, "body": {"error": "too_many_calls", "message": "too many calls are live on this line; wait for one to finish, then retry.", "msRemaining": ms_remaining}}
579
+ at = re.sub(r"/refund$", "", refund_url or "")
580
+ why = f'the meter is off. Switch it on: POST {at}/line {{"op":"on","credential":"<yours>"}}.' if b["code"] == "meter_off" else "this line has no time left. Buy time: buy_time."
581
+ return {"status": 402, "body": {"error": b["code"], "message": why, "msRemaining": ms_remaining}}
582
+ line = {"deadline": b["deadline"], "renewed": False, "spent": False}
583
+ turn = threading.Lock()
584
+
585
+ def renew():
586
+ with turn:
587
+ if not line["spent"] and meter._now() >= line["deadline"]:
588
+ r = meter.renew(channel_id, ident, line["deadline"])
589
+ if r["ok"] and r["deadline"] > line["deadline"]:
590
+ line.update(deadline=r["deadline"], renewed=True)
591
+ else:
592
+ line["spent"] = True
593
+ return line["deadline"]
594
+
595
+ out, _units = metered(lambda: self._within(b["deadline"] - meter._now(), tool, args, lambda: renew() - meter._now()), deadline=b["deadline"], renew=renew, channelId=channel_id)
596
+ ended = meter._now()
597
+ gone = self._gone(out)
598
+ cut = not gone and (out is None or (ended >= line["deadline"] and not out[0]))
599
+ e = meter.end(channel_id, ident) if cut else meter.end(channel_id, ident, b["started"], ended)
600
+ meta = {"channelId": channel_id, "msRemaining": e["msRemaining"], "elapsedMs": ended - b["started"] if line["renewed"] and not cut else e["elapsedMs"]}
601
+ if cut or (not gone and e["over"]):
602
+ return {"status": 402, "body": {**_out_of_time(True), "msRemaining": e["msRemaining"]}}
603
+ if gone:
604
+ return {"status": 499, "body": {**_hung_up(True), "msRemaining": meta["msRemaining"], "elapsedMs": meta["elapsedMs"]}, "meta": meta}
605
+ ok, value, status = out
606
+ if not ok:
607
+ return {"status": status, "failed": value, "meta": meta}
608
+ return {"status": 200, "value": value, "meta": meta}
609
+
610
+ def _line_tool(self, args, header_credential):
611
+ l = self._line_op(args, header_credential)
612
+ return l["body"] if l["status"] == 200 else {"isError": True, "structuredContent": l["body"], "content": _text(l["body"])}
613
+
614
+ def _line_op(self, args, header_credential=""):
615
+ def fail(status, code, why):
616
+ return {"status": status, "body": {"op": "line_failed", "code": code, "why": why}}
617
+
618
+ if not self.time:
619
+ return fail(404, "no_time", "this seller sells no line time")
620
+ if not isinstance(args, dict) or args.get("op") not in LINE_OPS:
621
+ return fail(400, "bad_request", "op is open, prove, on, off, status or close")
622
+ meter = self.meter
623
+ op = args["op"]
624
+ if op in ("open", "prove"):
625
+ cid = args.get("channelId")
626
+ ident = cid.lower() if isinstance(cid, str) and CHANNEL.match(cid) else None
627
+ if not ident:
628
+ return fail(400, "bad_request", 'channelId is missing or is not 0x and 64 hex characters: send "channelId":"0x…" with the op')
629
+ ch = self.engine.channel(ident) if ident else None
630
+ if not ch or not ch.get("channelConfig"):
631
+ return fail(404, "unknown_channel", "no channel with that id here. Pay a call on it first, e.g. buy_time.")
632
+ if _int(ch.get("withdrawRequestedAt")) > 0:
633
+ return fail(409, "channel_leaving", "this channel is withdrawing. Open a new channel.")
634
+ if op == "open":
635
+ c = meter.challenge(ident)
636
+ return {"status": 200, "body": {"op": "challenge", "channelId": ident, "nonce": c["nonce"], "sign": c["message"], "expiresInSeconds": c["expiresInSeconds"]}}
637
+ r = meter.prove(ch, args.get("nonce"), args.get("signature"))
638
+ if not r["ok"]:
639
+ return fail(r["status"], r["code"], r["why"])
640
+ return {"status": 200, "body": {"op": "opened", "credential": r["credential"], "channelId": ident, "msRemaining": r["msRemaining"], "metering": True}}
641
+ credential = args["credential"] if isinstance(args.get("credential"), str) and args["credential"] != "" else header_credential
642
+ if not credential:
643
+ return fail(400, "bad_request", 'credential is missing or is not a string: add "credential":"<yours>" to the body, or send it as the x-line header')
644
+ ident = meter.line(credential)
645
+ if not ident:
646
+ return fail(403, "line_unknown", "no open line with that credential")
647
+ if op == "close":
648
+ meter.close(credential)
649
+ return {"status": 200, "body": {"op": "closed", "channelId": ident}}
650
+ if op == "on":
651
+ holder = "0x" + secrets.token_hex(16)
652
+ channels = self.engine.parts()["server"].channels
653
+ marked = channels.mark(ident, holder, False)
654
+ if marked.get("unavailable"):
655
+ return {"status": 503, "body": {"op": "line_failed", "code": "line_unavailable", "why": "the line could not be read right now. Retry."}}
656
+ if "settling" in marked:
657
+ secs = marked["settling"]
658
+ return {"status": 402, "body": {"op": "line_failed", "code": "refund_settling", "why": f"a refund is settling on this channel, retry in {secs}s.", "retry_after_seconds": secs}}
659
+ if "judged" in marked:
660
+ secs = marked["judged"]
661
+ return {"status": 402, "body": {"op": "line_failed", "code": "refund_pending", "why": f"a refund asked for on this channel is being worked out, and the meter stays off until it is answered. Retry in {secs}s.", "retry_after_seconds": secs}}
662
+ try:
663
+ meter.switch(ident, True)
664
+ finally:
665
+ channels.unmark(ident, holder)
666
+ elif op != "status":
667
+ meter.switch(ident, False)
668
+ return {"status": 200, "body": {"op": op, **meter.status(ident)}}
669
+
670
+ def _probe(self, tool, args, payment, credential):
671
+ return payment == "" and isinstance(args, dict) and not args and not tool.get("builtin") and not self._is_free(tool) and not (tool.get("meter") == "time" and credential)
672
+
673
+ def _probe_terms(self, tool, resource, grant, refund_url, via="http"):
674
+ try:
675
+ price = None if self.mode == "gate" else self._price_for(tool, {})
676
+ except Exception:
677
+ return None
678
+ s = self._serve(tool, {}, resource, "", grant, refund_url, price, via)
679
+ return None if s.get("delivered") or s.get("failed") else s
680
+
681
+ def _price_or_refusal(self, tool, args):
682
+ if self.mode == "gate":
683
+ return None, None
684
+ try:
685
+ return self._price_for(tool, args), None
686
+ except Exception as e:
687
+ return None, {"error": "price_invalid", "tool": tool["name"], "message": str(e)}
688
+
689
+ @staticmethod
690
+ def _http_failure(tool_name, out, status):
691
+ if isinstance(out, dict):
692
+ fields = _failed(out, tool_name)["structuredContent"]
693
+ body = {"error": "tool_failed", "message": _said(out), "tool": tool_name, **fields}
694
+ wait = body.get("retry_after_seconds")
695
+ return _own_status(fields), ({"retry-after": str(wait)} if isinstance(wait, int) and not isinstance(wait, bool) and wait > 0 else {}), body
696
+ return status, {}, {"error": "invalid_arguments" if status == 400 else "tool_failed", "message": out, "tool": tool_name}
697
+
698
+ def _unknown_tool(self, tool):
699
+ return {"error": "unknown_tool", "message": f"no tool named {tool} here; tools lists the ones there are", "tool": tool, "tools": list(self._tools)}
700
+
701
+ def _http(self, tool_name, headers, body, resource=None, refund_url=None, client=""):
702
+ tool = self._tools.get(tool_name)
703
+ if not tool and tool_name == "line":
704
+ l = self._line_op({})
705
+ return l["status"], {}, l["body"]
706
+ if not tool:
707
+ return 404, {}, self._unknown_tool(tool_name)
708
+ try:
709
+ args = _loads(body if body and body.strip() else b"{}")
710
+ except ValueError as e:
711
+ return 400, {}, {"error": "invalid_arguments", "message": f"the body is not JSON: {e}", "tool": tool_name}
712
+ wrong = _validate(tool["inputSchema"], self._asked(tool, args))
713
+ if wrong and self._probe(tool, args, _first(headers, ("payment-signature", "x-payment")) or ("paid" if _mpp_credential(headers) else ""), _first(headers, ("x-line",))):
714
+ s = self._probe_terms(tool, resource, _first(headers, ("x-grant",)), refund_url)
715
+ if s:
716
+ return s["status"], s["headers"], s["body"]
717
+ if wrong:
718
+ return 400, {}, {"error": "invalid_arguments", "message": wrong, "tool": tool_name}
719
+ if tool.get("builtin") == "line":
720
+ l = self._line_op(args, _first(headers, ("x-line",)))
721
+ return l["status"], {}, l["body"]
722
+ if self._is_free(tool):
723
+ f = self._free(tool, args, client, _first(headers, ("x-line",)))
724
+ if "limited" in f:
725
+ return 429, {"retry-after": str(f["retryAfter"])}, f["limited"]
726
+ if not f["ok"]:
727
+ return self._http_failure(tool_name, f["body"], f["status"])
728
+ return 200, {}, f["body"]
729
+ credential = _first(headers, ("x-line",))
730
+ if tool.get("meter") == "time" and credential:
731
+ l = self._on_line(tool, args, credential, refund_url)
732
+ if "failed" in l:
733
+ f = self._http_failure(tool_name, l["failed"], l["status"])
734
+ return f[0], {**f[1], **_reading(l["meta"])}, {**f[2], "msRemaining": l["meta"]["msRemaining"], "elapsedMs": l["meta"]["elapsedMs"]}
735
+ if l["status"] != 200:
736
+ return l["status"], _reading(l["meta"]) if "meta" in l else {}, l["body"]
737
+ return 200, _reading(l["meta"]), l["value"]
738
+ price, invalid = self._price_or_refusal(tool, args)
739
+ if invalid:
740
+ return 500, {}, invalid
741
+ s = self._serve(tool, args, resource, _first(headers, ("payment-signature", "x-payment")), _first(headers, ("x-grant",)), refund_url, price, "http", _mpp_credential(headers))
742
+ if self._timed_out(s):
743
+ return 402, {}, s["body"]
744
+ if self._hung(s):
745
+ return 499, {}, s["body"]
746
+ if s.get("failed"):
747
+ return self._http_failure(tool_name, s["body"], s["status"])
748
+ return s["status"], s["headers"], s["body"]
749
+
750
+ @staticmethod
751
+ def _payment_result(s):
752
+ body = s["body"] if isinstance(s["body"], dict) else {}
753
+ required = _unb64(s["headers"].get("PAYMENT-REQUIRED", "")) if s["headers"].get("PAYMENT-REQUIRED") else None
754
+ if not isinstance(required, dict):
755
+ required = body if "accepts" in body else None
756
+ if not required and s["status"] == 402:
757
+ required = {"x402Version": 2, "accepts": [], **body}
758
+ if required:
759
+ required = {"x402Version": 2, **body, **required}
760
+ return {"isError": True, "structuredContent": required, "content": _text(required), **_mpp_meta(s)}
761
+ if isinstance(s["body"], dict):
762
+ return {"isError": True, "structuredContent": s["body"], "content": _text(s["body"]), **_mpp_meta(s)}
763
+ return {"isError": True, "content": _text(body), **_mpp_meta(s)}
764
+
765
+ @staticmethod
766
+ def _mcp_failure(out, status, name):
767
+ if isinstance(out, dict):
768
+ return _failed(out, name)
769
+ return _failure({"error": "invalid_arguments" if status == 400 else "tool_failed", "tool": name, "message": out})
770
+
771
+ @staticmethod
772
+ def _delivered(out, meta=None):
773
+ extra = {"_meta": meta} if meta else {}
774
+ structured = {"structuredContent": out} if isinstance(out, dict) else {}
775
+ return {"content": _text(out), **structured, **extra}
776
+
777
+ def _mcp_call(self, params, resource=None, grant="", refund_url=None, client="", line=""):
778
+ if not isinstance(params, dict):
779
+ raise _RpcError(-32602, "params must be an object")
780
+ name = params.get("name")
781
+ if not isinstance(name, str):
782
+ raise _RpcError(-32602, "params.name must be a string")
783
+ meta = params.get("_meta", {})
784
+ if meta is None:
785
+ meta = {}
786
+ if not isinstance(meta, dict):
787
+ raise _RpcError(-32602, "params._meta must be an object")
788
+ args = params.get("arguments", {})
789
+ if args is None:
790
+ args = {}
791
+ if not isinstance(args, dict):
792
+ raise _RpcError(-32602, "params.arguments must be an object")
793
+ payment = meta.get(META_PAYMENT)
794
+ paying = payment is not None and payment is not False and payment != 0 and payment != ""
795
+ tool = self._tools.get(name)
796
+ if not tool:
797
+ return _failure(self._unknown_tool(name))
798
+ wrong = _validate(tool["inputSchema"], self._asked(tool, args))
799
+ credential = meta.get(META_LINE).strip() if isinstance(meta.get(META_LINE), str) and meta[META_LINE].strip() else (line or "").strip()
800
+ if wrong and self._probe(tool, args, "paid" if paying or meta.get(McpPayment.CREDENTIAL) else "", credential):
801
+ s = self._probe_terms(tool, resource, grant, refund_url, "mcp")
802
+ if s:
803
+ return self._payment_result(s)
804
+ if wrong:
805
+ return _failure({"error": "invalid_arguments", "tool": name, "message": wrong})
806
+ if self._is_free(tool):
807
+ f = self._free(tool, args, client, credential)
808
+ if "limited" in f:
809
+ return _failure(f["limited"])
810
+ if not f["ok"]:
811
+ return self._mcp_failure(f["body"], f["status"], name)
812
+ return self._delivered(f["body"])
813
+ if tool.get("meter") == "time" and credential:
814
+ l = self._on_line(tool, args, credential, refund_url)
815
+ if "failed" in l:
816
+ return _metered(self._mcp_failure(l["failed"], l["status"], name), l["meta"])
817
+ if l["status"] != 200:
818
+ return _metered(_failure(l["body"]), l.get("meta"))
819
+ return self._delivered(l["value"], {META_METER: l["meta"]})
820
+ price, invalid = self._price_or_refusal(tool, args)
821
+ if invalid:
822
+ return _failure(invalid)
823
+ s = self._serve(tool, args, resource, _b64(payment) if paying else "", grant, refund_url, price, "mcp", "" if paying else McpPayment.header(meta))
824
+ if self._timed_out(s) or self._hung(s):
825
+ return _failure(s["body"])
826
+ if s.get("failed"):
827
+ return self._mcp_failure(s["body"], s["status"], name)
828
+ if not s["delivered"]:
829
+ return self._payment_result(s)
830
+ return self._delivered(s["body"], _settled_meta(s["headers"]))
831
+
832
+ def _takes_mpp(self):
833
+ if self.mode == "gate":
834
+ return False
835
+ try:
836
+ return self.engine.parts()["server"].mpp_asset() is not None
837
+ except Exception:
838
+ return False
839
+
840
+ def _callers(self, line, session, meta):
841
+ named = meta.get(META_LINE) if isinstance(meta, dict) else None
842
+ credential = named.strip() if isinstance(named, str) and named.strip() else (line or "").strip()
843
+ ident = session.strip() if isinstance(session, str) else ""
844
+ by = set()
845
+ if credential:
846
+ by.add(f"line {credential}")
847
+ if McpPayment.in_session(ident) or MCP_SESSION.match(ident):
848
+ by.add(f"session {ident}")
849
+ return by
850
+
851
+ def _called(self, ident, line, session, meta):
852
+ key = _call_key(ident)
853
+ call = (Hangup(caller()), self._callers(line, session, meta))
854
+ if key is not None:
855
+ with self._calls:
856
+ self._calling.setdefault(key, []).append(call)
857
+
858
+ def land():
859
+ call[0].leave()
860
+ with self._calls:
861
+ live = [c for c in self._calling.get(key, ()) if c is not call]
862
+ if live:
863
+ self._calling[key] = live
864
+ else:
865
+ self._calling.pop(key, None)
866
+
867
+ return call[0], land
868
+
869
+ def _canceled(self, msg, line, session):
870
+ params = msg.get("params")
871
+ if msg.get("method") != "notifications/cancelled" or not isinstance(params, dict):
872
+ return
873
+ key = _call_key(params.get("requestId"))
874
+ if key is None:
875
+ return
876
+ by = self._callers(line, session, params.get("_meta"))
877
+ with self._calls:
878
+ found = [c for c in self._calling.get(key, ()) if c[1] & by]
879
+ for c in found:
880
+ c[0].hang_up()
881
+
882
+ def _mcp_one(self, msg, resource=None, grant="", refund_url=None, client="", line="", payment=False, session=""):
883
+ if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
884
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}}
885
+ if "id" not in msg:
886
+ self._canceled(msg, line, session)
887
+ return None
888
+ ident = msg["id"]
889
+ try:
890
+ method = msg["method"]
891
+ params = msg.get("params")
892
+ if params is not None and not isinstance(params, (dict, list)):
893
+ raise _RpcError(-32602, "params must be an object")
894
+ if method == "initialize":
895
+ asked = params.get("protocolVersion") if isinstance(params, dict) else None
896
+ declared = isinstance(params, dict) and McpPayment.declares(params.get("capabilities")) and self._takes_mpp() and self._sells_by_charge()
897
+ result = {"protocolVersion": asked if asked in MCP_VERSIONS else MCP_VERSIONS[0],
898
+ "capabilities": {"tools": {}, **({"experimental": {"payment": McpPayment.capability()}} if declared else {})},
899
+ "serverInfo": {"name": self.server_name, "version": self.version}}
900
+ elif method == "ping":
901
+ result = {}
902
+ elif method == "tools/list":
903
+ result = {"tools": self.tools()}
904
+ elif method == "tools/call":
905
+ asked = McpPayment.params(msg)
906
+ heard, land = self._called(ident, line, session, asked.get("_meta") if isinstance(asked, dict) else None)
907
+ token = hearing(heard)
908
+ try:
909
+ result = self._mcp_call(asked, resource, grant, refund_url, client, line)
910
+ finally:
911
+ unhearing(token)
912
+ land()
913
+ unpaid = McpPayment.error(result.get("_meta", {}).get(McpPayment.REQUIRED), result.get("structuredContent")) if payment else None
914
+ if unpaid is not None:
915
+ return {"jsonrpc": "2.0", "id": ident, "error": unpaid}
916
+ else:
917
+ raise _RpcError(-32601, f"method not found: {method}")
918
+ return {"jsonrpc": "2.0", "id": ident, "result": result}
919
+ except _RpcError as e:
920
+ return {"jsonrpc": "2.0", "id": ident, "error": {"code": e.code, "message": str(e)}}
921
+ except Exception as e:
922
+ return {"jsonrpc": "2.0", "id": ident, "error": {"code": -32603, "message": f"internal error: {e}"}}
923
+
924
+ def _mcp(self, body, resource=None, grant="", refund_url=None, client="", line="", session=""):
925
+ try:
926
+ data = _loads(body or b"")
927
+ except ValueError:
928
+ return 400, {}, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
929
+ batch = isinstance(data, list)
930
+ if batch and not data:
931
+ return 400, {}, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request: empty batch"}}
932
+ items = data if batch else [data]
933
+ payment = McpPayment.in_session(session) and self._takes_mpp()
934
+ one = lambda m: self._mcp_one(m, resource, grant, refund_url, client, line, payment, session)
935
+ opened = {}
936
+ if not batch and isinstance(data, dict) and data.get("method") == "initialize" and "id" in data:
937
+ declared = isinstance(data.get("params"), dict) and McpPayment.declares(data["params"].get("capabilities")) and self._takes_mpp()
938
+ issued = McpPayment.session() if declared else "mcp." + base64.urlsafe_b64encode(secrets.token_bytes(12)).decode()
939
+ opened = {"mcp-session-id": issued, "access-control-expose-headers": CORS["access-control-expose-headers"] + ", Mcp-Session-Id"}
940
+ if len(items) > 1:
941
+ carried = [contextvars.copy_context() for _ in items]
942
+ with ThreadPoolExecutor(max_workers=len(items)) as pool:
943
+ found = list(pool.map(lambda pair: pair[0].run(one, pair[1]), zip(carried, items)))
944
+ else:
945
+ found = [one(m) for m in items]
946
+ answers = [a for a in found if a is not None]
947
+ if not answers:
948
+ return 202, {}, None
949
+ return 200, opened, answers if batch else answers[0]
950
+
951
+ def _responses(self, tool):
952
+ if self._is_free(tool):
953
+ out = {"200": {"description": "the result"}, "400": {"description": "invalid arguments"}, "500": {"description": "the tool failed"}}
954
+ if self.engine.free_limit and not tool.get("builtin"):
955
+ out["429"] = {"description": f"over {self.engine.free_limit} free calls an hour per address"}
956
+ out["413"] = {"description": "the body is over the size this seller reads; nothing is charged"}
957
+ if tool.get("name") == "line":
958
+ out.update({"400": {"description": "invalid arguments (error invalid_arguments), or a step refused (op line_failed, with code and why)"}, "402": {"description": "a refund settling on the channel answers refund_settling; \"on\" while a refund asked for on the channel is being worked out answers refund_pending"}, "403": {"description": "no line with that credential, or the proof is not the payer's"},
959
+ "404": {"description": "no channel with that id here, or this seller sells no line time"}, "409": {"description": "the channel is leaving; code and why say so"},
960
+ "503": {"description": "the line could not be read right now (code line_unavailable; nothing changed, send it again)"}})
961
+ return out
962
+ timed = tool.get("meter") == "time"
963
+ unavailable = ("the check failed (error payment_unavailable), this gate has no checks left this month (error gate_credit_exhausted)" if self.mode == "gate"
964
+ else "the payment check or settlement failed (error payment_unavailable)")
965
+ out = {"200": {"description": "the result"},
966
+ "400": {"description": "invalid arguments; nothing is charged" if self.mode == "gate" else "invalid arguments (error invalid_arguments), or a payment that does not decode (error bad_payment_header) or is neither a voucher nor a deposit (error bad_payment_type); nothing is charged"},
967
+ "402": {"description": "a zero-value x402 signature is required; nothing is paid" if self.mode == "gate" else
968
+ ("x402 payment required" if tool.get("builtin") == "buy_time" or self._price_tag(tool).get("per") == "unit" else "payment required: x402, or MPP when the 402 carries a Payment challenge")
969
+ + ("; on a line, the meter is off or the line is out of time" if timed else "") + "; a refund settling on the channel answers refund_settling"},
970
+ "403": {"description": "key not admitted" + ("; on a line, no line with that credential" if timed else "")},
971
+ "413": {"description": "the body is over the size this seller reads; nothing is charged"},
972
+ "500": {"description": "the tool failed; nothing is charged per call" + (", and on a line the call has used the time it ran" if timed else "")},
973
+ "503": {"description": unavailable + ", or this seller's setup is incomplete (error not_ready); nothing is charged. Retry later"}}
974
+ if timed:
975
+ out["429"] = {"description": "too many calls are live on the line (error too_many_calls)"}
976
+ return out
977
+
978
+ def _sells_by_charge(self):
979
+ if self._mpp_offers():
980
+ return True
981
+ return callable(self.engine.prices) and any(not self._is_free(t) and not t.get("builtin") and not self._own_price(t) for t in self._tools.values())
982
+
983
+ def _mpp_offers(self):
984
+ if self.mode == "gate":
985
+ return {}
986
+ try:
987
+ server = self.engine.parts()["server"]
988
+ except Exception:
989
+ return {}
990
+ offers = {}
991
+ for t in self._tools.values():
992
+ if self._is_free(t) or (not self._own_price(t) and callable(self.engine.prices)):
993
+ continue
994
+ try:
995
+ offer = server.mpp_offer(self._price_for(t, {}), t.get("description"))
996
+ except Exception:
997
+ offer = None
998
+ if offer is not None:
999
+ offers[t["name"]] = offer
1000
+ return offers
1001
+
1002
+ def discovery(self, origin, mount=""):
1003
+ base = (self._public(origin.rstrip("/") + mount) or "").rstrip("/")
1004
+ return self.openapi(base, mount)
1005
+
1006
+ def openapi(self, base_url, mount=None):
1007
+ paths = {f"/v1/{t['name']}": {"post": {"operationId": t["name"], "summary": t["description"], "x-price": self._price_tag(t),
1008
+ "requestBody": {"required": True, "content": {"application/json": {"schema": t["inputSchema"]}}},
1009
+ "responses": self._responses(t)}}
1010
+ for t in self._tools.values()}
1011
+ info = {"title": self.server_name, "version": self.version,
1012
+ "description": f"{HOW[self.mode]}. MCP: {base_url}/mcp"}
1013
+ contact = self.engine.contact
1014
+ if contact:
1015
+ info["contact"] = {"email": contact[7:]} if contact.lower().startswith("mailto:") else {"url": contact}
1016
+ doc = {"openapi": "3.0.3", "info": info, "servers": [{"url": base_url}], "paths": paths}
1017
+ if mount is None:
1018
+ return discovery(doc, self._mpp_offers(), self.site)
1019
+ return discovery(doc, self._mpp_offers(), self.site, base_url[:len(base_url) - len(mount)], mount)
1020
+
1021
+ def refund(self, body):
1022
+ try:
1023
+ args = json.loads(body or b"{}")
1024
+ except ValueError:
1025
+ return 400, {}, {"op": "refund_failed", "code": "invalid_json", "why": "the body is not JSON"}
1026
+ if not isinstance(args, dict):
1027
+ return 400, {}, {"op": "refund_failed", "code": "invalid_request", "why": "expected { channelId, issued, signature }, optional selfSend or gasPayment"}
1028
+ try:
1029
+ r = self.engine.refund(args)
1030
+ except Exception as e:
1031
+ return 503, {}, {"op": "refund_failed", "code": "refund_unavailable", "why": f"the refund could not be handled right now: {e}", "retry_after_seconds": 5}
1032
+ return r["status"], {}, r["body"]
1033
+
1034
+ @staticmethod
1035
+ def ours(path):
1036
+ return path in ("/mcp", "/openapi.json", "/refund", "/line") or path.startswith("/v1/")
1037
+
1038
+ def handle(self, method, path, headers, body, base_url="", client="", hangup=None):
1039
+ token = None if hangup is None else hearing(hangup)
1040
+ try:
1041
+ out = self._handle(method, path, {k.lower(): v for k, v in headers.items()}, body, base_url, client or "")
1042
+ finally:
1043
+ if token is not None:
1044
+ unhearing(token)
1045
+ if out is None:
1046
+ return None
1047
+ status, extra, payload = out
1048
+ return status, {**CORS, **extra, **({"content-type": PROBLEM_JSON} if _problem(payload) else {})}, payload
1049
+
1050
+ def _handle(self, method, path, headers, body, base_url, client=""):
1051
+ if method == "OPTIONS" and self.ours(path):
1052
+ return 204, dict(PREFLIGHT), None
1053
+ refund_at = self._public(base_url + "/refund")
1054
+ mcp = self._public(base_url + "/mcp")
1055
+ if path == "/mcp":
1056
+ if method != "POST":
1057
+ return 405, {"allow": "POST, OPTIONS"}, None
1058
+ return self._mcp(body, mcp, _first(headers, ("x-grant",)), refund_at, client, _first(headers, ("x-line",)), _first(headers, ("mcp-session-id",)))
1059
+ if path == "/openapi.json" and method == "GET":
1060
+ return 200, {"cache-control": CACHE_CONTROL}, self.openapi((self._public(base_url) or base_url).rstrip("/"))
1061
+ if path == "/refund" and method == "POST":
1062
+ return self.refund(body)
1063
+ if path == "/line" and method == "POST":
1064
+ if not self.time:
1065
+ l = self._line_op({})
1066
+ return l["status"], {}, l["body"]
1067
+ try:
1068
+ args = _loads(body or b"")
1069
+ except ValueError:
1070
+ return 400, {}, {"op": "line_failed", "code": "bad_request", "why": "the body is not JSON"}
1071
+ l = self._line_op(args, _first(headers, ("x-line",)))
1072
+ return l["status"], {}, l["body"]
1073
+ if path.startswith("/v1/") and method == "POST":
1074
+ return self._http(path[4:], headers, body, self._public(base_url + path), refund_at, client)
1075
+ return None
1076
+
1077
+ def asgi(self, fallback=None):
1078
+ async def app(scope, receive, send):
1079
+ if scope["type"] != "http":
1080
+ if fallback:
1081
+ return await fallback(scope, receive, send)
1082
+ return
1083
+ path = scope["path"][len(scope.get("root_path", "")):] or "/"
1084
+ if not self.ours(path) and fallback:
1085
+ return await fallback(scope, receive, send)
1086
+ headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
1087
+ try:
1088
+ declared = int(headers.get("content-length") or 0)
1089
+ except ValueError:
1090
+ declared = 0
1091
+ chunks, size, big = [], 0, declared > MAX_BODY
1092
+ while not big:
1093
+ event = await receive()
1094
+ if event.get("type") == "http.disconnect":
1095
+ return
1096
+ chunk = event.get("body", b"")
1097
+ size += len(chunk)
1098
+ if size > MAX_BODY:
1099
+ big = True
1100
+ break
1101
+ chunks.append(chunk)
1102
+ if not event.get("more_body"):
1103
+ break
1104
+ body = b"".join(chunks)
1105
+ gone = Hangup()
1106
+ if big:
1107
+ out = (413, dict(CORS), {"error": "body_too_large", "message": f"the body is over {MAX_BODY} bytes", "limit": MAX_BODY})
1108
+ else:
1109
+ host = headers.get("host", "localhost")
1110
+ base_url = f"{scope.get('scheme', 'http')}://{host}{scope.get('root_path', '')}"
1111
+ peer = scope.get("client")
1112
+ client = str(peer[0]) if isinstance(peer, (list, tuple)) and peer and peer[0] is not None else ""
1113
+
1114
+ async def listen():
1115
+ try:
1116
+ while (await receive()).get("type") != "http.disconnect":
1117
+ pass
1118
+ except Exception:
1119
+ return
1120
+ gone.hang_up()
1121
+
1122
+ listener = asyncio.ensure_future(listen())
1123
+ try:
1124
+ out = await asyncio.to_thread(self.handle, scope["method"], path, headers, body, base_url, client, gone)
1125
+ except BaseException:
1126
+ gone.hang_up()
1127
+ raise
1128
+ finally:
1129
+ listener.cancel()
1130
+ if out is None:
1131
+ if fallback:
1132
+ replayed = [{"type": "http.request", "body": body, "more_body": False}]
1133
+
1134
+ async def again():
1135
+ if replayed:
1136
+ return replayed.pop()
1137
+ return {"type": "http.disconnect"} if gone.gone else await receive()
1138
+ return await fallback(scope, again, send)
1139
+ out = (404, {}, {"error": "not_found", "message": "nothing is served at this path"})
1140
+ status, extra, payload = out
1141
+ raw = b"" if payload is None else stringify(payload).encode()
1142
+ hdrs = [(b"content-type", str(extra.get("content-type", "application/json")).encode())] + [(k.lower().encode(), str(v).encode()) for k, v in extra.items() if k.lower() != "content-type"]
1143
+ try:
1144
+ await send({"type": "http.response.start", "status": status, "headers": hdrs})
1145
+ await send({"type": "http.response.body", "body": raw})
1146
+ except Exception:
1147
+ if not gone.gone:
1148
+ raise
1149
+ return app
1150
+
1151
+ def wsgi(self, fallback=None):
1152
+ def app(environ, start_response):
1153
+ path = environ.get("PATH_INFO") or "/"
1154
+ try:
1155
+ path = path.encode("latin-1").decode("utf-8")
1156
+ except (UnicodeEncodeError, UnicodeDecodeError):
1157
+ pass
1158
+ if not self.ours(path) and fallback:
1159
+ return fallback(environ, start_response)
1160
+ try:
1161
+ length = max(int(environ.get("CONTENT_LENGTH") or 0), 0)
1162
+ except ValueError:
1163
+ length = 0
1164
+ if length > MAX_BODY:
1165
+ out = (413, dict(CORS), {"error": "body_too_large", "message": f"the body is over {MAX_BODY} bytes", "limit": MAX_BODY})
1166
+ body = b""
1167
+ else:
1168
+ body = environ["wsgi.input"].read(length) if length else b""
1169
+ headers = {k[5:].replace("_", "-").lower(): v for k, v in environ.items() if k.startswith("HTTP_")}
1170
+ if environ.get("CONTENT_TYPE"):
1171
+ headers["content-type"] = environ["CONTENT_TYPE"]
1172
+ base_url = f"{environ.get('wsgi.url_scheme', 'http')}://{environ.get('HTTP_HOST', 'localhost')}{environ.get('SCRIPT_NAME', '')}"
1173
+ out = self.handle(environ["REQUEST_METHOD"], path, headers, body, base_url, environ.get("REMOTE_ADDR") or "")
1174
+ if out is None:
1175
+ if fallback:
1176
+ environ["wsgi.input"] = io.BytesIO(body)
1177
+ environ["CONTENT_LENGTH"] = str(len(body))
1178
+ return fallback(environ, start_response)
1179
+ out = (404, {}, {"error": "not_found", "message": "nothing is served at this path"})
1180
+ status, extra, payload = out
1181
+ raw = b"" if payload is None else stringify(payload).encode()
1182
+ start_response(f"{status} {_reason(status)}", [("content-type", str(extra.get("content-type", "application/json")))] + [(k, str(v)) for k, v in extra.items() if k.lower() != "content-type"])
1183
+ return [raw]
1184
+ return app
1185
+
1186
+ def paid(self, fn):
1187
+ priced = {"name": fn.__name__}
1188
+ if self.mode != "gate" and not callable(self.engine.prices):
1189
+ self._price_for(priced)
1190
+
1191
+ @functools.wraps(fn)
1192
+ def wrapper(*args, **kwargs):
1193
+ from flask import request, make_response
1194
+ box = {}
1195
+ price, invalid = self._price_or_refusal(priced, dict(kwargs))
1196
+ if invalid:
1197
+ return self._flask_json(make_response, invalid, 500)
1198
+
1199
+ def run():
1200
+ box["resp"] = make_response(fn(*args, **kwargs))
1201
+ return (box["resp"].status_code < 400), box["resp"], box["resp"].status_code
1202
+
1203
+ headers = {k.lower(): v for k, v in request.headers.items()}
1204
+ resource = Engine.resource(self._public(request.url), (fn.__doc__ or "").strip())
1205
+ s = self.engine.serve(fn.__name__, resource, _first(headers, ("payment-signature", "x-payment")), _first(headers, ("x-grant",)), run, None, price, self._listing(), None, None, _mpp_credential(headers))
1206
+ if s.get("failed"):
1207
+ return s["body"]
1208
+ if not s["delivered"]:
1209
+ return self._flask(self._flask_json(make_response, s["body"], s["status"]), s["headers"])
1210
+ return self._flask(s["body"], s["headers"])
1211
+ return wrapper
1212
+
1213
+ def refund_view(self):
1214
+ def refund():
1215
+ from flask import request, make_response
1216
+ status, _, body = self.refund(request.get_data())
1217
+ return self._flask_json(make_response, body, status)
1218
+ return refund
1219
+
1220
+ @staticmethod
1221
+ def _flask_json(make_response, body, status):
1222
+ resp = make_response(stringify(body), status)
1223
+ resp.headers["Content-Type"] = PROBLEM_JSON if _problem(body) else "application/json"
1224
+ return resp
1225
+
1226
+ @staticmethod
1227
+ def _flask(resp, headers):
1228
+ for k, v in headers.items():
1229
+ resp.headers[k] = v
1230
+ return resp
1231
+
1232
+
1233
+ Pass = Bridge