cyan-eclipse-book 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.
bazaar-manifest.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "bench": {
3
+ "bench_verdict": "CERTIFIED(local)",
4
+ "certified": true,
5
+ "checks": [
6
+ "criteria edit mid-position: one-word change -> CRITERIA_CHANGED event + receipt; position keeps as-of-entry, flag set",
7
+ "resolution_verify against a WRONG source refused by name; correct source verifies",
8
+ "DISPUTED blocks verification until cleared",
9
+ "fee math recomputed independently (all-in buy AND sell, per-unit + total); event-contract max payout attached",
10
+ "missing schedule AND stale schedule -> FEE_UNVERIFIED while bid/ask still serve",
11
+ "probability scan: planted keys in provenance, criteria-object field name, nested trading_window \u2014 never emitted; verbatim carriers refused by name",
12
+ "UNMAPPED native status preserved verbatim",
13
+ "order_draft: DRAFT only, NOT_EVALUATED mandate note, receipt ref; off-allowlist type refused",
14
+ "capability denylist by enumeration: exactly the 17 M0 tools; placement/withdraw/account/advice names refused",
15
+ "auth enforced",
16
+ "schema valid",
17
+ "no network surface (source-level)",
18
+ "no secret leak",
19
+ "sane errors",
20
+ "process health",
21
+ "audit completeness (receipt chain recomputed independently)",
22
+ "bench isolation"
23
+ ],
24
+ "not_tested": [
25
+ "Live venue reads inside the shipped build \u2014 M0 contains no network code path; live Kalshi/Polymarket public payloads were verified through the transport seam from the Verity bench seat 2026-11-02 (BENCH-VERITY.md: PASS-WITH-CONDITIONS), but no in-build live adapter ships in v0.1.0.",
26
+ "Order placement, account reads, and the Wallet mandate gate \u2014 M2/M1 scope, owner-gated (spec 18, decision D3). M0 order_draft records an unsubmitted DRAFT with mandate_pre_eval NOT_EVALUATED; no submission path exists.",
27
+ "Fee schedules as live data \u2014 shipped schedules are fixture-shaped records with provenance labels saying so; the live fetch + account-specific tier provenance lands with the adapters' live step.",
28
+ "Kalshi / Polymarket US / Kraken / Binance.US / Alpaca chapters \u2014 specced (suite spec 10-12), not built in M0."
29
+ ],
30
+ "scorecard_ref": "test-bench/results/eclipse-book-mcp-2026-10-25.json",
31
+ "status": "CERTIFIED(local) \u2014 Eclipse Bazaar Test Bench 2026-10-25 (scorecard eclipse-book-mcp-2026-10-25.json): 16 pass / 1 na / 0 fail over 17 checks incl. bench isolation. M0 data-only scope; live venue reads not_tested per the preserved not_tested list."
32
+ },
33
+ "category": "Markets / Exchange & Prediction-Market Infrastructure",
34
+ "created": "2026-10-25",
35
+ "description": "The Market Core of the Eclipse Markets suite (spec eclipse-markets-suite-spec-20261025): one canonical Instrument / Book / Quote / Order / Position / Resolution model over exchanges and prediction markets, with venue adapters that translate and never improvise. Resolution criteria are stored verbatim and hash-pinned \u2014 a venue criteria edit is a CRITERIA_CHANGED event and positions keep the criteria as of entry. Quotes carry all-in buy/sell cost for a stated size from versioned, provenance-stamped fee schedules; a stale or missing schedule degrades all-in fields to FEE_UNVERIFIED rather than quoting a wrong number. Resolutions move REPORTED -> VERIFIED only against the criteria's own named source (DISPUTED is first-class), and no core output ever carries a probability field \u2014 outputs carry price + liquidity flag + depth + verbatim criteria. M0 is data only: no accounts, no keys, no order submission at any tier; order_draft is an unsubmitted record whose mandate pre-evaluation is honestly NOT_EVALUATED until the owner-gated M2.",
36
+ "icon": "icon-512.png",
37
+ "name": "Eclipse Book MCP",
38
+ "powered_by": "Cyan Eclipse Technologies",
39
+ "price_model": "Free local v0.1; family pricing TBD per spec",
40
+ "repo": null,
41
+ "slug": "eclipse-book-mcp",
42
+ "status": "Live \u2014 v0.1.0 M0 local build (Market Core, data only; CERTIFIED(local) \u2014 Eclipse Bazaar Test Bench 2026-10-25; Verity bench 2026-11-02 PASS-WITH-CONDITIONS incl. live Kalshi/Polymarket public reads via the transport seam \u2014 see BENCH-VERITY.md)",
43
+ "tagline": "Market data + venue access layer \u2014 not an exchange: one book, one fee truth, one ledger of record.",
44
+ "tools": [
45
+ "instrument_ingest",
46
+ "instrument_get",
47
+ "instrument_search",
48
+ "book_ingest",
49
+ "quote_get",
50
+ "fee_schedule_register",
51
+ "fee_schedule_get",
52
+ "position_open",
53
+ "position_list",
54
+ "position_get",
55
+ "resolution_report",
56
+ "resolution_verify",
57
+ "resolution_get",
58
+ "order_draft",
59
+ "order_get",
60
+ "venue_status",
61
+ "receipt_list"
62
+ ],
63
+ "transport": "MCP over stdio (HTTP transport on roadmap)",
64
+ "vendor": "Cyan Eclipse Technologies",
65
+ "x402_ready": false
66
+ }
book_engine.py ADDED
@@ -0,0 +1,619 @@
1
+ # Eclipse Book MCP
2
+ # Copyright © 2026 Cyan Eclipse Technologies LLC · cetech.ai
3
+ # Eclipse Book — shared Market Core engine (spec
4
+
5
+ """Eclipse Book — shared Market Core engine (spec
6
+ eclipse-markets-suite-spec-20261025 sections 5-7).
7
+
8
+ This module is the suite law in code: canonical Instrument /
9
+ Book / Quote / Order / Position / Resolution objects, fee truth
10
+ (section 5.3), the resolution-verification discipline (5.4),
11
+ liquidity + probability honesty (5.5), and the append-only
12
+ hash-chained receipt ledger.
13
+
14
+ Adapters (Forecast, Strike first) import this module from the
15
+ sibling core directory and translate venue-native payloads into
16
+ engine calls against the SHARED suite state, loaded fresh at
17
+ every call. An adapter may never emit a core object it had to
18
+ guess at: missing native fields surface as explicit gaps, and a
19
+ venue status that does not map into the core vocabulary surfaces
20
+ as UNMAPPED with the native string preserved, never coerced
21
+ (section 5.2).
22
+
23
+ M0 SCOPE: data only. This engine contains no order-submission,
24
+ account, withdrawal, or transfer capability at any tier — the
25
+ Order object exists as a DRAFT record (section 5.6) whose mandate
26
+ pre-evaluation is honestly NOT_EVALUATED in M0. Placement is M2,
27
+ owner-gated (spec section 18, decision D3).
28
+
29
+ Pure functions over a state dict: load/save are the caller's
30
+ job. Stdlib only. Money math is Decimal end-to-end; prices are
31
+ decimal strings on every surface (section 5.2).
32
+ """
33
+ import copy
34
+ import hashlib
35
+ import json
36
+ from datetime import datetime, timezone
37
+ from decimal import Decimal
38
+
39
+
40
+ class ToolError(Exception):
41
+ pass
42
+
43
+
44
+ KINDS = {"SPOT", "EVENT_CONTRACT", "EQUITY", "PLAY_EVENT_CONTRACT"}
45
+ STATUSES = {"OPEN", "CLOSED", "RESOLVED", "VOIDED"}
46
+ MONEY_CLASSES = {"REAL", "PLAY"}
47
+ BOOK_TYPES = {"CLOB", "AMM", "INDICATIVE"}
48
+ RESOLUTION_STATES = {"NONE", "REPORTED", "VERIFIED", "DISPUTED"}
49
+ ORDER_TYPES = {"MARKET", "LIMIT"}
50
+ ORDER_STATUSES = {"DRAFT", "SUBMITTED", "OPEN", "PARTIAL", "FILLED",
51
+ "CANCELLED", "REJECTED", "EXPIRED"}
52
+
53
+ # Liquidity law (spec 5.5): core-owned thresholds, notional in the
54
+ # instrument's currency at the best ask. Adapters may tighten,
55
+ # never loosen. DEEP >= 10000, THIN < 1000, else FRAGILE.
56
+ LIQUIDITY_DEEP_MIN = Decimal("10000")
57
+ LIQUIDITY_THIN_MAX = Decimal("1000")
58
+
59
+ DEFAULT_FEE_FRESHNESS_HOURS = 24
60
+ DEFAULT_BOOK_STALE_SECONDS = 86400
61
+
62
+
63
+ def now_iso() -> str:
64
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
65
+
66
+
67
+ def parse_time(value):
68
+ if value is None:
69
+ return None
70
+ text = str(value).strip()
71
+ if len(text) == 10:
72
+ text += "T00:00:00+00:00"
73
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
74
+
75
+
76
+ def sha(text: str) -> str:
77
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
78
+
79
+
80
+ def canon(obj) -> str:
81
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
82
+
83
+
84
+ def D(value) -> Decimal:
85
+ return Decimal(str(value))
86
+
87
+
88
+ def ds(value) -> str:
89
+ """Decimal -> canonical decimal string (no exponent, no
90
+ trailing-zero noise)."""
91
+ d = D(value)
92
+ s = format(d, "f")
93
+ if "." in s:
94
+ s = s.rstrip("0").rstrip(".")
95
+ return s if s not in ("", "-0") else "0"
96
+
97
+
98
+ # ---------------------------------------------------------------- receipts
99
+ def emit_receipt(state: dict, rtype: str, detail: dict) -> dict:
100
+ prev = state["receipts"][-1]["hash"] if state["receipts"] else "GENESIS"
101
+ entry = {"seq": len(state["receipts"]) + 1, "ts": now_iso(),
102
+ "type": rtype, "prev_hash": prev, "detail": detail}
103
+ entry["hash"] = sha(prev + canon(
104
+ {k: v for k, v in entry.items() if k != "hash"}))
105
+ state["receipts"].append(entry)
106
+ return entry
107
+
108
+
109
+ def verify_receipt_chain(receipts) -> bool:
110
+ prev = "GENESIS"
111
+ for entry in receipts:
112
+ expect = sha(prev + canon(
113
+ {k: v for k, v in entry.items() if k != "hash"}))
114
+ if entry.get("hash") != expect or entry.get("prev_hash") != prev:
115
+ return False
116
+ prev = entry["hash"]
117
+ return True
118
+
119
+
120
+ # ------------------------------------------------------- probability law
121
+ def probability_scan(obj, path="$"):
122
+ """Spec 5.5: the core never emits a probability field.
123
+ Returns the list of offending key paths (keys only — a price
124
+ is a price, and no output key may name it anything else)."""
125
+ hits = []
126
+ if isinstance(obj, dict):
127
+ for key, value in obj.items():
128
+ if "probab" in str(key).lower():
129
+ hits.append(f"{path}.{key}")
130
+ hits.extend(probability_scan(value, f"{path}.{key}"))
131
+ elif isinstance(obj, list):
132
+ for i, value in enumerate(obj):
133
+ hits.extend(probability_scan(value, f"{path}[{i}]"))
134
+ return hits
135
+
136
+
137
+ def guard_no_probability(obj):
138
+ hits = probability_scan(obj)
139
+ if hits:
140
+ raise ToolError(
141
+ "PROBABILITY_FIELD_FORBIDDEN: core outputs carry price + "
142
+ f"liquidity + criteria, never a probability field ({hits})")
143
+ return obj
144
+
145
+
146
+ # ------------------------------------------------------------------- state
147
+ def empty_state() -> dict:
148
+ return {"config": {"fee_freshness_hours": DEFAULT_FEE_FRESHNESS_HOURS,
149
+ "book_stale_seconds": DEFAULT_BOOK_STALE_SECONDS},
150
+ "instruments": {}, "books": {}, "fee_schedules": {},
151
+ "positions": {}, "orders": {}, "receipts": []}
152
+
153
+
154
+ def ensure_seeded(state: dict, seed: dict) -> dict:
155
+ state["config"].update(seed.get("config", {}))
156
+ for sched in seed.get("fee_schedules", []):
157
+ if sched["schedule_ref"] not in state["fee_schedules"]:
158
+ state["fee_schedules"][sched["schedule_ref"]] = sched
159
+ return state
160
+
161
+
162
+ # ------------------------------------------------------------ fee schedules
163
+ def fee_schedule_register(state: dict, args: dict) -> dict:
164
+ """Fee schedules are versioned data with provenance (5.3):
165
+ source + fetched_at are required; a schedule without them is
166
+ not a schedule."""
167
+ for field in ("venue", "version", "source", "fetched_at",
168
+ "taker_fee_bps"):
169
+ if args.get(field) in (None, ""):
170
+ raise ToolError(f"FEE_SCHEDULE_PROVENANCE_REQUIRED: {field}")
171
+ ref = f"{args['venue']}@{args['version']}"
172
+ sched = {"schedule_ref": ref, "venue": args["venue"],
173
+ "version": args["version"], "source": args["source"],
174
+ "fetched_at": args["fetched_at"],
175
+ "taker_fee_bps": ds(args["taker_fee_bps"]),
176
+ "maker_fee_bps": ds(args.get("maker_fee_bps", "0")),
177
+ "settlement_fee_model": args.get(
178
+ "settlement_fee_model", {"type": "none"}),
179
+ "freshness_hours": int(args.get(
180
+ "freshness_hours",
181
+ state["config"]["fee_freshness_hours"]))}
182
+ state["fee_schedules"][ref] = sched
183
+ emit_receipt(state, "fee_schedule_registered",
184
+ {"schedule_ref": ref, "venue": sched["venue"],
185
+ "fetched_at": sched["fetched_at"]})
186
+ return {"fee_schedule": sched}
187
+
188
+
189
+ def fee_schedule_for(state: dict, venue: str):
190
+ """Latest registered schedule for a venue, or None."""
191
+ cands = [s for s in state["fee_schedules"].values()
192
+ if s["venue"] == venue]
193
+ if not cands:
194
+ return None
195
+ return sorted(cands, key=lambda s: s["fetched_at"])[-1]
196
+
197
+
198
+ def fee_freshness(state: dict, venue: str, as_of: str):
199
+ """-> (schedule, status): FRESH | STALE | NO_SCHEDULE."""
200
+ sched = fee_schedule_for(state, venue)
201
+ if sched is None:
202
+ return None, "NO_SCHEDULE"
203
+ age_h = (parse_time(as_of) - parse_time(
204
+ sched["fetched_at"])).total_seconds() / 3600.0
205
+ if age_h > sched["freshness_hours"]:
206
+ return sched, "STALE"
207
+ return sched, "FRESH"
208
+
209
+
210
+ # --------------------------------------------------------------- instruments
211
+ def instrument_id_for(venue: str, kind: str, native_id: str) -> str:
212
+ wing = "B" if kind in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT") else "A"
213
+ return f"{wing}:{venue}:{native_id}"
214
+
215
+
216
+ def ingest_instrument(state: dict, spec_in: dict) -> dict:
217
+ """Register or refresh a canonical Instrument. Resolution
218
+ criteria are verbatim + hash-pinned at first read (5.4): a
219
+ later ingest carrying different criteria records a
220
+ CRITERIA_CHANGED event; the instrument keeps every version,
221
+ and positions keep the criteria as of their entry."""
222
+ for field in ("venue", "native_id", "kind", "title"):
223
+ if not spec_in.get(field):
224
+ raise ToolError(f"INSTRUMENT_FIELD_REQUIRED: {field}")
225
+ kind = spec_in["kind"]
226
+ if kind not in KINDS:
227
+ raise ToolError(f"INSTRUMENT_KIND_UNKNOWN: {kind}")
228
+ money_class = spec_in.get("money_class", "REAL")
229
+ if money_class not in MONEY_CLASSES:
230
+ raise ToolError(f"MONEY_CLASS_UNKNOWN: {money_class}")
231
+ if kind == "PLAY_EVENT_CONTRACT" and money_class != "PLAY":
232
+ raise ToolError("PLAY_INSTRUMENT_REQUIRES_PLAY_MONEY_CLASS")
233
+ criteria = spec_in.get("resolution_criteria")
234
+ if kind in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT"):
235
+ if not criteria or not criteria.get("text"):
236
+ raise ToolError(
237
+ "RESOLUTION_CRITERIA_REQUIRED: an event contract "
238
+ "without verbatim criteria is not an instrument")
239
+ if not spec_in.get("resolution_source"):
240
+ raise ToolError(
241
+ "RESOLUTION_SOURCE_REQUIRED: the named resolver is "
242
+ "part of the instrument (spec 5.4)")
243
+ native_status = spec_in.get("native_status")
244
+ mapped = spec_in.get("status_mapped")
245
+ status = mapped if mapped in STATUSES else "UNMAPPED"
246
+ iid = instrument_id_for(spec_in["venue"], kind, spec_in["native_id"])
247
+ existing = state["instruments"].get(iid)
248
+ event = None
249
+ if existing is None:
250
+ inst = {"instrument_id": iid, "venue": spec_in["venue"],
251
+ "native_id": spec_in["native_id"],
252
+ "native_symbol": spec_in.get("native_symbol"),
253
+ "kind": kind, "title": spec_in["title"],
254
+ "underlying": spec_in.get("underlying"),
255
+ "outcomes": spec_in.get("outcomes", []),
256
+ "resolution_source": spec_in.get("resolution_source"),
257
+ "trading_window": spec_in.get("trading_window", {}),
258
+ "tick": spec_in.get("tick"),
259
+ "min_size": spec_in.get("min_size"),
260
+ "currency": spec_in.get("currency", "USD"),
261
+ "money_class": money_class, "status": status,
262
+ "native_status": native_status,
263
+ "provenance": spec_in.get("provenance", {}),
264
+ "resolution": {"state": "NONE", "outcome": None,
265
+ "source": None, "evidence_ref": None,
266
+ "criteria_match": None, "history": []}}
267
+ if criteria:
268
+ inst["resolution_criteria"] = criteria["text"]
269
+ inst["resolution_criteria_source_ref"] = criteria.get(
270
+ "source_ref")
271
+ inst["criteria_versions"] = [{
272
+ "hash": sha(criteria["text"]), "text": criteria["text"],
273
+ "source_ref": criteria.get("source_ref"),
274
+ "first_seen": now_iso()}]
275
+ # Probability guard runs PRE-COMMIT (PP3-H3): a refused
276
+ # ingest writes nothing — no instrument, no receipt.
277
+ guard_no_probability(inst)
278
+ state["instruments"][iid] = inst
279
+ emit_receipt(state, "instrument_ingested",
280
+ {"instrument_id": iid, "venue": inst["venue"],
281
+ "kind": kind})
282
+ return {"instrument": inst, "criteria_changed": None}
283
+ # Refresh: status/native fields update; criteria are pinned.
284
+ # Mutations land on a copy and commit only after the
285
+ # probability guard passes (PP3-H3) — a refused refresh
286
+ # leaves the stored instrument and receipts untouched.
287
+ updated = copy.deepcopy(existing)
288
+ updated["status"] = status
289
+ updated["native_status"] = native_status
290
+ if spec_in.get("trading_window"):
291
+ updated["trading_window"] = spec_in["trading_window"]
292
+ if criteria:
293
+ new_hash = sha(criteria["text"])
294
+ cur_hash = updated["criteria_versions"][-1]["hash"]
295
+ if new_hash != cur_hash:
296
+ updated["criteria_versions"].append({
297
+ "hash": new_hash, "text": criteria["text"],
298
+ "source_ref": criteria.get("source_ref"),
299
+ "first_seen": now_iso()})
300
+ updated["resolution_criteria"] = criteria["text"]
301
+ updated["resolution_criteria_source_ref"] = criteria.get(
302
+ "source_ref")
303
+ event = {"type": "CRITERIA_CHANGED", "instrument_id": iid,
304
+ "old_hash": cur_hash, "new_hash": new_hash}
305
+ guard_no_probability(updated)
306
+ state["instruments"][iid] = updated
307
+ if event is not None:
308
+ emit_receipt(state, "criteria_changed", dict(event))
309
+ return {"instrument": updated, "criteria_changed": event}
310
+
311
+
312
+ def criteria_snapshot(inst: dict):
313
+ if "criteria_versions" not in inst:
314
+ return None
315
+ cur = inst["criteria_versions"][-1]
316
+ return {"text": cur["text"], "hash": cur["hash"],
317
+ "source_ref": cur.get("source_ref")}
318
+
319
+
320
+ # --------------------------------------------------------------------- books
321
+ def build_book(instrument_id: str, venue: str, book_type: str,
322
+ bids, asks, as_of: str, tick=None, native_ref=None):
323
+ """Canonical Book from normalized levels. bids/asks: lists of
324
+ {price, size} with decimal-string values, any order — the
325
+ builder sorts (bids desc, asks asc) and preserves venue-native
326
+ precision by keeping the strings it was given."""
327
+ if book_type not in BOOK_TYPES:
328
+ raise ToolError(f"BOOK_TYPE_UNKNOWN: {book_type}")
329
+ bids_s = sorted(bids, key=lambda lv: D(lv["price"]), reverse=True)
330
+ asks_s = sorted(asks, key=lambda lv: D(lv["price"]))
331
+ book = {"instrument_id": instrument_id, "venue": venue,
332
+ "as_of": as_of, "book_type": book_type,
333
+ "bids": [{"price": ds(lv["price"]), "size": ds(lv["size"])}
334
+ for lv in bids_s],
335
+ "asks": [{"price": ds(lv["price"]), "size": ds(lv["size"])}
336
+ for lv in asks_s],
337
+ "native_ref": native_ref}
338
+ book["depth_summary"] = depth_summary(book, tick)
339
+ if bids_s and asks_s:
340
+ bid, ask = D(bids_s[0]["price"]), D(asks_s[0]["price"])
341
+ mid = (bid + ask) / 2
342
+ book["spread_bps"] = ds((ask - bid) / mid * 10000) if mid else None
343
+ else:
344
+ book["spread_bps"] = None
345
+ return book
346
+
347
+
348
+ def depth_summary(book: dict, tick=None) -> dict:
349
+ bids, asks = book["bids"], book["asks"]
350
+ out = {"size_at_best_bid": bids[0]["size"] if bids else "0",
351
+ "size_at_best_ask": asks[0]["size"] if asks else "0",
352
+ "notional_at_best_ask": "0", "size_within_5_ticks_bid": "0",
353
+ "size_within_5_ticks_ask": "0"}
354
+ if asks:
355
+ out["notional_at_best_ask"] = ds(
356
+ D(asks[0]["size"]) * D(asks[0]["price"]))
357
+ if tick:
358
+ t = D(tick)
359
+ if bids:
360
+ floor = D(bids[0]["price"]) - 5 * t
361
+ out["size_within_5_ticks_bid"] = ds(sum(
362
+ D(lv["size"]) for lv in bids if D(lv["price"]) >= floor))
363
+ if asks:
364
+ cap = D(asks[0]["price"]) + 5 * t
365
+ out["size_within_5_ticks_ask"] = ds(sum(
366
+ D(lv["size"]) for lv in asks if D(lv["price"]) <= cap))
367
+ return out
368
+
369
+
370
+ def liquidity_flag(book: dict) -> str:
371
+ notional = D(book["depth_summary"]["notional_at_best_ask"])
372
+ if notional >= LIQUIDITY_DEEP_MIN:
373
+ return "DEEP"
374
+ if notional < LIQUIDITY_THIN_MAX:
375
+ return "THIN"
376
+ return "FRAGILE"
377
+
378
+
379
+ def ingest_book(state: dict, book: dict) -> dict:
380
+ iid = book["instrument_id"]
381
+ if iid not in state["instruments"]:
382
+ raise ToolError(f"BOOK_FOR_UNKNOWN_INSTRUMENT: {iid}")
383
+ outcome = book.get("outcome")
384
+ key = f"{iid}|{outcome}" if outcome else iid
385
+ book = dict(book)
386
+ book["outcome"] = outcome
387
+ state["books"][key] = book
388
+ return {"book": book}
389
+
390
+
391
+ def get_book(state: dict, instrument_id: str, outcome=None):
392
+ key = f"{instrument_id}|{outcome}" if outcome else instrument_id
393
+ book = state["books"].get(key)
394
+ if book is None and outcome is None:
395
+ cands = [b for k, b in state["books"].items()
396
+ if k.startswith(instrument_id + "|")]
397
+ if len(cands) == 1:
398
+ book = cands[0]
399
+ if book is None:
400
+ raise ToolError(f"BOOK_NOT_FOUND: {key}")
401
+ return book
402
+
403
+
404
+ # -------------------------------------------------------------------- quotes
405
+ def quote_get(state: dict, instrument_id: str, size,
406
+ outcome=None, as_of=None) -> dict:
407
+ """The ONLY price-shaped object consumers may receive (5.1):
408
+ price + all-in cost for a stated size + fee-truth state +
409
+ liquidity + (for event contracts) the criteria attached.
410
+ Never a probability field (5.5)."""
411
+ inst = state["instruments"].get(instrument_id)
412
+ if inst is None:
413
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {instrument_id}")
414
+ book = get_book(state, instrument_id, outcome)
415
+ if not book["bids"] or not book["asks"]:
416
+ raise ToolError("BOOK_ONE_SIDED: no two-sided book, no quote")
417
+ as_of = as_of or now_iso()
418
+ bid = D(book["bids"][0]["price"])
419
+ ask = D(book["asks"][0]["price"])
420
+ mid = (bid + ask) / 2
421
+ n = D(size)
422
+ sched, fee_status = fee_freshness(state, inst["venue"], as_of)
423
+ quote = {"instrument_id": instrument_id, "venue": inst["venue"],
424
+ "outcome": outcome, "as_of": as_of,
425
+ "book_as_of": book["as_of"], "bid": ds(bid), "ask": ds(ask),
426
+ "mid": ds(mid), "mid_basis": "computed",
427
+ "spread_bps": book["spread_bps"],
428
+ "size": ds(n), "currency": inst["currency"],
429
+ "money_class": inst["money_class"],
430
+ "liquidity_flag": liquidity_flag(book),
431
+ "depth_summary": book["depth_summary"],
432
+ "fee_status": fee_status,
433
+ "fee_schedule_ref": sched["schedule_ref"] if sched else None}
434
+ age = (parse_time(as_of) - parse_time(
435
+ book["as_of"])).total_seconds()
436
+ quote["book_stale"] = age > state["config"]["book_stale_seconds"]
437
+ if fee_status == "FRESH":
438
+ bps = D(sched["taker_fee_bps"])
439
+ buy_gross = ask * n
440
+ buy_fee = buy_gross * bps / 10000
441
+ sell_gross = bid * n
442
+ sell_fee = sell_gross * bps / 10000
443
+ quote["all_in_buy_cost"] = {
444
+ "total": ds(buy_gross + buy_fee),
445
+ "per_unit": ds((buy_gross + buy_fee) / n),
446
+ "fee_amount": ds(buy_fee),
447
+ "fee_schedule_ref": sched["schedule_ref"]}
448
+ quote["all_in_sell_value"] = {
449
+ "total": ds(sell_gross - sell_fee),
450
+ "per_unit": ds((sell_gross - sell_fee) / n),
451
+ "fee_amount": ds(sell_fee),
452
+ "fee_schedule_ref": sched["schedule_ref"]}
453
+ quote["settlement_fee_model"] = sched["settlement_fee_model"]
454
+ if inst["kind"] in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT"):
455
+ quote["max_payout_for_size"] = ds(n * D(1))
456
+ else:
457
+ # A wrong all-in number is worse than no number (5.3).
458
+ quote["all_in_buy_cost"] = "FEE_UNVERIFIED"
459
+ quote["all_in_sell_value"] = "FEE_UNVERIFIED"
460
+ if inst["kind"] in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT"):
461
+ snap = criteria_snapshot(inst)
462
+ quote["resolution_criteria"] = snap["text"]
463
+ quote["resolution_criteria_hash"] = snap["hash"]
464
+ quote["resolution_source"] = inst["resolution_source"]
465
+ return guard_no_probability(quote)
466
+
467
+
468
+ # ----------------------------------------------------------------- positions
469
+ def position_open(state: dict, args: dict) -> dict:
470
+ inst = state["instruments"].get(args["instrument_id"])
471
+ if inst is None:
472
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
473
+ pid = f"pos-{len(state['positions']) + 1}"
474
+ snap = criteria_snapshot(inst)
475
+ pos = {"position_id": pid, "instrument_id": inst["instrument_id"],
476
+ "venue": inst["venue"],
477
+ "account_ref": args.get("account_ref", "owner"),
478
+ "outcome": args.get("outcome"),
479
+ "size": ds(args["size"]),
480
+ "avg_cost": ds(args["entry_price"]),
481
+ "avg_cost_basis": "all-in per unit, as supplied at entry",
482
+ "settlement_state": "OPEN",
483
+ "criteria_as_of_entry": snap,
484
+ "opened_at": now_iso()}
485
+ state["positions"][pid] = pos
486
+ emit_receipt(state, "position_opened",
487
+ {"position_id": pid, "instrument_id": inst["instrument_id"],
488
+ "criteria_hash": snap["hash"] if snap else None})
489
+ return {"position": position_view(state, pos)}
490
+
491
+
492
+ def position_view(state: dict, pos: dict) -> dict:
493
+ inst = state["instruments"][pos["instrument_id"]]
494
+ view = dict(pos)
495
+ cur = criteria_snapshot(inst)
496
+ view["criteria_current"] = cur
497
+ entry = pos.get("criteria_as_of_entry")
498
+ view["criteria_changed_since_entry"] = bool(
499
+ entry and cur and entry["hash"] != cur["hash"])
500
+ view["resolution_state"] = inst["resolution"]["state"]
501
+ view["money_class"] = inst["money_class"]
502
+ return guard_no_probability(view)
503
+
504
+
505
+ # --------------------------------------------------------------- resolutions
506
+ def resolution_report(state: dict, args: dict) -> dict:
507
+ """A resolution is a claim until verified (5.4): REPORTED."""
508
+ inst = state["instruments"].get(args["instrument_id"])
509
+ if inst is None:
510
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
511
+ res = inst["resolution"]
512
+ if args.get("disputed"):
513
+ res["state"] = "DISPUTED"
514
+ elif res["state"] in ("NONE", "DISPUTED"):
515
+ res["state"] = "REPORTED"
516
+ res["outcome"] = args["outcome"]
517
+ res["source"] = args.get("source")
518
+ res["evidence_ref"] = args.get("evidence_ref")
519
+ res["history"].append({"state": res["state"], "ts": now_iso(),
520
+ "outcome": args["outcome"],
521
+ "evidence_ref": args.get("evidence_ref")})
522
+ emit_receipt(state, "resolution_reported",
523
+ {"instrument_id": inst["instrument_id"],
524
+ "state": res["state"], "outcome": args["outcome"]})
525
+ return {"resolution": resolution_view(inst)}
526
+
527
+
528
+ def resolution_verify(state: dict, args: dict) -> dict:
529
+ """VERIFIED only against the criteria's OWN named source
530
+ (5.4). A verification attempt naming any other source is
531
+ refused by name; a live dispute keeps the state DISPUTED."""
532
+ inst = state["instruments"].get(args["instrument_id"])
533
+ if inst is None:
534
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
535
+ res = inst["resolution"]
536
+ if res["state"] == "NONE":
537
+ raise ToolError("RESOLUTION_NOT_REPORTED: nothing to verify")
538
+ named = inst.get("resolution_source")
539
+ if args.get("verification_source") != named:
540
+ raise ToolError(
541
+ f"RESOLUTION_SOURCE_MISMATCH: criteria name '{named}' as "
542
+ f"the source of truth; '{args.get('verification_source')}' "
543
+ "cannot verify this resolution")
544
+ if res["state"] == "DISPUTED" and not args.get("dispute_cleared"):
545
+ raise ToolError(
546
+ "RESOLUTION_DISPUTED: dispute is open; verification waits "
547
+ "for finality (spec 5.4)")
548
+ res["state"] = "VERIFIED"
549
+ res["criteria_match"] = bool(args.get("criteria_match", True))
550
+ res["history"].append({"state": "VERIFIED", "ts": now_iso(),
551
+ "verification_source": named,
552
+ "criteria_match": res["criteria_match"]})
553
+ emit_receipt(state, "resolution_verified",
554
+ {"instrument_id": inst["instrument_id"],
555
+ "outcome": res["outcome"],
556
+ "criteria_match": res["criteria_match"]})
557
+ return {"resolution": resolution_view(inst)}
558
+
559
+
560
+ def resolution_view(inst: dict) -> dict:
561
+ res = inst["resolution"]
562
+ return guard_no_probability({
563
+ "instrument_id": inst["instrument_id"],
564
+ "state": res["state"], "verification": res["state"],
565
+ "outcome": res["outcome"], "source": res["source"],
566
+ "named_resolution_source": inst.get("resolution_source"),
567
+ "evidence_ref": res["evidence_ref"],
568
+ "criteria_match": res["criteria_match"],
569
+ "history": res["history"]})
570
+
571
+
572
+ # -------------------------------------------------------------------- orders
573
+ def order_draft(state: dict, args: dict) -> dict:
574
+ """M0: a DRAFT record only. No submission path exists in
575
+ this build at any tier; the mandate pre-evaluation is
576
+ honestly NOT_EVALUATED until M2 wires the Wallet gate
577
+ (spec 5.6 / 7.2)."""
578
+ inst = state["instruments"].get(args["instrument_id"])
579
+ if inst is None:
580
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
581
+ otype = args.get("type", "LIMIT")
582
+ if otype not in ORDER_TYPES:
583
+ raise ToolError(
584
+ f"ORDER_TYPE_NOT_IN_V1_ALLOWLIST: {otype} (MARKET|LIMIT only)")
585
+ oid = f"ord-{len(state['orders']) + 1}"
586
+ order = {"order_id": oid, "venue_order_id": None,
587
+ "instrument_id": inst["instrument_id"],
588
+ "venue": inst["venue"], "side": args["side"],
589
+ "type": otype, "size": ds(args["size"]),
590
+ "limit_price": ds(args["limit_price"])
591
+ if args.get("limit_price") is not None else None,
592
+ "status": "DRAFT", "fills": [],
593
+ "mandate_ref": None,
594
+ "mandate_pre_eval": "NOT_EVALUATED_M0_DATA_ONLY",
595
+ "created_at": now_iso()}
596
+ state["orders"][oid] = order
597
+ receipt = emit_receipt(state, "order_drafted",
598
+ {"order_id": oid,
599
+ "instrument_id": inst["instrument_id"]})
600
+ order["receipt_ref"] = receipt["hash"]
601
+ return {"order": order}
602
+
603
+
604
+ # ---------------------------------------------------------------- venue status
605
+ def venue_status(state: dict, venue: str, as_of=None) -> dict:
606
+ as_of = as_of or now_iso()
607
+ sched, fee_status = fee_freshness(state, venue, as_of)
608
+ insts = [i for i in state["instruments"].values()
609
+ if i["venue"] == venue]
610
+ return {"venue": venue, "as_of": as_of,
611
+ "capability": "M0_DATA_ONLY",
612
+ "order_tools": "ABSENT",
613
+ "account_tools": "ABSENT",
614
+ "instruments": len(insts),
615
+ "fee_schedule_ref": sched["schedule_ref"] if sched else None,
616
+ "fee_status": fee_status,
617
+ "chain_valid": verify_receipt_chain(state["receipts"])}
618
+
619
+ # Eclipse Book MCP · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: cyan-eclipse-book
3
+ Version: 0.1.0
4
+ Summary: Market data + venue access layer — not an exchange: one book, one fee truth, one ledger of record.
5
+ Project-URL: Homepage, https://cetech.ai
6
+ Project-URL: Repository, https://github.com/CYAN-ECLIPSE-TECHNOLOGIES-LLC/eclipse-book-mcp
7
+ Project-URL: Vendor, https://cetech.ai
8
+ Author: Cyan Eclipse Technologies LLC
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: fees,market-data,mcp,model-context-protocol,venues
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # cyan-eclipse-book
21
+
22
+ mcp-name: io.github.CYAN-ECLIPSE-TECHNOLOGIES-LLC/book
23
+
24
+ **What this is:** the MCP server for **Eclipse Book MCP** - The Market Core of the Eclipse Markets suite (spec eclipse-markets-suite-spec-20261025): one canonical Instrument / Book / Quote / Order / Position / Resolution model over exchanges and prediction markets, with venue adapters that translate and never improvise. Resolution criteria are stored verbatim and hash-pinned — a venue criteria edit is a CRITERIA_CHANGED event and positions keep the criteria as of entry. Quotes carry all-in buy/sell cost for a stated size from versioned, provenance-stamped fee schedules; a stale or missing schedule degrades all-in fields to FEE_UNVERIFIED rather than quoting a wrong number. Resolutions move REPORTED -> VERIFIED only against the criteria's own named source (DISPUTED is first-class), and no core output ever carries a probability field — outputs carry price + liquidity flag + depth + verbatim criteria. M0 is data only: no accounts, no keys, no order submission at any tier; order_draft is an unsubmitted record whose mandate pre-evaluation is honestly NOT_EVALUATED until the owner-gated M2.
25
+
26
+ ## Run it
27
+
28
+ ```console
29
+ $ pip install cyan-eclipse-book
30
+ $ eclipse-book # MCP over stdio, Python stdlib only
31
+ ```
32
+
33
+ MCP client config:
34
+
35
+ ```json
36
+ { "mcpServers": { "cyan-eclipse-book": { "command": "uvx", "args": ["--from", "cyan-eclipse-book", "eclipse-book"] } } }
37
+ ```
38
+
39
+ ## Tools (17)
40
+
41
+ `instrument_ingest` - `instrument_get` - `instrument_search` - `book_ingest` - `quote_get` - `fee_schedule_register` - `fee_schedule_get` - `position_open` - `position_list` - `position_get` - `resolution_report` - `resolution_verify` - `resolution_get` - `order_draft` - `order_get` - `venue_status` - `receipt_list`
42
+
43
+ ## Honest scope
44
+
45
+ - In development — v0.1.0 M0 local build (Market Core, data only; fixtures behind transport seams; CERTIFIED(local) — Eclipse Bazaar Test Bench 2026-10-25)
46
+ - State and runtime data files ship in the wheel beside `server.py`,
47
+ at the same relative paths the server reads them from.
48
+
49
+ Powered by Cyan Eclipse Technologies LLC (cetech.ai) - a Veteran-Owned
50
+ Small Business. MIT licensed.
@@ -0,0 +1,9 @@
1
+ bazaar-manifest.json,sha256=oPV-jqsBoreA4iCiYSNmy7uw9WVf6-RgxYe2Apg16Pw,4657
2
+ book_engine.py,sha256=fceXMnZLDbaeI_PV_JeSdN-yHfS3kVKPW-tQudWJdHE,26539
3
+ fixtures.json,sha256=t1d8_AuHUsGiK8cE3DjpwLp4JsJ9VPVZJrsE00Ot7ts,1130
4
+ server.py,sha256=KuPRo9uLtvThnjHIXpI0SQHjwFL3NA-6bYFzfnR-5co,16985
5
+ cyan_eclipse_book-0.1.0.dist-info/METADATA,sha256=ajV_lXtrcs9HS7tfNvVRo1DkjEm-pA4yYwW9F-8MqGI,2981
6
+ cyan_eclipse_book-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ cyan_eclipse_book-0.1.0.dist-info/entry_points.txt,sha256=xjkDyF8zjzh6lAKYcZuQGhZ4X1pDW0dFdpftfxHRltA,45
8
+ cyan_eclipse_book-0.1.0.dist-info/licenses/LICENSE,sha256=J64raxD33SRnnCn8p1F-lRl0rnesurM9Jlkd3auDZvA,1086
9
+ cyan_eclipse_book-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eclipse-book = server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cyan Eclipse Technologies LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
fixtures.json ADDED
@@ -0,0 +1 @@
1
+ {"config":{"book_stale_seconds":86400,"fee_freshness_hours":24},"fee_schedules":[{"fetched_at":"2026-10-25T00:00:00Z","freshness_hours":24,"maker_fee_bps":"0","schedule_ref":"polymarket_global@2026-07-01","settlement_fee_model":{"type":"none"},"source":"fixture: Polymarket fee schedule shape (per-market taker curve, maker zero) — live schedule is a build-time fetch, not_tested in M0","taker_fee_bps":"75","venue":"polymarket_global","version":"2026-07-01"},{"fetched_at":"2026-10-25T12:00:00Z","freshness_hours":72,"maker_fee_bps":"40","schedule_ref":"coinbase@2026-10-25","settlement_fee_model":{"type":"none"},"source":"fixture: Coinbase Advanced Trade public fee schedule shape — account-specific tiers supersede at M1","taker_fee_bps":"60","venue":"coinbase","version":"2026-10-25"},{"fetched_at":"2026-08-01T00:00:00Z","freshness_hours":24,"maker_fee_bps":"40","schedule_ref":"coinbase@2026-08-01-stale","settlement_fee_model":{"type":"none"},"source":"fixture: deliberately stale schedule used to prove FEE_UNVERIFIED degradation","taker_fee_bps":"60","venue":"coinbase_legacy_fixture","version":"2026-08-01-stale"}]}
server.py ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ # Eclipse Book MCP
3
+ # Copyright © 2026 Cyan Eclipse Technologies LLC · cetech.ai
4
+
5
+ """Eclipse Book MCP — v0.1.0, phase M0 (Market Core, data only).
6
+
7
+ Powered by Cyan Eclipse Technologies (cetech.ai).
8
+
9
+ Descriptor (mandatory with the name, spec section 3.1): Eclipse
10
+ Book — market data + venue access layer: exchanges and
11
+ prediction markets, normalized: one book, one fee truth, one
12
+ ledger of record. It is NOT an exchange, not a matching engine,
13
+ and never a bookmaker: it holds no funds, crosses no orders,
14
+ and takes no other side.
15
+
16
+ This server owns (spec eclipse-markets-suite-spec-20261025
17
+ section 5): the canonical Instrument / Book / Quote / Order /
18
+ Position / Resolution objects; resolution criteria stored
19
+ VERBATIM + hash-pinned (a criteria edit is a CRITERIA_CHANGED
20
+ event; positions keep the criteria as of entry); fee truth
21
+ (all-in cost per stated size from versioned, provenance-stamped
22
+ fee schedules — a stale schedule degrades the quote to
23
+ FEE_UNVERIFIED rather than quoting a wrong number); the
24
+ resolution lifecycle (REPORTED -> VERIFIED only against the
25
+ criteria's own named source; DISPUTED is first-class); and the
26
+ probability law (no core output carries a probability field —
27
+ outputs carry price + liquidity flag + depth + criteria ref).
28
+
29
+ ADAPTER SEAM (M0 fixture form, stated plainly): adapters import
30
+ book_engine from this directory and evaluate against the SHARED
31
+ suite state file ($ECLIPSE_BOOK_STATE or ./data/state.json
32
+ beside this file), loaded fresh at every call. Adapters own
33
+ translation from venue-native payloads; the core owns objects,
34
+ law, and the ledger.
35
+
36
+ M0 SCOPE: data only. No accounts, no keys, no order submission
37
+ exists in this build at any tier — order_draft produces an
38
+ unsubmitted DRAFT record whose mandate pre-evaluation is
39
+ honestly NOT_EVALUATED until M2 (owner-gated). No certification
40
+ claimed — bench pending.
41
+
42
+ Transport: MCP over stdio (newline-delimited JSON-RPC 2.0),
43
+ stdlib only. State: $ECLIPSE_BOOK_STATE or ./data/state.json,
44
+ seeded from fixtures.json.
45
+ """
46
+ import json
47
+ import os
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ HERE = Path(__file__).parent
52
+ sys.path.insert(0, str(HERE))
53
+
54
+ import book_engine as eng # noqa: E402
55
+ from book_engine import ToolError # noqa: E402
56
+
57
+ STATE_PATH = Path(os.environ.get(
58
+ "ECLIPSE_BOOK_STATE", HERE / "data" / "state.json"))
59
+ FIXTURES_PATH = HERE / "fixtures.json"
60
+
61
+
62
+ def load_state() -> dict:
63
+ if STATE_PATH.exists():
64
+ return json.loads(STATE_PATH.read_text())
65
+ state = eng.empty_state()
66
+ seed = json.loads(FIXTURES_PATH.read_text())
67
+ eng.ensure_seeded(state, seed)
68
+ save_state(state)
69
+ return state
70
+
71
+
72
+ def save_state(state: dict) -> None:
73
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
74
+ STATE_PATH.write_text(json.dumps(state, indent=1))
75
+
76
+
77
+ # ------------------------------------------------------------------- tools
78
+ def t_instrument_ingest(state, args):
79
+ return eng.ingest_instrument(state, args)
80
+
81
+
82
+ def t_instrument_get(state, args):
83
+ inst = state["instruments"].get(args["instrument_id"])
84
+ if inst is None:
85
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
86
+ return {"instrument": eng.guard_no_probability(inst)}
87
+
88
+
89
+ def t_instrument_search(state, args):
90
+ query = (args.get("query") or "").lower()
91
+ venue = args.get("venue")
92
+ out = []
93
+ for inst in state["instruments"].values():
94
+ if venue and inst["venue"] != venue:
95
+ continue
96
+ if query and query not in inst["title"].lower() \
97
+ and query not in inst["instrument_id"].lower():
98
+ continue
99
+ out.append({"instrument_id": inst["instrument_id"],
100
+ "venue": inst["venue"], "kind": inst["kind"],
101
+ "title": inst["title"], "status": inst["status"],
102
+ "native_status": inst["native_status"],
103
+ "money_class": inst["money_class"]})
104
+ return {"instruments": out, "count": len(out)}
105
+
106
+
107
+ def t_book_ingest(state, args):
108
+ book = eng.build_book(
109
+ args["instrument_id"], args["venue"],
110
+ args.get("book_type", "CLOB"), args["bids"], args["asks"],
111
+ args["as_of"], tick=args.get("tick"),
112
+ native_ref=args.get("native_ref"))
113
+ book["outcome"] = args.get("outcome")
114
+ return eng.ingest_book(state, book)
115
+
116
+
117
+ def t_quote_get(state, args):
118
+ return {"quote": eng.quote_get(
119
+ state, args["instrument_id"], args["size"],
120
+ outcome=args.get("outcome"), as_of=args.get("as_of"))}
121
+
122
+
123
+ def t_fee_schedule_register(state, args):
124
+ return eng.fee_schedule_register(state, args)
125
+
126
+
127
+ def t_fee_schedule_get(state, args):
128
+ sched, status = eng.fee_freshness(
129
+ state, args["venue"], args.get("as_of") or eng.now_iso())
130
+ if sched is None:
131
+ raise ToolError(f"FEE_SCHEDULE_NOT_FOUND: {args['venue']}")
132
+ return {"fee_schedule": sched, "fee_status": status}
133
+
134
+
135
+ def t_position_open(state, args):
136
+ return eng.position_open(state, args)
137
+
138
+
139
+ def t_position_list(state, args):
140
+ return {"positions": [eng.position_view(state, p)
141
+ for p in state["positions"].values()],
142
+ "count": len(state["positions"])}
143
+
144
+
145
+ def t_position_get(state, args):
146
+ pos = state["positions"].get(args["position_id"])
147
+ if pos is None:
148
+ raise ToolError(f"POSITION_NOT_FOUND: {args['position_id']}")
149
+ return {"position": eng.position_view(state, pos)}
150
+
151
+
152
+ def t_resolution_report(state, args):
153
+ return eng.resolution_report(state, args)
154
+
155
+
156
+ def t_resolution_verify(state, args):
157
+ return eng.resolution_verify(state, args)
158
+
159
+
160
+ def t_resolution_get(state, args):
161
+ inst = state["instruments"].get(args["instrument_id"])
162
+ if inst is None:
163
+ raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
164
+ return {"resolution": eng.resolution_view(inst)}
165
+
166
+
167
+ def t_order_draft(state, args):
168
+ return eng.order_draft(state, args)
169
+
170
+
171
+ def t_order_get(state, args):
172
+ order = state["orders"].get(args["order_id"])
173
+ if order is None:
174
+ raise ToolError(f"ORDER_NOT_FOUND: {args['order_id']}")
175
+ return {"order": order}
176
+
177
+
178
+ def t_venue_status(state, args):
179
+ return eng.venue_status(state, args["venue"],
180
+ as_of=args.get("as_of"))
181
+
182
+
183
+ def t_receipt_list(state, args):
184
+ limit = args.get("limit")
185
+ receipts = state["receipts"]
186
+ if limit is not None:
187
+ receipts = receipts[-int(limit):]
188
+ return {"receipts": receipts, "count": len(state["receipts"]),
189
+ "chain_valid": eng.verify_receipt_chain(state["receipts"])}
190
+
191
+
192
+ TOOLS = {
193
+ "instrument_ingest": (
194
+ t_instrument_ingest,
195
+ "T1 (core-local). Register or refresh a canonical "
196
+ "Instrument from an adapter-normalized spec. Event "
197
+ "contracts REQUIRE verbatim resolution criteria + a "
198
+ "named resolution source; criteria are hash-pinned and a "
199
+ "later change is a CRITERIA_CHANGED event (spec 5.4). "
200
+ "Unmapped venue statuses surface as UNMAPPED, never "
201
+ "coerced (5.2).",
202
+ {"venue": {"type": "string"}, "native_id": {"type": "string"},
203
+ "native_symbol": {"type": "string"},
204
+ "kind": {"type": "string"}, "title": {"type": "string"},
205
+ "underlying": {"type": "string"},
206
+ "outcomes": {"type": "array", "items": {"type": "string"}},
207
+ "resolution_criteria": {"type": "object"},
208
+ "resolution_source": {"type": "string"},
209
+ "trading_window": {"type": "object"},
210
+ "tick": {"type": "string"}, "min_size": {"type": "string"},
211
+ "currency": {"type": "string"},
212
+ "money_class": {"type": "string"},
213
+ "native_status": {"type": "string"},
214
+ "status_mapped": {"type": "string"},
215
+ "provenance": {"type": "object"}},
216
+ ["venue", "native_id", "kind", "title"]),
217
+ "instrument_get": (
218
+ t_instrument_get,
219
+ "T0. One canonical Instrument by core id, criteria "
220
+ "versions and resolution state included.",
221
+ {"instrument_id": {"type": "string"}}, ["instrument_id"]),
222
+ "instrument_search": (
223
+ t_instrument_search,
224
+ "T0. Search instruments by title/id substring, optional "
225
+ "venue filter.",
226
+ {"query": {"type": "string"}, "venue": {"type": "string"}}, []),
227
+ "book_ingest": (
228
+ t_book_ingest,
229
+ "T1 (core-local). Store a canonical Book snapshot built "
230
+ "from normalized levels for an ingested instrument "
231
+ "(per outcome for event contracts).",
232
+ {"instrument_id": {"type": "string"},
233
+ "venue": {"type": "string"}, "book_type": {"type": "string"},
234
+ "bids": {"type": "array", "items": {"type": "object"}},
235
+ "asks": {"type": "array", "items": {"type": "object"}},
236
+ "as_of": {"type": "string"}, "tick": {"type": "string"},
237
+ "outcome": {"type": "string"},
238
+ "native_ref": {"type": "string"}},
239
+ ["instrument_id", "venue", "bids", "asks", "as_of"]),
240
+ "quote_get": (
241
+ t_quote_get,
242
+ "T0. The only price-shaped object (spec 5.1): bid/ask/"
243
+ "computed mid, ALL-IN buy/sell cost for the stated size "
244
+ "from the venue's dated fee schedule, liquidity flag + "
245
+ "depth, and — for event contracts — the verbatim "
246
+ "resolution criteria. A stale or missing fee schedule "
247
+ "degrades all-in fields to FEE_UNVERIFIED (5.3). Never "
248
+ "a probability field (5.5).",
249
+ {"instrument_id": {"type": "string"},
250
+ "size": {"type": "string"}, "outcome": {"type": "string"},
251
+ "as_of": {"type": "string"}},
252
+ ["instrument_id", "size"]),
253
+ "fee_schedule_register": (
254
+ t_fee_schedule_register,
255
+ "T1 (core-local). Register a versioned fee schedule. "
256
+ "Provenance (source + fetched_at) is REQUIRED — a "
257
+ "schedule without it is not a schedule (5.3).",
258
+ {"venue": {"type": "string"}, "version": {"type": "string"},
259
+ "source": {"type": "string"},
260
+ "fetched_at": {"type": "string"},
261
+ "taker_fee_bps": {"type": "string"},
262
+ "maker_fee_bps": {"type": "string"},
263
+ "settlement_fee_model": {"type": "object"},
264
+ "freshness_hours": {"type": "integer"}},
265
+ ["venue", "version", "source", "fetched_at", "taker_fee_bps"]),
266
+ "fee_schedule_get": (
267
+ t_fee_schedule_get,
268
+ "T0. The venue's current fee schedule + its freshness "
269
+ "state (FRESH | STALE | NO_SCHEDULE).",
270
+ {"venue": {"type": "string"}, "as_of": {"type": "string"}},
271
+ ["venue"]),
272
+ "position_open": (
273
+ t_position_open,
274
+ "T1 (core-local record). Record a position with the "
275
+ "resolution criteria snapshotted AS OF ENTRY (5.4). A "
276
+ "record, not an execution: M0 places nothing.",
277
+ {"instrument_id": {"type": "string"},
278
+ "size": {"type": "string"},
279
+ "entry_price": {"type": "string"},
280
+ "outcome": {"type": "string"},
281
+ "account_ref": {"type": "string"}},
282
+ ["instrument_id", "size", "entry_price"]),
283
+ "position_list": (
284
+ t_position_list,
285
+ "T0/T1. All recorded positions, each carrying its "
286
+ "criteria-as-of-entry beside the current criteria and a "
287
+ "criteria_changed_since_entry flag.",
288
+ {}, []),
289
+ "position_get": (
290
+ t_position_get,
291
+ "T0/T1. One recorded position by id.",
292
+ {"position_id": {"type": "string"}}, ["position_id"]),
293
+ "resolution_report": (
294
+ t_resolution_report,
295
+ "T1 (core-local). Record a venue-reported resolution "
296
+ "(REPORTED) or mark a dispute (DISPUTED, first-class, "
297
+ "spec 5.4). A report is a claim, not a fact.",
298
+ {"instrument_id": {"type": "string"},
299
+ "outcome": {"type": "string"}, "source": {"type": "string"},
300
+ "evidence_ref": {"type": "string"},
301
+ "disputed": {"type": "boolean"}},
302
+ ["instrument_id", "outcome"]),
303
+ "resolution_verify": (
304
+ t_resolution_verify,
305
+ "T1 (core-local). Promote REPORTED -> VERIFIED, only "
306
+ "against the criteria's OWN named source of truth; any "
307
+ "other source is refused by name, and an open dispute "
308
+ "blocks verification until cleared (5.4).",
309
+ {"instrument_id": {"type": "string"},
310
+ "verification_source": {"type": "string"},
311
+ "criteria_match": {"type": "boolean"},
312
+ "dispute_cleared": {"type": "boolean"}},
313
+ ["instrument_id", "verification_source"]),
314
+ "resolution_get": (
315
+ t_resolution_get,
316
+ "T0. The Resolution record for an instrument: state "
317
+ "(NONE|REPORTED|VERIFIED|DISPUTED), outcome, sources, "
318
+ "history. Unverified is data, not fact (5.4).",
319
+ {"instrument_id": {"type": "string"}}, ["instrument_id"]),
320
+ "order_draft": (
321
+ t_order_draft,
322
+ "T1. Build an UNSUBMITTED Order in DRAFT (MARKET|LIMIT "
323
+ "only) with its receipt. M0 has no submission path at "
324
+ "any tier; mandate pre-evaluation reads "
325
+ "NOT_EVALUATED_M0_DATA_ONLY (spec 5.6, 7.2).",
326
+ {"instrument_id": {"type": "string"},
327
+ "side": {"type": "string"}, "type": {"type": "string"},
328
+ "size": {"type": "string"},
329
+ "limit_price": {"type": "string"}},
330
+ ["instrument_id", "side", "size"]),
331
+ "order_get": (
332
+ t_order_get,
333
+ "T0/T1. One Order record with lifecycle + fills + fee "
334
+ "reconciliation fields.",
335
+ {"order_id": {"type": "string"}}, ["order_id"]),
336
+ "venue_status": (
337
+ t_venue_status,
338
+ "T0. Adapter health for a venue: capability record "
339
+ "(M0 = DATA ONLY), instrument count, fee-schedule "
340
+ "freshness, ledger chain state.",
341
+ {"venue": {"type": "string"}, "as_of": {"type": "string"}},
342
+ ["venue"]),
343
+ "receipt_list": (
344
+ t_receipt_list,
345
+ "T0. The suite ledger: append-only, hash-chained "
346
+ "receipts; verifies the chain.",
347
+ {"limit": {"type": "integer"}}, []),
348
+ }
349
+
350
+ # Capability denylist, stated in code (spec 7/8): M0 is data
351
+ # only — no placement, no accounts, no withdrawals, no advice.
352
+ FORBIDDEN_TOOL_NAMES = {
353
+ "order_place", "order_submit", "place_order", "submit_order",
354
+ "trade_execute", "buy", "sell", "withdraw", "withdraw_funds",
355
+ "transfer_funds", "account_get", "account_summary",
356
+ "create_api_key", "margin_open", "leverage_set",
357
+ "recommend", "signal_get", "probability_get",
358
+ }
359
+
360
+
361
+ def tool_list():
362
+ return [{"name": name, "description": description,
363
+ "inputSchema": {"type": "object", "properties": props,
364
+ "required": required}}
365
+ for name, (_, description, props, required) in TOOLS.items()]
366
+
367
+
368
+ def handle(req: dict):
369
+ method, rid = req.get("method", ""), req.get("id")
370
+ if method == "initialize":
371
+ return {"jsonrpc": "2.0", "id": rid, "result": {
372
+ "protocolVersion": req.get("params", {}).get(
373
+ "protocolVersion", "2025-03-26"),
374
+ "capabilities": {"tools": {}},
375
+ "serverInfo": {"name": "eclipse-book",
376
+ "version": "0.1.0"}}}
377
+ if method == "ping":
378
+ return {"jsonrpc": "2.0", "id": rid, "result": {}}
379
+ if method == "tools/list":
380
+ return {"jsonrpc": "2.0", "id": rid,
381
+ "result": {"tools": tool_list()}}
382
+ if method == "tools/call":
383
+ name = req["params"]["name"]
384
+ args = req["params"].get("arguments", {})
385
+ if name in FORBIDDEN_TOOL_NAMES or name not in TOOLS:
386
+ return {"jsonrpc": "2.0", "id": rid, "error": {
387
+ "code": -32602,
388
+ "message": f"unknown or line-forbidden tool {name}"}}
389
+ state = load_state()
390
+ receipts_before = len(state["receipts"])
391
+ try:
392
+ out = TOOLS[name][0](state, args)
393
+ save_state(state)
394
+ return {"jsonrpc": "2.0", "id": rid, "result": {
395
+ "content": [{"type": "text",
396
+ "text": json.dumps(out, indent=1)}]}}
397
+ except ToolError as exc:
398
+ if len(state["receipts"]) > receipts_before:
399
+ save_state(state)
400
+ return {"jsonrpc": "2.0", "id": rid, "result": {
401
+ "content": [{"type": "text",
402
+ "text": json.dumps({"error": str(exc)})}],
403
+ "isError": True}}
404
+ if rid is not None:
405
+ return {"jsonrpc": "2.0", "id": rid, "error": {
406
+ "code": -32601, "message": f"method not found: {method}"}}
407
+ return None
408
+
409
+
410
+ def main():
411
+ for line in sys.stdin:
412
+ line = line.strip()
413
+ if not line:
414
+ continue
415
+ try:
416
+ response = handle(json.loads(line))
417
+ except Exception as exc: # never crash the transport
418
+ response = {"jsonrpc": "2.0", "id": None, "error": {
419
+ "code": -32603, "message": str(exc)}}
420
+ if response is not None:
421
+ sys.stdout.write(json.dumps(response) + "\n")
422
+ sys.stdout.flush()
423
+
424
+
425
+ if __name__ == "__main__":
426
+ main()
427
+
428
+ # Eclipse Book MCP · © 2026 Cyan Eclipse Technologies LLC · cetech.ai