curator-data 0.2.0__tar.gz

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 (53) hide show
  1. curator_data-0.2.0/.gitignore +46 -0
  2. curator_data-0.2.0/GRAPH-USAGE.md +110 -0
  3. curator_data-0.2.0/PKG-INFO +381 -0
  4. curator_data-0.2.0/PUBLISHING.md +132 -0
  5. curator_data-0.2.0/README.md +358 -0
  6. curator_data-0.2.0/curator_data/__init__.py +25 -0
  7. curator_data-0.2.0/curator_data/chain/__init__.py +12 -0
  8. curator_data-0.2.0/curator_data/chain/rpc.py +121 -0
  9. curator_data-0.2.0/curator_data/cli.py +204 -0
  10. curator_data-0.2.0/curator_data/config.py +175 -0
  11. curator_data-0.2.0/curator_data/default.py +34 -0
  12. curator_data-0.2.0/curator_data/facts.py +143 -0
  13. curator_data-0.2.0/curator_data/graph/__init__.py +11 -0
  14. curator_data-0.2.0/curator_data/graph/errors.py +42 -0
  15. curator_data-0.2.0/curator_data/graph/factory.py +48 -0
  16. curator_data-0.2.0/curator_data/graph/gateway.py +172 -0
  17. curator_data-0.2.0/curator_data/ports.py +106 -0
  18. curator_data-0.2.0/curator_data/queries.py +266 -0
  19. curator_data-0.2.0/curator_data/registry.py +294 -0
  20. curator_data-0.2.0/curator_data/sources/__init__.py +59 -0
  21. curator_data-0.2.0/curator_data/sources/aave.py +209 -0
  22. curator_data-0.2.0/curator_data/sources/chainlink.py +206 -0
  23. curator_data-0.2.0/curator_data/sources/feeds.py +69 -0
  24. curator_data-0.2.0/curator_data/sources/messari.py +390 -0
  25. curator_data-0.2.0/curator_data/sources/pools.py +44 -0
  26. curator_data-0.2.0/curator_data/sources/protocols.py +152 -0
  27. curator_data-0.2.0/curator_data/sources/token_api.py +315 -0
  28. curator_data-0.2.0/curator_data/sources/tokens.py +42 -0
  29. curator_data-0.2.0/curator_data/verify.py +270 -0
  30. curator_data-0.2.0/curator_data/x402/__init__.py +27 -0
  31. curator_data-0.2.0/curator_data/x402/client.py +167 -0
  32. curator_data-0.2.0/curator_data/x402/payment.py +291 -0
  33. curator_data-0.2.0/curator_mcp/README.md +145 -0
  34. curator_data-0.2.0/curator_mcp/SKILL.md +114 -0
  35. curator_data-0.2.0/curator_mcp/curator_mcp/__init__.py +16 -0
  36. curator_data-0.2.0/curator_mcp/curator_mcp/server.py +215 -0
  37. curator_data-0.2.0/curator_mcp/pyproject.toml +55 -0
  38. curator_data-0.2.0/publish.sh +127 -0
  39. curator_data-0.2.0/pyproject.toml +56 -0
  40. curator_data-0.2.0/tests/conftest.py +58 -0
  41. curator_data-0.2.0/tests/test_aave.py +158 -0
  42. curator_data-0.2.0/tests/test_chainlink.py +239 -0
  43. curator_data-0.2.0/tests/test_cli.py +116 -0
  44. curator_data-0.2.0/tests/test_facts.py +72 -0
  45. curator_data-0.2.0/tests/test_gateway.py +161 -0
  46. curator_data-0.2.0/tests/test_mcp_server.py +114 -0
  47. curator_data-0.2.0/tests/test_messari.py +413 -0
  48. curator_data-0.2.0/tests/test_published.py +129 -0
  49. curator_data-0.2.0/tests/test_queries.py +141 -0
  50. curator_data-0.2.0/tests/test_registry.py +376 -0
  51. curator_data-0.2.0/tests/test_source_agnostic.py +95 -0
  52. curator_data-0.2.0/tests/test_token_api.py +292 -0
  53. curator_data-0.2.0/tests/test_x402.py +392 -0
@@ -0,0 +1,46 @@
1
+ # ── Secrets ───────────────────────────────────────────────────────────────
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # ── Python ────────────────────────────────────────────────────────────────
7
+ __pycache__/
8
+ *.py[cod]
9
+ .venv/
10
+ venv/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .mypy_cache/
14
+ *.egg-info/
15
+ dist/
16
+ build/
17
+
18
+ # ── Node / Next.js ────────────────────────────────────────────────────────
19
+ node_modules/
20
+ .next/
21
+ out/
22
+ .turbo/
23
+ *.tsbuildinfo
24
+
25
+ # ── Foundry ───────────────────────────────────────────────────────────────
26
+ cache/
27
+ broadcast/
28
+ # NOTE: contracts/out is intentionally NOT ignored — Lane A publishes ABIs from
29
+ # there and every other lane consumes them (master plan §10 Lane A).
30
+
31
+ # ── Local runtime state ───────────────────────────────────────────────────
32
+ *.log
33
+ .anvil-state.json
34
+ scratch/
35
+ # Lane B: persisted mandates and AgentAction journals (agent/config.py state_dir)
36
+ .agent-state/
37
+ # Wave 0: e2e runs against an isolated AGENT_STATE_DIR so they never share a
38
+ # journal with a running agent — two writers corrupt line-count-derived ids.
39
+ .agent-state-*/
40
+
41
+ # ── OS / editor ───────────────────────────────────────────────────────────
42
+ .DS_Store
43
+ Thumbs.db
44
+ .idea/
45
+ .vscode/*
46
+ !.vscode/extensions.json
@@ -0,0 +1,110 @@
1
+ # The Graph — exactly what we use
2
+
3
+ Written for the submission form. Track 2 asks you to *"briefly describe how The Graph is used (which
4
+ Subgraphs, endpoints, tools)"*, and Track 3 asks for **two or more Graph products composed**. This is
5
+ the copy-pasteable answer, with the specific identifiers rather than a category name — every figure
6
+ below was read from the live gateway on 2026-07-25.
7
+
8
+ Reproduce any of it with:
9
+
10
+ ```bash
11
+ uv run curator-data verify-live # queries every subgraph and prints what came back
12
+ uv run curator-data snapshot --assets USDC,WETH
13
+ uv run curator-data protocols # the configured table
14
+ ```
15
+
16
+ ---
17
+
18
+ ## Subgraphs queried (decentralised network, via the gateway)
19
+
20
+ | Protocol | Subgraph ID | Schema | Supplies |
21
+ |---|---|---|---|
22
+ | **Moonwell** (Base) | `33ex1ExmYQtwGVwri1AP3oMFPGSce6YbocBP7fWbsBrg` | Messari **standardized** lending | supply APY, TVL, utilization |
23
+ | **Aave V3** (Base) | `GQFbb95cE6d8mV989mL5figjaGaKCQB3xqYrr1bRyXqF` | Aave's **own** schema (`reserves`) | supply APY, TVL, utilization |
24
+ | **Uniswap V3** (Base) | `FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS` | Messari **standardized** dex-amm | pool liquidity |
25
+ | The Graph Network | `DZz4kDTdmzWLWsV373w2bSmoar3umKKH9y82SUKr5qmp` | — | used to *search* the network for candidate subgraphs (see below) |
26
+
27
+ Endpoint form: `https://gateway.thegraph.com/api/subgraphs/id/{id}`, authenticated with
28
+ `Authorization: Bearer $GRAPH_API_KEY` — the key travels as a header, never in the URL, so it cannot
29
+ leak into a log or a screen share.
30
+
31
+ ### The composition argument, concretely
32
+
33
+ Messari publishes **one standardized schema per protocol type**, so a single GraphQL document reads
34
+ every lending market. Adding a protocol is one line of configuration in
35
+ [`sources/protocols.py`](curator_data/sources/protocols.py) — no adapter, no schema change. That
36
+ claim is asserted by a test (`test_one_query_shape_serves_every_lending_protocol`), not just stated.
37
+
38
+ **Where it stops, honestly:** the published *Aave V3 Base* subgraph is **not** Messari-standardized —
39
+ it exposes `reserves`, not `markets`. We searched all 381 active Base subgraphs via The Graph's own
40
+ network subgraph and found no standardized Aave on Base, so Aave got its own adapter and its own
41
+ registry key. It is a separate key rather than a branch inside the Messari adapter because
42
+ `Fact.source` is **provenance**, and labelling Aave's own subgraph as "messari" would be false.
43
+
44
+ ---
45
+
46
+ ## Other Graph products used
47
+
48
+ **Token API** — `https://api.pinax.network/v1`, authenticated with a Graph Market JWT
49
+ (`TOKEN_API_KEY`; the subgraph gateway's key is rejected with 401). Base has no price category, so
50
+ price is derived from executed DEX swaps: `GET /evm/swaps?network=base&pool={pool}&limit=10`.
51
+
52
+ **x402 pay-per-query** — `https://gateway.thegraph.com/api/x402/subgraphs/id/{id}`. The agent pays
53
+ for its own market data out of its own wallet, in USDC on Base, **with no API key at all**. The
54
+ gateway quotes $0.01 per query.
55
+
56
+ **MCP server** (Track 1) — [`curator_mcp/`](curator_mcp/), a separately installable distribution with
57
+ its own `pyproject.toml`, `README.md` and [`SKILL.md`](curator_mcp/SKILL.md). Four tools:
58
+ `compare_protocols`, `get_market_yields`, `list_markets`, `get_token_price`, plus a
59
+ `curator://protocols` resource. It shares this package's sources rather than reimplementing them, so
60
+ the reusable product runs the same code as the demo — our agent is visibly *a* consumer, not *the*
61
+ consumer.
62
+
63
+ ---
64
+
65
+ ## Two or more Graph products, composed (Track 3)
66
+
67
+ Four sources merge into one **source-agnostic** `MarketSnapshot`:
68
+
69
+ ```
70
+ Messari standardized subgraphs ─┐
71
+ Aave V3 subgraph ─┼─► MarketSnapshot (flat facts, each carrying its own provenance)
72
+ Graph Token API ─┤
73
+ Chainlink (on-chain, non-Graph)─┘
74
+ ```
75
+
76
+ Sources never see each other and never agree on coverage; each contributes a *partial* list of facts
77
+ and the registry merges them. That is what makes the composition real rather than a list — and the
78
+ fourth source is deliberately **not** an HTTP API at all: it reads a contract over JSON-RPC, showing
79
+ the port abstracts *kinds of provider* rather than endpoints.
80
+
81
+ A live snapshot, exactly as an agent tick runs it (6.3s):
82
+
83
+ ```
84
+ moonwell USDC APY 15.38% TVL $ 14,489,518 util 0.91 [messari]
85
+ aave-v3 USDC APY 3.51% TVL $172,603,113 util 0.85 [aave]
86
+ aave-v3 WETH APY 1.46% TVL $174,448,695 util 0.77 [aave]
87
+ price USDC $ 1.00 via [chainlink]
88
+ price WETH $1,856.33 via [chainlink, token_api] spread 0.29%
89
+ ```
90
+
91
+ Two lending protocols compared, and **two mechanically independent price sources cross-validating** —
92
+ an oracle read and executed swap prices, 0.29% apart. A wide spread is surfaced as `disagreement`,
93
+ because that gap means a stale oracle, a manipulated pool, or a dislocated market: all things a
94
+ curator should act on.
95
+
96
+ ---
97
+
98
+ ## Live data, not mocks
99
+
100
+ The Graph disqualifies mocked or static data. `curator-data verify-live` is the gate: it queries
101
+ every configured subgraph for real and **exits non-zero if anything failed *or was skipped***,
102
+ because "we did not check" is not proof. The unit suite is deliberately the opposite — hermetic, no
103
+ network, no credentials — so the two never get confused for one another.
104
+
105
+ ## Known limitation, stated plainly
106
+
107
+ The Uniswap V3 subgraph's indexers are intermittently unavailable (`bad indexers`, or a ~20s
108
+ timeout). It is not our query — live introspection confirms the subgraph *is* Messari-standardized
109
+ and it has returned data. A per-protocol deadline means it costs at most 6s and degrades into
110
+ `MarketSnapshot.errors` rather than delaying the tick. Lending data does not depend on it.
@@ -0,0 +1,381 @@
1
+ Metadata-Version: 2.4
2
+ Name: curator-data
3
+ Version: 0.2.0
4
+ Summary: Pluggable market-data registry: mandate-named sources merged into a source-agnostic MarketSnapshot
5
+ License: MIT
6
+ Keywords: agent-tools,chainlink,defi,market-data,subgraph,thegraph
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Topic :: Software Development :: Libraries
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: curator-schema
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: pydantic>=2.7
16
+ Requires-Dist: python-dotenv>=1.0
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
19
+ Requires-Dist: pytest>=8; extra == 'test'
20
+ Provides-Extra: x402
21
+ Requires-Dist: eth-account>=0.11; extra == 'x402'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # `/data` — the market data layer (Lane C)
25
+
26
+ **Everything the agent can see.** A pluggable source registry that merges partial contributions from
27
+ any number of providers into one source-agnostic `MarketSnapshot`.
28
+
29
+ Four sources ship today — three over HTTP, one reading a contract on-chain. The registry is the
30
+ product; they are its consumers.
31
+
32
+ ---
33
+
34
+ ## Purpose
35
+
36
+ The agent decides where to put capital, so what it can see determines what it can do. This layer
37
+ owns that, with two goals held simultaneously:
38
+
39
+ 1. **Win three Graph tracks now** — live Messari standardized subgraphs, the Token API, a standalone
40
+ MCP server, and x402 pay-per-query.
41
+ 2. **Make adding a non-Graph provider later a 30-minute job** — Chainlink, Pyth, DefiLlama.
42
+
43
+ Those goals conflict only if the Graph adapters drive the design. They don't: the registry and the
44
+ `MarketSnapshot` shape came first, and no provider name appears anywhere above `sources/`
45
+ (asserted by [`tests/test_source_agnostic.py`](tests/test_source_agnostic.py)).
46
+
47
+ ---
48
+
49
+ ## Public interface
50
+
51
+ ### The one call Lane B needs
52
+
53
+ ```python
54
+ from curator_data import build_registry
55
+
56
+ registry = build_registry() # reads .env
57
+ snapshot = await registry.snapshot(
58
+ mandate.permitted_data_sources, # ["messari", "token_api"]
59
+ mandate.constraints.allowed_assets, # ["USDC", "WETH"]
60
+ )
61
+ ```
62
+
63
+ Implements the frozen [`DataSourceRegistry`](../packages/schema/python/curator_schema/ports.py)
64
+ port. Returns a [`MarketSnapshot`](../packages/schema/market-snapshot.schema.json).
65
+
66
+ | Method | Returns | Notes |
67
+ |---|---|---|
68
+ | `await registry.snapshot(source_keys, assets)` | `MarketSnapshot` | Fans out concurrently, merges, never raises for source failure |
69
+ | `registry.available()` | `list[str]` | Registered keys — the set the genesis UI offers |
70
+ | `registry.describe()` | `list[dict]` | Key, human description and capabilities, for a source picker |
71
+ | `registry.sources_providing(*kinds)` | `list[str]` | Capability lookup: who supplies `"price"`? |
72
+ | `registry.register(key, factory)` | `None` | Runtime registration, for embedders and tests |
73
+ | `await registry.aclose()` | `None` | Releases every source's HTTP client. Also an async context manager |
74
+
75
+ ### For late-binding consumers (Lane B)
76
+
77
+ The agent harness resolves its data seam from configuration rather than importing this lane
78
+ directly, so it needs an *instance* at a stable path. That is:
79
+
80
+ ```bash
81
+ AGENT_DATA_REGISTRY=curator_data.default:registry
82
+ ```
83
+
84
+ [`curator_data/default.py`](curator_data/default.py) exposes a ready-made `Registry` built from the
85
+ environment. It satisfies the frozen `DataSourceRegistry` Protocol, and importing it cannot fail on
86
+ a missing `GRAPH_API_KEY` — sources are constructed lazily, so an absent credential degrades into
87
+ `snapshot.errors` rather than raising at import and dropping the caller back to fixtures.
88
+
89
+ ### Market-level views
90
+
91
+ `MarketSnapshot` is a flat fact list, which is what keeps it source-agnostic — but that is not how
92
+ you render a table. Pivot it:
93
+
94
+ ```python
95
+ from curator_data.queries import pivot_markets, pivot_pools, prices, errors_as_dicts
96
+
97
+ for row in pivot_markets(snapshot): # sorted by APY, highest first
98
+ print(row.protocol, row.supply_apy, row.tvl_usd, row.utilization, row.fact_ids)
99
+ ```
100
+
101
+ `MarketRow` / `PoolRow` both expose `.to_dict()`. `row.fact_ids` is what belongs in
102
+ `AllocationDecision.facts_used`.
103
+
104
+ ### Command line
105
+
106
+ ```bash
107
+ uv run curator-data sources # what a mandate may grant
108
+ uv run curator-data protocols # what is configured, and how to add more
109
+ uv run curator-data snapshot --assets USDC,WETH [--json]
110
+ uv run curator-data verify-live # prove the demo path hits live data (exit 1 if not)
111
+ ```
112
+
113
+ `snapshot --json` emits a schema-valid `MarketSnapshot` on stdout, so another lane can consume real
114
+ data without importing any of this.
115
+
116
+ ---
117
+
118
+ ## Data shapes
119
+
120
+ Everything crossing the boundary is defined in [`packages/schema`](../packages/schema/). Nothing new
121
+ is invented here.
122
+
123
+ ```jsonc
124
+ {
125
+ "taken_at": "2026-07-25T14:05:00Z",
126
+ "facts": [
127
+ {
128
+ "id": "messari:yield:aave-v3/usdc", // stable across snapshots; cite this
129
+ "kind": "yield", // yield|price|tvl|liquidity|volatility|utilization|volume
130
+ "subject": { "protocol": "aave-v3", "market": "USDC", "chain": "base" },
131
+ "value": 0.0432, // 4.32% — a FRACTION, never 4.32
132
+ "unit": "apy_fraction",
133
+ "source": "messari", // provenance — the UI shows this
134
+ "observed_at": "2026-07-25T14:04:12Z"
135
+ }
136
+ ],
137
+ "errors": [ { "source": "moonwell", "message": "HTTP 502" } ]
138
+ }
139
+ ```
140
+
141
+ **`errors` is not decoration.** A non-empty `errors` means the snapshot is *partial*. Show it to the
142
+ model — an agent that treats a partial view as complete is the failure mode this layer is shaped to
143
+ avoid, and it holds a key.
144
+
145
+ ---
146
+
147
+ ## Sources that ship
148
+
149
+ | Key | Provides | Data | Live status |
150
+ |---|---|---|---|
151
+ | `messari` | `yield`, `tvl`, `utilization`, `liquidity` | Messari standardized subgraphs — lending markets and DEX pools on Base | ✅ Moonwell verified live (~15% USDC APY on $14.5M). Uniswap V3 works but its indexers are slow/intermittent |
152
+ | `aave` | `yield`, `tvl`, `utilization` | Aave V3 on Base, via **Aave's own** subgraph schema | ✅ verified live (3.41% USDC APY on $174.9M, 0.84 utilization) |
153
+ | `chainlink` | `price` | Chainlink feeds, read **on-chain** over JSON-RPC | ✅ verified live (WETH $1,858.98, USDC $0.9999). **Needs no credential** |
154
+ | `token_api` | `price` | The Graph Token API — prices derived from executed DEX swaps | ✅ verified live (WETH $1,857.95). Needs its own Graph Market JWT, *not* `GRAPH_API_KEY` |
155
+
156
+ All subgraph IDs live in [`curator_data/sources/protocols.py`](curator_data/sources/protocols.py),
157
+ including a list of candidates **rejected after live testing**, so nobody re-adds them.
158
+
159
+ ### Two independent price sources, on purpose
160
+
161
+ `chainlink` reads an oracle; `token_api` derives price from executed DEX swaps. The mechanisms share
162
+ nothing, so agreement corroborates and **disagreement is a signal** — a stale oracle, a manipulated
163
+ pool, or a genuinely dislocated market. Live they sat 0.19% apart.
164
+
165
+ `prices(snapshot)` therefore returns every observation rather than one winner:
166
+
167
+ ```python
168
+ {"WETH": {"price_usd": 1857.18, # median consensus
169
+ "sources": ["chainlink", "token_api"],
170
+ "observations": [{"source": "chainlink", "price_usd": 1858.98, ...}, ...],
171
+ "spread_pct": 0.19, "disagreement": False}}
172
+ ```
173
+
174
+ `chainlink` also reads the **same feeds `totalAssets()` uses**, so the agent and the vault contract
175
+ can never disagree about what the portfolio is worth.
176
+
177
+ ### Why Aave is a separate source rather than a branch in `messari`
178
+
179
+ Live introspection showed the published *Aave V3 Base* subgraph exposes `reserves`, not the
180
+ standardized `markets` — so one query shape genuinely cannot read it. It could have been a second
181
+ query inside the Messari adapter, but **`Fact.source` is provenance**: labelling data pulled from
182
+ Aave's own subgraph as `messari` would be false to anyone reading the dApp.
183
+
184
+ Adding it was `sources/aave.py` plus one line in `sources/__init__.py`. Nothing else changed — not
185
+ the registry, the schema, the MCP server or the agent. That is the extension-point claim exercised
186
+ on a real provider rather than a test double.
187
+
188
+ ---
189
+
190
+ ## Adding a data source
191
+
192
+ The extension point. **One new file, one new line.**
193
+
194
+ ```python
195
+ # curator_data/sources/pyth.py
196
+ from curator_data.ports import BaseSource
197
+ from curator_data.facts import FactBuilder
198
+
199
+ class PythSource(BaseSource):
200
+ key = "pyth"
201
+ provides = ("price",)
202
+ description = "Pyth price feeds on Base"
203
+
204
+ async def fetch(self, assets):
205
+ builder = FactBuilder(self.key)
206
+ return [builder.usd("price", builder.subject(token=a), await self._read(a))
207
+ for a in assets]
208
+
209
+ def make_pyth_source(settings):
210
+ return PythSource(settings)
211
+ ```
212
+
213
+ ```python
214
+ # curator_data/sources/__init__.py — the ONLY other edit
215
+ SOURCE_FACTORIES = {
216
+ "messari": make_messari_source,
217
+ "token_api": make_token_api_source,
218
+ "aave": make_aave_source,
219
+ "chainlink": make_chainlink_source,
220
+ "pyth": make_pyth_source, # ← this line
221
+ }
222
+ ```
223
+
224
+ Then name `"pyth"` in a mandate's `permitted_data_sources`. Nothing else changes — not the registry,
225
+ not the schema, not the agent, not the dApp. Because sources are selected by *capability*
226
+ (`provides`), the new source immediately participates in price queries and in the MCP server's
227
+ `get_token_price` — and it is cross-checked against the existing price sources for free.
228
+
229
+ **This is not a hypothetical.** `aave` and `chainlink` were both added this way *after* the registry
230
+ shipped, and `chainlink` is not even an HTTP API — it reads a contract over JSON-RPC. Neither
231
+ required a change outside its own file plus one line here.
232
+
233
+ **Adding a protocol to an existing source is even smaller** — one `Protocol(...)` line in
234
+ `protocols.py`. That is the point of Messari standardized subgraphs: every lending market answers
235
+ the same GraphQL document, so a new protocol needs no adapter at all.
236
+
237
+ ### What a source must guarantee
238
+
239
+ - **Never raise for expected failure** — a timeout, a rate limit, a missing market. Return what you
240
+ have and call `self.note("...")` for the rest; notes surface in `snapshot.errors`.
241
+ - **Normalise at the boundary.** `apy_fraction` is `0.0432` for 4.32%. Use
242
+ `FactBuilder.apy_from_percent()` when the upstream reports percentages — Messari does.
243
+ - **Never guess an identifier.** An unknown token symbol is a note naming the fix, not a guessed
244
+ contract address. This system trades with a real key.
245
+
246
+ ---
247
+
248
+ ## Dependencies
249
+
250
+ **Requires:** `curator-schema` (the frozen interface), `httpx`, `pydantic`, `python-dotenv`.
251
+ Optional: `eth-account` for x402 (`pip install curator-data[x402]`).
252
+
253
+ **Depends on no other lane.** Lane B consumes this through the `DataSourceRegistry` port; nothing
254
+ here imports `agent/`, `venues/`, `web/` or `contracts/`.
255
+
256
+ ### Credentials
257
+
258
+ | Variable | Required | Default | Purpose |
259
+ |---|---|---|---|
260
+ | `GRAPH_API_KEY` | **yes** | — | Subgraph gateway. Free at [thegraph.com/studio](https://thegraph.com/studio) → API Keys |
261
+ | `TOKEN_API_KEY` | no | falls back to `GRAPH_API_KEY` | Token API bearer token — a *separate* credential |
262
+ | `TOKEN_API_URL` | no | `https://api.pinax.network/v1` | ⚠️ **not** `token-api.thegraph.com` — that host, named in The Graph's own docs, does not resolve. Verified live |
263
+ | `DATA_CHAIN` | no | `base` | Chain to query |
264
+ | `DATA_REQUEST_TIMEOUT_S` | no | `15` | Per-HTTP-request timeout |
265
+ | `DATA_SOURCE_TIMEOUT_S` | no | `20` | Per-source ceiling inside `snapshot()` |
266
+ | `X402_ENABLED` | no | `false` | Pay-per-query. Needs `X402_PRIVATE_KEY` too |
267
+ | `X402_PRIVATE_KEY` | no | — | Wallet with a few dollars of USDC on Base |
268
+
269
+ ---
270
+
271
+ ## The MCP server
272
+
273
+ [`curator_mcp/`](curator_mcp/) is a **separate distribution** — its own `pyproject.toml`,
274
+ `README.md` and [`SKILL.md`](curator_mcp/SKILL.md). It installs standalone from a clone:
275
+
276
+ ```bash
277
+ uv pip install ./data/curator_mcp # siblings resolve from the repo; verified in a clean 3.10 venv
278
+ ```
279
+
280
+ ```jsonc
281
+ { "mcpServers": { "curator": {
282
+ "command": "uv", "args": ["run", "--directory", "/abs/path/to/repo", "curator-mcp"],
283
+ "env": { "GRAPH_API_KEY": "..." } } } }
284
+ ```
285
+
286
+ Publishing to PyPI turns that into `uvx curator-mcp` with no clone at all. The distributions are
287
+ built and verified to install from wheels alone; only the upload is outstanding — see
288
+ [PUBLISHING.md](PUBLISHING.md).
289
+
290
+ Tools: `compare_protocols`, `get_market_yields`, `list_markets`, `get_token_price`.
291
+
292
+ It shares this package's sources rather than reimplementing them, so the reusable product runs the
293
+ same code as our demo. Our agent talks to the registry directly (in-process, no stdio hop) — it is
294
+ visibly *a* consumer, not *the* consumer.
295
+
296
+ ---
297
+
298
+ ## x402 — pay-per-query
299
+
300
+ Off by default. Enabled only by `X402_ENABLED=true` **and** `X402_PRIVATE_KEY`.
301
+
302
+ It is a **decorator over the gateway transport**, not a data source:
303
+
304
+ ```
305
+ GatewayClient API-key auth. Always works. The default.
306
+ X402GatewayClient wraps it. Tries to pay; delegates on ANY failure.
307
+ ```
308
+
309
+ There is no code path where enabling x402 loses data the API-key path would have returned. Worst
310
+ case is a wasted round-trip and a note in `errors`. A client-side ceiling refuses to sign anything
311
+ above 1 USDC — a market-data query costs a fraction of a cent, so a larger demand means something is
312
+ wrong.
313
+
314
+ ---
315
+
316
+ ## Assumptions & invariants
317
+
318
+ Callers may rely on all of these:
319
+
320
+ - **`snapshot()` never raises** for source failure, timeout or an unknown source key. Those degrade
321
+ into `errors[]`. It raises only for programmer error.
322
+ - **Every `Fact.source` is the registry key that produced it.** Enforced at merge; a mislabelled
323
+ fact is corrected *and* reported.
324
+ - **Fact ids are unique within a snapshot** and stable across snapshots for the same subject, so
325
+ `facts_used` citations are unambiguous and decisions can be diffed over time.
326
+ - **APY is always a fraction.** `0.0432` means 4.32%.
327
+ - **Amounts in USD are floats; no `uint256` crosses this boundary** — this layer reports market
328
+ observations, not balances.
329
+ - **A source is constructed once** and reused across ticks, so connection pools survive. Call
330
+ `aclose()` when finished.
331
+ - **`permitted_data_sources` is access control.** A registered source not named is never consulted.
332
+
333
+ ---
334
+
335
+ ## Tests
336
+
337
+ ```bash
338
+ uv run pytest data/tests -q # 180 tests, no network, no credentials
339
+ uv run curator-data verify-live # the live path — needs GRAPH_API_KEY
340
+ ```
341
+
342
+ The unit suite never touches the network (`httpx.MockTransport`); `verify-live` only touches the
343
+ network. Live gateway data on the demo path is a Graph submission gate, so it is a command rather
344
+ than an assumption.
345
+
346
+ `tests/conftest.py` strips credentials and disables `.env` discovery for every test. That is
347
+ deliberate: when a real `GRAPH_API_KEY` first landed in `.env`, three tests changed behaviour and
348
+ several others silently began making live calls. The suite now asserts the same thing on a laptop
349
+ with a full `.env` and on a fresh clone with none — which is what the macOS handoff needs.
350
+
351
+ > ⚠️ **Shared-venv trap.** `uv sync --extra data` *prunes* every package not in the named extras,
352
+ > which silently uninstalls other lanes' dependencies. Always sync all of them:
353
+ > `uv sync --extra dev --extra data --extra agent --extra venues`.
354
+
355
+ ---
356
+
357
+ ## Layout
358
+
359
+ ```
360
+ curator_data/
361
+ config.py Settings — every env value resolved in one place
362
+ ports.py BaseSource: the class a new source subclasses
363
+ facts.py FactBuilder — unit-safe, provenance-stamped Fact construction
364
+ registry.py fan-out, merge, degradation, capability lookup ← the extension point
365
+ queries.py pivots from flat facts back into market rows
366
+ verify.py live-path checks (importable; the CLI just prints them)
367
+ cli.py curator-data: sources / protocols / snapshot / verify-live
368
+ graph/ gateway transport (GraphQL over httpx) + transport factory
369
+ sources/
370
+ __init__.py THE REGISTRATION TABLE — one line per source
371
+ protocols.py protocol -> subgraph id (data, not code)
372
+ tokens.py symbol -> contract address (data, not code)
373
+ messari.py Messari standardized subgraphs
374
+ token_api.py Graph Token API
375
+ x402/ pay-per-query transport decorator, feature-flagged
376
+ curator_mcp/ SEPARATE DISTRIBUTION — the standalone MCP server
377
+ tests/
378
+ ```
379
+
380
+ Plan: [`plans/2026-07-25-lane-c-data.md`](../plans/2026-07-25-lane-c-data.md).
381
+ Decisions and their reasoning: [`docs/build-log.md`](../docs/build-log.md).
@@ -0,0 +1,132 @@
1
+ # Publishing `curator-mcp` to PyPI
2
+
3
+ The MCP server is a submission deliverable for The Graph's Track 1, whose stated criteria include
4
+ **Reusability & completeness (25%)** and whose defining requirement is *"reusable tooling or
5
+ infrastructure … not a single end-user app."* A server only we can run does not meet that.
6
+
7
+ **Everything below has been verified except the upload itself, which needs a PyPI token.**
8
+
9
+ ---
10
+
11
+ ## Current state: it installs, without PyPI
12
+
13
+ The blocker reported in the phase 2 plan §6 — `uv pip install ./data/curator_mcp` failing with
14
+ *"Because curator-data was not found in the package registry … unsatisfiable"* — **is fixed.**
15
+
16
+ `data/curator_mcp/pyproject.toml` now carries relative `[tool.uv.sources]` for its two siblings, so
17
+ a clone resolves them locally. Verified in a clean Python 3.10 venv outside the repo:
18
+
19
+ ```bash
20
+ uv pip install ./data/curator_mcp # exit 0
21
+ python -c "import asyncio; from curator_mcp.server import build_server; \
22
+ print(sorted(t.name for t in asyncio.run(build_server().list_tools())))"
23
+ # ['compare_protocols', 'get_market_yields', 'get_token_price', 'list_markets']
24
+ ```
25
+
26
+ Those entries are **inert once the packages exist on PyPI** — a registry version satisfies the
27
+ requirement first — and they match the root workspace's `editable = true`, without which `uv sync`
28
+ fails with *"Requirements contain conflicting URLs for package curator-data"*.
29
+
30
+ ## Publishing makes `uvx curator-mcp` work for someone who has never seen the repo
31
+
32
+ That is the version worth having: a judge pasting our MCP config into their own client, with no
33
+ clone.
34
+
35
+ ### The short version
36
+
37
+ ```bash
38
+ ./data/publish.sh # build + verify, uploads nothing — safe to run now
39
+ ./data/publish.sh --publish # build, verify, confirm, then upload
40
+ ```
41
+
42
+ The dry run is the default deliberately. It builds all three distributions and proves they install
43
+ from wheels alone in a clean 3.10 venv — the check that matters, because a PyPI version number can
44
+ never be re-uploaded. `--publish` additionally requires `UV_PUBLISH_TOKEN` and prompts before
45
+ uploading, since `curator-schema` belongs to Wave 0 rather than Lane C.
46
+
47
+ The manual equivalents are below, for anyone who would rather see each step.
48
+
49
+ ### 1. Build
50
+
51
+ ```bash
52
+ uv build --out-dir dist packages/schema/python # curator-schema
53
+ uv build --out-dir dist data # curator-data
54
+ uv build --out-dir dist data/curator_mcp # curator-mcp
55
+ ```
56
+
57
+ Verified — all three produce a wheel and an sdist:
58
+
59
+ ```
60
+ curator_schema-0.1.0-py3-none-any.whl curator_schema-0.1.0.tar.gz
61
+ curator_data-0.2.0-py3-none-any.whl curator_data-0.2.0.tar.gz
62
+ curator_mcp-0.2.0-py3-none-any.whl curator_mcp-0.2.0.tar.gz
63
+ ```
64
+
65
+ ### 2. Prove the wheels stand alone *before* uploading
66
+
67
+ This is the step that catches a broken dependency chain while it is still free to fix:
68
+
69
+ ```bash
70
+ uv venv --python 3.10 /tmp/wheelcheck/.venv
71
+ uv pip install --python /tmp/wheelcheck/.venv --find-links dist curator-mcp
72
+ ```
73
+
74
+ **Verified passing.** Resolving from `--find-links` alone proves the wheel metadata carries real
75
+ dependency names rather than the local path sources — path sources are a uv resolution hint and are
76
+ deliberately not baked into wheels.
77
+
78
+ ### 3. Upload — bottom of the dependency chain first
79
+
80
+ Order matters: each upload must be able to resolve the one below it.
81
+
82
+ ```bash
83
+ export UV_PUBLISH_TOKEN=pypi-... # from https://pypi.org/manage/account/token/
84
+
85
+ uv publish --token "$UV_PUBLISH_TOKEN" dist/curator_schema-*
86
+ uv publish --token "$UV_PUBLISH_TOKEN" dist/curator_data-*
87
+ uv publish --token "$UV_PUBLISH_TOKEN" dist/curator_mcp-*
88
+ ```
89
+
90
+ Then confirm the thing we actually claim in `SKILL.md`:
91
+
92
+ ```bash
93
+ uvx curator-mcp # from a machine that has never seen this repo
94
+ ```
95
+
96
+ ### Names are free
97
+
98
+ Checked against PyPI: `curator-mcp`, `curator-data` and `curator-schema` all return 404. Nobody has
99
+ claimed them.
100
+
101
+ > **`curator-schema` belongs to Wave 0**, not to Lane C. It needs no edit to publish — its metadata
102
+ > is already valid — but the decision to publish it is not Lane C's to take alone. It is the bottom
103
+ > of the chain, so `curator-data` cannot be published without it.
104
+
105
+ ### Test first, if you want to be careful
106
+
107
+ ```bash
108
+ uv publish --publish-url https://test.pypi.org/legacy/ --token "$UV_TEST_TOKEN" dist/*
109
+ ```
110
+
111
+ Note that a version number on PyPI is **permanent** — `0.2.0` can never be re-uploaded, only
112
+ superseded by `0.2.1`. Get the wheel-only check in step 2 passing first.
113
+
114
+ ---
115
+
116
+ ## Why the version numbers moved to 0.2.0
117
+
118
+ Not changelog hygiene. **uv caches built wheels by name and version**, so rebuilding `0.1.0` after
119
+ adding modules serves the stale wheel and fails with a confusing `ModuleNotFoundError: No module
120
+ named 'curator_data.sources.feeds'`. Hit during this work; `--no-cache` confirmed the diagnosis.
121
+ Bump the version whenever the package contents change.
122
+
123
+ ## Checklist before publishing
124
+
125
+ - [x] `LICENSE` present at repo root (MIT), matching what both `pyproject.toml`s declare
126
+ - [x] `readme` set on every package, so PyPI shows a description
127
+ - [x] Classifiers and keywords set
128
+ - [x] `requires-python = ">=3.10"` — the MCP SDK's floor, verified by installing on 3.10
129
+ - [x] Wheels install from `--find-links` with no repo present
130
+ - [x] `curator-mcp` exposes the `curator-mcp` console script
131
+ - [ ] **PyPI token** — needs a human
132
+ - [ ] Repo public (a stated requirement of every Graph track anyway)