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.
- alloc_context-0.1.0.dist-info/METADATA +154 -0
- alloc_context-0.1.0.dist-info/RECORD +85 -0
- alloc_context-0.1.0.dist-info/WHEEL +5 -0
- alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
- alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
- alloc_context-0.1.0.dist-info/top_level.txt +1 -0
- alloccontext/__init__.py +3 -0
- alloccontext/__main__.py +149 -0
- alloccontext/config.py +415 -0
- alloccontext/horizon.py +30 -0
- alloccontext/ingest/__init__.py +1 -0
- alloccontext/ingest/cf_benchmarks.py +38 -0
- alloccontext/ingest/cf_history.py +65 -0
- alloccontext/ingest/coinbase_client.py +234 -0
- alloccontext/ingest/coinbase_portfolio.py +53 -0
- alloccontext/ingest/coingecko.py +148 -0
- alloccontext/ingest/coinmarketcap.py +135 -0
- alloccontext/ingest/env_keys.py +12 -0
- alloccontext/ingest/etf_flows.py +282 -0
- alloccontext/ingest/exchange/__init__.py +4 -0
- alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
- alloccontext/ingest/exchange/kraken_adapter.py +66 -0
- alloccontext/ingest/exchange/live.py +95 -0
- alloccontext/ingest/exchange/portfolio.py +8 -0
- alloccontext/ingest/exchange/registry.py +27 -0
- alloccontext/ingest/exchange/types.py +5 -0
- alloccontext/ingest/exchange_http.py +28 -0
- alloccontext/ingest/fear_greed.py +89 -0
- alloccontext/ingest/fred.py +138 -0
- alloccontext/ingest/http_errors.py +29 -0
- alloccontext/ingest/kalshi.py +84 -0
- alloccontext/ingest/kalshi_api.py +199 -0
- alloccontext/ingest/kalshi_client.py +95 -0
- alloccontext/ingest/kalshi_files.py +44 -0
- alloccontext/ingest/kalshi_state.py +67 -0
- alloccontext/ingest/kraken_client.py +177 -0
- alloccontext/ingest/kraken_portfolio.py +161 -0
- alloccontext/ingest/macro_calendar.py +310 -0
- alloccontext/ingest/macro_normalize.py +98 -0
- alloccontext/ingest/market_snapshots.py +113 -0
- alloccontext/ingest/outcome.py +110 -0
- alloccontext/ingest/parse_helpers.py +23 -0
- alloccontext/ingest/runner.py +148 -0
- alloccontext/mcp/__init__.py +1 -0
- alloccontext/mcp/assets.py +153 -0
- alloccontext/mcp/bazaar.py +630 -0
- alloccontext/mcp/contracts.py +286 -0
- alloccontext/mcp/handlers.py +487 -0
- alloccontext/mcp/http.py +250 -0
- alloccontext/mcp/payment_middleware.py +211 -0
- alloccontext/mcp/server.py +319 -0
- alloccontext/mcp/staleness.py +30 -0
- alloccontext/mcp/validation.py +56 -0
- alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
- alloccontext/mcp/x402_config.py +131 -0
- alloccontext/mcp/x402_pricing.py +55 -0
- alloccontext/mcp/x402_stables.py +179 -0
- alloccontext/rollup/__init__.py +1 -0
- alloccontext/rollup/band.py +50 -0
- alloccontext/rollup/breadth.py +45 -0
- alloccontext/rollup/cf_math.py +103 -0
- alloccontext/rollup/cluster.py +149 -0
- alloccontext/rollup/cluster_config.py +86 -0
- alloccontext/rollup/comparison.py +67 -0
- alloccontext/rollup/context.py +118 -0
- alloccontext/rollup/delta.py +109 -0
- alloccontext/rollup/etf.py +113 -0
- alloccontext/rollup/fear_greed.py +61 -0
- alloccontext/rollup/macro.py +185 -0
- alloccontext/rollup/portfolio.py +137 -0
- alloccontext/rollup/rebalance.py +125 -0
- alloccontext/rollup/regime.py +188 -0
- alloccontext/rollup/sentiment.py +118 -0
- alloccontext/rollup/snapshots.py +64 -0
- alloccontext/rollup/tape.py +176 -0
- alloccontext/status_report.py +321 -0
- alloccontext/store/__init__.py +0 -0
- alloccontext/store/db.py +216 -0
- alloccontext/store/jsonutil.py +10 -0
- alloccontext/store/meta.py +20 -0
- alloccontext/store/retention.py +63 -0
- alloccontext/store/status.py +89 -0
- alloccontext/timeutil.py +11 -0
- alloccontext/x402_production_check.py +193 -0
- 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
|