alloc-context 0.1.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 (85) hide show
  1. alloc_context-0.1.0.dist-info/METADATA +154 -0
  2. alloc_context-0.1.0.dist-info/RECORD +85 -0
  3. alloc_context-0.1.0.dist-info/WHEEL +5 -0
  4. alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
  5. alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. alloc_context-0.1.0.dist-info/top_level.txt +1 -0
  7. alloccontext/__init__.py +3 -0
  8. alloccontext/__main__.py +149 -0
  9. alloccontext/config.py +415 -0
  10. alloccontext/horizon.py +30 -0
  11. alloccontext/ingest/__init__.py +1 -0
  12. alloccontext/ingest/cf_benchmarks.py +38 -0
  13. alloccontext/ingest/cf_history.py +65 -0
  14. alloccontext/ingest/coinbase_client.py +234 -0
  15. alloccontext/ingest/coinbase_portfolio.py +53 -0
  16. alloccontext/ingest/coingecko.py +148 -0
  17. alloccontext/ingest/coinmarketcap.py +135 -0
  18. alloccontext/ingest/env_keys.py +12 -0
  19. alloccontext/ingest/etf_flows.py +282 -0
  20. alloccontext/ingest/exchange/__init__.py +4 -0
  21. alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
  22. alloccontext/ingest/exchange/kraken_adapter.py +66 -0
  23. alloccontext/ingest/exchange/live.py +95 -0
  24. alloccontext/ingest/exchange/portfolio.py +8 -0
  25. alloccontext/ingest/exchange/registry.py +27 -0
  26. alloccontext/ingest/exchange/types.py +5 -0
  27. alloccontext/ingest/exchange_http.py +28 -0
  28. alloccontext/ingest/fear_greed.py +89 -0
  29. alloccontext/ingest/fred.py +138 -0
  30. alloccontext/ingest/http_errors.py +29 -0
  31. alloccontext/ingest/kalshi.py +84 -0
  32. alloccontext/ingest/kalshi_api.py +199 -0
  33. alloccontext/ingest/kalshi_client.py +95 -0
  34. alloccontext/ingest/kalshi_files.py +44 -0
  35. alloccontext/ingest/kalshi_state.py +67 -0
  36. alloccontext/ingest/kraken_client.py +177 -0
  37. alloccontext/ingest/kraken_portfolio.py +161 -0
  38. alloccontext/ingest/macro_calendar.py +310 -0
  39. alloccontext/ingest/macro_normalize.py +98 -0
  40. alloccontext/ingest/market_snapshots.py +113 -0
  41. alloccontext/ingest/outcome.py +110 -0
  42. alloccontext/ingest/parse_helpers.py +23 -0
  43. alloccontext/ingest/runner.py +148 -0
  44. alloccontext/mcp/__init__.py +1 -0
  45. alloccontext/mcp/assets.py +153 -0
  46. alloccontext/mcp/bazaar.py +630 -0
  47. alloccontext/mcp/contracts.py +286 -0
  48. alloccontext/mcp/handlers.py +487 -0
  49. alloccontext/mcp/http.py +250 -0
  50. alloccontext/mcp/payment_middleware.py +211 -0
  51. alloccontext/mcp/server.py +319 -0
  52. alloccontext/mcp/staleness.py +30 -0
  53. alloccontext/mcp/validation.py +56 -0
  54. alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
  55. alloccontext/mcp/x402_config.py +131 -0
  56. alloccontext/mcp/x402_pricing.py +55 -0
  57. alloccontext/mcp/x402_stables.py +179 -0
  58. alloccontext/rollup/__init__.py +1 -0
  59. alloccontext/rollup/band.py +50 -0
  60. alloccontext/rollup/breadth.py +45 -0
  61. alloccontext/rollup/cf_math.py +103 -0
  62. alloccontext/rollup/cluster.py +149 -0
  63. alloccontext/rollup/cluster_config.py +86 -0
  64. alloccontext/rollup/comparison.py +67 -0
  65. alloccontext/rollup/context.py +118 -0
  66. alloccontext/rollup/delta.py +109 -0
  67. alloccontext/rollup/etf.py +113 -0
  68. alloccontext/rollup/fear_greed.py +61 -0
  69. alloccontext/rollup/macro.py +185 -0
  70. alloccontext/rollup/portfolio.py +137 -0
  71. alloccontext/rollup/rebalance.py +125 -0
  72. alloccontext/rollup/regime.py +188 -0
  73. alloccontext/rollup/sentiment.py +118 -0
  74. alloccontext/rollup/snapshots.py +64 -0
  75. alloccontext/rollup/tape.py +176 -0
  76. alloccontext/status_report.py +321 -0
  77. alloccontext/store/__init__.py +0 -0
  78. alloccontext/store/db.py +216 -0
  79. alloccontext/store/jsonutil.py +10 -0
  80. alloccontext/store/meta.py +20 -0
  81. alloccontext/store/retention.py +63 -0
  82. alloccontext/store/status.py +89 -0
  83. alloccontext/timeutil.py +11 -0
  84. alloccontext/x402_production_check.py +193 -0
  85. alloccontext/x402_smoke_redact.py +41 -0
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from alloccontext.config import load_config
7
+ from alloccontext.mcp import handlers
8
+ from alloccontext.store.db import connect
9
+
10
+
11
+ def _transport_security_settings(*, host: str):
12
+ from urllib.parse import urlparse
13
+
14
+ from mcp.server.transport_security import TransportSecuritySettings
15
+
16
+ from alloccontext.mcp.bazaar import resolve_public_base_url
17
+
18
+ public = resolve_public_base_url()
19
+ if not public:
20
+ return None
21
+
22
+ parsed = urlparse(public if "://" in public else f"https://{public}")
23
+ hostname = parsed.hostname
24
+ if not hostname:
25
+ return None
26
+
27
+ scheme = parsed.scheme or "https"
28
+ return TransportSecuritySettings(
29
+ enable_dns_rebinding_protection=True,
30
+ allowed_hosts=[
31
+ "127.0.0.1:*",
32
+ "localhost:*",
33
+ "[::1]:*",
34
+ hostname,
35
+ f"{hostname}:*",
36
+ ],
37
+ allowed_origins=[
38
+ f"{scheme}://{hostname}:*",
39
+ public.rstrip("/"),
40
+ ],
41
+ )
42
+
43
+
44
+ def _require_mcp():
45
+ try:
46
+ from mcp.server.fastmcp import FastMCP
47
+ except ImportError as exc:
48
+ raise RuntimeError(
49
+ "MCP support requires the mcp package: pip install 'alloc-context[mcp]'"
50
+ ) from exc
51
+ return FastMCP
52
+
53
+
54
+ def create_server(
55
+ *,
56
+ config_path: str | None = None,
57
+ host: str = "127.0.0.1",
58
+ port: int = 8000,
59
+ stateless_http: bool = True,
60
+ ):
61
+ FastMCP = _require_mcp()
62
+ if config_path:
63
+ os.environ.setdefault("ALLOC_CONTEXT_CONFIG", config_path)
64
+
65
+ config = load_config(config_path)
66
+
67
+ mcp = FastMCP(
68
+ "alloc-context",
69
+ json_response=True,
70
+ host=host,
71
+ port=port,
72
+ stateless_http=stateless_http,
73
+ transport_security=_transport_security_settings(host=host),
74
+ instructions=(
75
+ "BTC/ETH allocation context: fused market backdrop from cached ingest, "
76
+ "USD rebalance moves, and allocation band checks. Facts only — no LLM."
77
+ ),
78
+ )
79
+
80
+ @mcp.tool(
81
+ name="get_context_bundle",
82
+ description=(
83
+ "Full ContextBundle JSON: portfolio, market, sentiment, macro, regime "
84
+ "hints, and delta vs the prior saved snapshot. Optional assets filter "
85
+ "(default BTC, ETH), target_pct, and band override server config for "
86
+ "drift math. freshness=cached uses the local ingest DB; freshness=live "
87
+ "runs ingest first."
88
+ ),
89
+ )
90
+ def get_context_bundle(
91
+ scope: str = "daily",
92
+ freshness: str = "cached",
93
+ assets: list[str] | None = None,
94
+ target_pct: dict[str, float] | None = None,
95
+ band: float | None = None,
96
+ ) -> dict[str, Any]:
97
+ """Return the full deterministic context bundle for daily or weekly scope."""
98
+ validated_scope = handlers.validate_scope(scope)
99
+ validated_freshness = handlers.validate_freshness(freshness)
100
+ conn = connect(config.paths.db)
101
+ try:
102
+ return handlers.get_context_bundle(
103
+ conn,
104
+ config,
105
+ scope=validated_scope,
106
+ freshness=validated_freshness,
107
+ assets=assets,
108
+ target_pct=target_pct,
109
+ band=band,
110
+ )
111
+ finally:
112
+ conn.close()
113
+
114
+ @mcp.tool(
115
+ name="get_market_context",
116
+ description=(
117
+ "Fused market backdrop: sentiment (Fear & Greed, Kalshi), macro events, "
118
+ "FRED indicators, ETF flows, and market breadth. Optional assets filter "
119
+ "(default BTC, ETH). freshness=cached uses the local ingest DB; "
120
+ "freshness=live runs ingest first (requires ingest API keys on the host)."
121
+ ),
122
+ )
123
+ def get_market_context(
124
+ scope: str = "daily",
125
+ freshness: str = "cached",
126
+ assets: list[str] | None = None,
127
+ ) -> dict[str, Any]:
128
+ """Return ContextBundle subset for daily or weekly scope."""
129
+ validated_scope = handlers.validate_scope(scope)
130
+ validated_freshness = handlers.validate_freshness(freshness)
131
+ conn = connect(config.paths.db)
132
+ try:
133
+ return handlers.get_market_context(
134
+ conn,
135
+ config,
136
+ scope=validated_scope,
137
+ freshness=validated_freshness,
138
+ assets=assets,
139
+ )
140
+ finally:
141
+ conn.close()
142
+
143
+ @mcp.tool(
144
+ name="get_rebalance_plan",
145
+ description=(
146
+ "USD deltas and exchange-style move lines to reach a target BTC/ETH/CASH "
147
+ "split. Requires allocation_pct, target_pct, and nav_usd. Optional band "
148
+ "returns a band_check block alongside the plan. exchange=kraken|coinbase "
149
+ "adjusts move wording."
150
+ ),
151
+ )
152
+ def get_rebalance_plan(
153
+ allocation_pct: dict[str, float],
154
+ target_pct: dict[str, float],
155
+ nav_usd: float,
156
+ exchange: str = "kraken",
157
+ band: float | None = None,
158
+ ) -> dict[str, Any]:
159
+ """Compute rebalance plan from current allocation and NAV."""
160
+ return handlers.get_rebalance_plan(
161
+ allocation_pct,
162
+ target_pct,
163
+ nav_usd,
164
+ exchange=exchange,
165
+ band=band,
166
+ )
167
+
168
+ @mcp.tool(
169
+ name="get_portfolio_state",
170
+ description=(
171
+ "Live portfolio NAV, allocation, drift, and band hint from "
172
+ "read-only exchange credentials passed in the request. Credentials are "
173
+ "never stored. Supports kraken and coinbase."
174
+ ),
175
+ )
176
+ def get_portfolio_state(
177
+ exchange: str,
178
+ api_key: str,
179
+ api_secret: str,
180
+ target_pct: dict[str, float] | None = None,
181
+ band: float | None = None,
182
+ ) -> dict[str, Any]:
183
+ """Fetch live portfolio state using caller-supplied read-only API keys."""
184
+ return handlers.get_portfolio_state(
185
+ config,
186
+ exchange=exchange,
187
+ api_key=api_key,
188
+ api_secret=api_secret,
189
+ target_pct=target_pct,
190
+ band=band,
191
+ )
192
+
193
+ @mcp.tool(
194
+ name="check_allocation_band",
195
+ description=(
196
+ "Check whether BTC/ETH/CASH allocation is outside a drift band vs "
197
+ "target_pct and return hint (within_band, consider_rebalance, etc.). "
198
+ "All three inputs are required — use get_context_bundle with target_pct "
199
+ "and band when you want cached portfolio drift from server config."
200
+ ),
201
+ )
202
+ def check_allocation_band(
203
+ allocation_pct: dict[str, float],
204
+ target_pct: dict[str, float],
205
+ band: float = 0.15,
206
+ ) -> dict[str, Any]:
207
+ """Evaluate allocation drift against band width (default 0.15 = 15%)."""
208
+ return handlers.check_band(allocation_pct, target_pct, band)
209
+
210
+ @mcp.tool(
211
+ name="get_context_at",
212
+ description=(
213
+ "Load a saved ContextBundle snapshot from ingest history. "
214
+ "as_of is an ISO timestamp; match=at_or_before returns the latest "
215
+ "snapshot on or before that time."
216
+ ),
217
+ )
218
+ def get_context_at(
219
+ as_of: str,
220
+ scope: str = "daily",
221
+ match: str = "at_or_before",
222
+ assets: list[str] | None = None,
223
+ target_pct: dict[str, float] | None = None,
224
+ band: float | None = None,
225
+ ) -> dict[str, Any]:
226
+ validated_scope = handlers.validate_scope(scope)
227
+ if match not in ("exact", "at_or_before"):
228
+ raise ValueError("match must be 'exact' or 'at_or_before'")
229
+ conn = connect(config.paths.db)
230
+ try:
231
+ return handlers.get_context_at(
232
+ conn,
233
+ config,
234
+ scope=validated_scope,
235
+ as_of=as_of,
236
+ match=match, # type: ignore[arg-type]
237
+ assets=assets,
238
+ target_pct=target_pct,
239
+ band=band,
240
+ )
241
+ finally:
242
+ conn.close()
243
+
244
+ @mcp.tool(
245
+ name="get_context_delta",
246
+ description=(
247
+ "Compare two ContextBundle snapshots and return notable_shifts. "
248
+ "prior_as_of is required; omit current_as_of for latest live bundle."
249
+ ),
250
+ )
251
+ def get_context_delta(
252
+ prior_as_of: str,
253
+ scope: str = "daily",
254
+ current_as_of: str | None = None,
255
+ assets: list[str] | None = None,
256
+ ) -> dict[str, Any]:
257
+ validated_scope = handlers.validate_scope(scope)
258
+ conn = connect(config.paths.db)
259
+ try:
260
+ return handlers.get_context_delta(
261
+ conn,
262
+ config,
263
+ scope=validated_scope,
264
+ prior_as_of=prior_as_of,
265
+ current_as_of=current_as_of,
266
+ assets=assets,
267
+ )
268
+ finally:
269
+ conn.close()
270
+
271
+ @mcp.tool(
272
+ name="check_allocation_bands",
273
+ description=(
274
+ "Evaluate allocation drift against multiple target_pct/band "
275
+ "scenarios in one call. Each scenario needs target_pct; optional "
276
+ "name and band (default 0.15)."
277
+ ),
278
+ )
279
+ def check_allocation_bands(
280
+ allocation_pct: dict[str, float],
281
+ scenarios: list[dict[str, Any]],
282
+ ) -> dict[str, Any]:
283
+ return handlers.check_allocation_bands(allocation_pct, scenarios)
284
+
285
+ schema_path = (
286
+ __import__("pathlib").Path(__file__).resolve().parent.parent.parent
287
+ / "schemas"
288
+ / "context-bundle.v1.json"
289
+ )
290
+
291
+ @mcp.resource("context-bundle://schema/v1")
292
+ def context_bundle_schema() -> str:
293
+ """ContextBundle JSON Schema for agent validation."""
294
+ return schema_path.read_text(encoding="utf-8")
295
+
296
+ @mcp.resource("alloc-context://tools/rebalance-hints")
297
+ def rebalance_hint_guide() -> str:
298
+ """Meaning of portfolio rebalance_hint codes."""
299
+ return (
300
+ "rebalance_hint codes: within_band — drift inside band; "
301
+ "consider_deploy_cash — cash above target; "
302
+ "consider_trim_to_cash — cash below target; "
303
+ "consider_rebalance — drift exceeds band."
304
+ )
305
+
306
+ return mcp
307
+
308
+
309
+ def run_stdio(*, config_path: str | None = None) -> None:
310
+ mcp = create_server(config_path=config_path)
311
+ mcp.run(transport="stdio")
312
+
313
+
314
+ def main() -> None:
315
+ run_stdio()
316
+
317
+
318
+ if __name__ == "__main__":
319
+ main()
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from alloccontext.timeutil import utc_now
7
+
8
+
9
+ def parse_as_of(value: str) -> datetime:
10
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
11
+ if dt.tzinfo is None:
12
+ dt = dt.replace(tzinfo=timezone.utc)
13
+ return dt
14
+
15
+
16
+ def age_seconds(as_of: datetime, *, now: datetime | None = None) -> int:
17
+ ref = now or utc_now()
18
+ if as_of.tzinfo is None:
19
+ as_of = as_of.replace(tzinfo=timezone.utc)
20
+ return max(0, int((ref - as_of).total_seconds()))
21
+
22
+
23
+ def with_staleness(payload: dict[str, Any], *, as_of: datetime) -> dict[str, Any]:
24
+ if as_of.tzinfo is None:
25
+ as_of = as_of.replace(tzinfo=timezone.utc)
26
+ return {
27
+ "as_of": as_of.replace(microsecond=0).isoformat(),
28
+ "age_seconds": age_seconds(as_of),
29
+ **payload,
30
+ }
@@ -0,0 +1,56 @@
1
+ """Input validation for MCP financial tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _ASSETS = ("BTC", "ETH", "CASH")
8
+ _PCT_SUM_TOLERANCE = 0.02
9
+
10
+
11
+ class McpValidationError(ValueError):
12
+ """Raised when MCP tool inputs fail validation."""
13
+
14
+
15
+ def validate_target_pct(values: dict[str, Any]) -> dict[str, float]:
16
+ if not isinstance(values, dict):
17
+ raise McpValidationError("target_pct must be an object")
18
+ normalized: dict[str, float] = {}
19
+ for asset in _ASSETS:
20
+ raw = values.get(asset)
21
+ if raw is None:
22
+ normalized[asset] = 0.0
23
+ continue
24
+ try:
25
+ pct = float(raw)
26
+ except (TypeError, ValueError) as exc:
27
+ raise McpValidationError(f"target_pct.{asset} must be a number") from exc
28
+ if pct < 0 or pct > 1:
29
+ raise McpValidationError(f"target_pct.{asset} must be between 0 and 1")
30
+ normalized[asset] = pct
31
+ total = sum(normalized.values())
32
+ if abs(total - 1.0) > _PCT_SUM_TOLERANCE:
33
+ raise McpValidationError(
34
+ f"target_pct must sum to approximately 1 (got {total:.4f})"
35
+ )
36
+ return normalized
37
+
38
+
39
+ def validate_band(band: Any) -> float:
40
+ try:
41
+ value = float(band)
42
+ except (TypeError, ValueError) as exc:
43
+ raise McpValidationError("band must be a number") from exc
44
+ if not 0 < value < 1:
45
+ raise McpValidationError("band must be between 0 and 1 exclusive")
46
+ return value
47
+
48
+
49
+ def validate_nav_usd(nav_usd: Any) -> float:
50
+ try:
51
+ value = float(nav_usd)
52
+ except (TypeError, ValueError) as exc:
53
+ raise McpValidationError("nav_usd must be a number") from exc
54
+ if value <= 0:
55
+ raise McpValidationError("nav_usd must be positive")
56
+ return value
@@ -0,0 +1,104 @@
1
+ """Dynamic Bazaar metadata for MCP tools/call on POST /mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from contextvars import ContextVar, Token
7
+ from typing import Any
8
+
9
+ from alloccontext.mcp.bazaar import (
10
+ BAZAAR_INDEX_TAGS,
11
+ BAZAAR_SERVICE_NAME,
12
+ LISTING_DESCRIPTION,
13
+ build_http_route_extensions,
14
+ build_mcp_tool_extensions,
15
+ mcp_tool_specs,
16
+ )
17
+ from alloccontext.mcp.x402_pricing import read_mcp_request_json
18
+ from x402.http.types import HTTPRequestContext, RouteConfig
19
+ from x402.http.x402_http_server import x402HTTPResourceServer
20
+
21
+ _mcp_tool_ctx: ContextVar[str | None] = ContextVar("alloc_mcp_bazaar_tool", default=None)
22
+ _TOOL_EXTENSIONS = build_mcp_tool_extensions()
23
+ _TOOL_DESCRIPTIONS = {spec["tool_name"]: spec["description"] for spec in mcp_tool_specs()}
24
+ _RESOURCE_INFO_PATCHED = False
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True)
28
+ class BazaarIndexResourceInfo:
29
+ service_name: str
30
+ tags: tuple[str, ...]
31
+
32
+
33
+ def bazaar_index_resource_info() -> BazaarIndexResourceInfo:
34
+ return BazaarIndexResourceInfo(
35
+ service_name=BAZAAR_SERVICE_NAME,
36
+ tags=BAZAAR_INDEX_TAGS,
37
+ )
38
+
39
+
40
+ def mcp_tool_name_from_body(body: dict[str, Any] | None) -> str | None:
41
+ if not body or body.get("method") != "tools/call":
42
+ return None
43
+ params = body.get("params")
44
+ if not isinstance(params, dict):
45
+ return None
46
+ name = params.get("name")
47
+ return name if isinstance(name, str) and name in _TOOL_EXTENSIONS else None
48
+
49
+
50
+ def patch_resource_info_for_bazaar() -> None:
51
+ """Attach CDP service_name/tags to x402 ResourceInfo when unset."""
52
+ global _RESOURCE_INFO_PATCHED
53
+ if _RESOURCE_INFO_PATCHED:
54
+ return
55
+ from x402.schemas.payments import ResourceInfo
56
+
57
+ original_init = ResourceInfo.__init__
58
+
59
+ def _init_with_bazaar_metadata(self, *args: Any, **kwargs: Any) -> None:
60
+ meta = bazaar_index_resource_info()
61
+ kwargs.setdefault("service_name", meta.service_name)
62
+ kwargs.setdefault("tags", list(meta.tags))
63
+ original_init(self, *args, **kwargs)
64
+
65
+ ResourceInfo.__init__ = _init_with_bazaar_metadata # type: ignore[method-assign]
66
+ _RESOURCE_INFO_PATCHED = True
67
+
68
+
69
+ class AllocContextHTTPResourceServer(x402HTTPResourceServer):
70
+ """Select per-tool Bazaar extensions from tools/call JSON body."""
71
+
72
+ async def process_http_request(self, context, paywall_config=None): # type: ignore[no-untyped-def]
73
+ token: Token[str | None] | None = None
74
+ if context.method == "POST" and context.path.rstrip("/").endswith("/mcp"):
75
+ body = await read_mcp_request_json(context)
76
+ tool_name = mcp_tool_name_from_body(body)
77
+ if tool_name:
78
+ token = _mcp_tool_ctx.set(tool_name)
79
+ try:
80
+ return await super().process_http_request(context, paywall_config)
81
+ finally:
82
+ if token is not None:
83
+ _mcp_tool_ctx.reset(token)
84
+
85
+ def _get_route_config(self, path: str, method: str): # type: ignore[no-untyped-def]
86
+ match = super()._get_route_config(path, method)
87
+ if match is None:
88
+ return match
89
+ route_config, pattern = match
90
+ tool_name = _mcp_tool_ctx.get()
91
+ if not tool_name:
92
+ return match
93
+ tool_extension = _TOOL_EXTENSIONS.get(tool_name)
94
+ if tool_extension is None:
95
+ return match
96
+ description = _TOOL_DESCRIPTIONS.get(tool_name, route_config.description)
97
+ return (
98
+ dataclasses.replace(
99
+ route_config,
100
+ extensions=tool_extension,
101
+ description=description or LISTING_DESCRIPTION,
102
+ ),
103
+ pattern,
104
+ )
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from alloccontext.mcp.bazaar import (
7
+ LISTING_DESCRIPTION,
8
+ build_http_route_extensions,
9
+ public_mcp_url,
10
+ resolve_public_base_url,
11
+ )
12
+ from alloccontext.mcp.x402_pricing import DEFAULT_MCP_PRICE_HEAVY
13
+ from alloccontext.mcp.x402_stables import (
14
+ build_payment_options_for_stables,
15
+ effective_accepted_stable_symbols,
16
+ load_accepted_stable_symbols,
17
+ )
18
+ from x402.extensions.bazaar import bazaar_resource_server_extension
19
+ from x402.http import FacilitatorConfig, HTTPFacilitatorClient
20
+ from x402.http.constants import DEFAULT_FACILITATOR_URL
21
+ from x402.http.types import RouteConfig
22
+ from x402.server import x402ResourceServer
23
+
24
+ CDP_FACILITATOR_URL = "https://api.cdp.coinbase.com/platform/v2/x402"
25
+ DEFAULT_NETWORK = "eip155:84532" # Base Sepolia
26
+ DEFAULT_MCP_PRICE = "$0.02"
27
+ MCP_HTTP_PATH = "/mcp"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class X402Settings:
32
+ enabled: bool
33
+ pay_to: str
34
+ facilitator_url: str
35
+ network: str
36
+ mcp_price: str
37
+ mcp_price_heavy: str = DEFAULT_MCP_PRICE_HEAVY
38
+ mcp_path: str = MCP_HTTP_PATH
39
+ accepted_stables: tuple[str, ...] = ()
40
+
41
+
42
+ def load_x402_settings(*, require_payment: bool = False) -> X402Settings:
43
+ pay_to = os.environ.get("X402_PAY_TO", "").strip()
44
+ if require_payment and not pay_to:
45
+ raise RuntimeError("X402_PAY_TO is required when x402 is enabled")
46
+
47
+ facilitator_url = os.environ.get("X402_FACILITATOR_URL", DEFAULT_FACILITATOR_URL).strip()
48
+ network = os.environ.get("X402_NETWORK", DEFAULT_NETWORK).strip()
49
+ mcp_price = os.environ.get("X402_PRICE_MCP", DEFAULT_MCP_PRICE).strip()
50
+ mcp_price_heavy = os.environ.get(
51
+ "X402_PRICE_MCP_HEAVY", DEFAULT_MCP_PRICE_HEAVY
52
+ ).strip()
53
+ mcp_path = os.environ.get("X402_MCP_PATH", MCP_HTTP_PATH).strip() or MCP_HTTP_PATH
54
+ if mcp_path != MCP_HTTP_PATH:
55
+ raise RuntimeError(
56
+ f"X402_MCP_PATH must be {MCP_HTTP_PATH!r} (got {mcp_path!r}); "
57
+ "custom paths are unsupported until the MCP HTTP mount is configurable"
58
+ )
59
+ enabled = require_payment and bool(pay_to)
60
+
61
+ return X402Settings(
62
+ enabled=enabled,
63
+ pay_to=pay_to,
64
+ facilitator_url=facilitator_url,
65
+ network=network,
66
+ mcp_price=mcp_price,
67
+ mcp_price_heavy=mcp_price_heavy,
68
+ mcp_path=mcp_path,
69
+ accepted_stables=load_accepted_stable_symbols(),
70
+ )
71
+
72
+
73
+ def _is_cdp_facilitator_url(url: str) -> bool:
74
+ return url.rstrip("/").startswith(CDP_FACILITATOR_URL.rstrip("/"))
75
+
76
+
77
+ def build_x402_facilitator_client(settings: X402Settings) -> HTTPFacilitatorClient:
78
+ if _is_cdp_facilitator_url(settings.facilitator_url):
79
+ try:
80
+ from cdp.x402 import create_facilitator_config
81
+ except ImportError as exc:
82
+ raise RuntimeError(
83
+ "CDP facilitator requires cdp-sdk (pip install 'alloc-context[hosted]')"
84
+ ) from exc
85
+ if not cdp_facilitator_configured():
86
+ raise RuntimeError(
87
+ "CDP facilitator requires CDP_API_KEY_ID and CDP_API_KEY_SECRET"
88
+ )
89
+ return HTTPFacilitatorClient(create_facilitator_config())
90
+
91
+ return HTTPFacilitatorClient(FacilitatorConfig(url=settings.facilitator_url))
92
+
93
+
94
+ def build_x402_resource_server(settings: X402Settings) -> x402ResourceServer:
95
+ from x402.mechanisms.evm.exact import ExactEvmServerScheme
96
+
97
+ facilitator = build_x402_facilitator_client(settings)
98
+ server = x402ResourceServer(facilitator)
99
+ server.register(settings.network, ExactEvmServerScheme())
100
+ server.register_extension(bazaar_resource_server_extension)
101
+ return server
102
+
103
+
104
+ def _route_resource_url(settings: X402Settings) -> str | None:
105
+ public_base = resolve_public_base_url()
106
+ if not public_base:
107
+ return None
108
+ return public_mcp_url(base_url=public_base, mcp_path=settings.mcp_path)
109
+
110
+
111
+ def build_x402_routes(settings: X402Settings) -> dict[str, RouteConfig]:
112
+ accepts = build_payment_options_for_stables(
113
+ pay_to=settings.pay_to,
114
+ network=settings.network,
115
+ light_price=settings.mcp_price,
116
+ heavy_price=settings.mcp_price_heavy,
117
+ symbols=effective_accepted_stable_symbols(settings.accepted_stables),
118
+ )
119
+ return {
120
+ f"POST {settings.mcp_path}": RouteConfig(
121
+ accepts=accepts,
122
+ resource=_route_resource_url(settings),
123
+ mime_type="application/json",
124
+ description=LISTING_DESCRIPTION,
125
+ extensions=build_http_route_extensions(),
126
+ ),
127
+ }
128
+
129
+
130
+ def cdp_facilitator_configured() -> bool:
131
+ return bool(os.environ.get("CDP_API_KEY_ID") and os.environ.get("CDP_API_KEY_SECRET"))
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from x402.http.types import HTTPRequestContext
6
+
7
+ DEFAULT_MCP_PRICE_HEAVY = "$0.05"
8
+
9
+ HEAVY_MCP_TOOLS = frozenset({"get_portfolio_state"})
10
+
11
+
12
+ def mcp_call_is_heavy(body: dict[str, Any] | None) -> bool:
13
+ """Return True when an MCP tools/call warrants the heavy x402 price."""
14
+ if not body or body.get("method") != "tools/call":
15
+ return False
16
+
17
+ params = body.get("params")
18
+ if not isinstance(params, dict):
19
+ return False
20
+
21
+ tool_name = params.get("name")
22
+ if isinstance(tool_name, str) and tool_name in HEAVY_MCP_TOOLS:
23
+ return True
24
+
25
+ arguments = params.get("arguments")
26
+ if not isinstance(arguments, dict):
27
+ return False
28
+
29
+ freshness = arguments.get("freshness")
30
+ return isinstance(freshness, str) and freshness.strip().lower() == "live"
31
+
32
+
33
+ async def read_mcp_request_json(context: HTTPRequestContext) -> dict[str, Any] | None:
34
+ request = getattr(context.adapter, "_request", None)
35
+ if request is None:
36
+ return None
37
+ try:
38
+ body = await request.json()
39
+ except Exception:
40
+ return None
41
+ return body if isinstance(body, dict) else None
42
+
43
+
44
+ def build_mcp_dynamic_price(*, light_price: str, heavy_price: str):
45
+ """Build an async x402 DynamicPrice callback for POST /mcp."""
46
+
47
+ async def resolve_price(context: HTTPRequestContext) -> str:
48
+ body = await read_mcp_request_json(context)
49
+ if body is None:
50
+ return heavy_price
51
+ if mcp_call_is_heavy(body):
52
+ return heavy_price
53
+ return light_price
54
+
55
+ return resolve_price