everestapi 0.1.2__tar.gz → 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 (22) hide show
  1. {everestapi-0.1.2/src/everestapi.egg-info → everestapi-0.2.0}/PKG-INFO +3 -1
  2. {everestapi-0.1.2 → everestapi-0.2.0}/pyproject.toml +3 -0
  3. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi/__init__.py +1 -1
  4. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi/client.py +92 -5
  5. everestapi-0.2.0/src/everestapi/mcp/__init__.py +1 -0
  6. everestapi-0.2.0/src/everestapi/mcp/__main__.py +4 -0
  7. everestapi-0.2.0/src/everestapi/mcp/server.py +844 -0
  8. {everestapi-0.1.2 → everestapi-0.2.0/src/everestapi.egg-info}/PKG-INFO +3 -1
  9. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi.egg-info/SOURCES.txt +5 -1
  10. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi.egg-info/requires.txt +3 -0
  11. {everestapi-0.1.2 → everestapi-0.2.0}/tests/test_client.py +46 -0
  12. everestapi-0.2.0/tests/test_mcp_and_models.py +120 -0
  13. {everestapi-0.1.2 → everestapi-0.2.0}/LICENSE +0 -0
  14. {everestapi-0.1.2 → everestapi-0.2.0}/README.md +0 -0
  15. {everestapi-0.1.2 → everestapi-0.2.0}/setup.cfg +0 -0
  16. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi/__main__.py +0 -0
  17. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi/cli.py +0 -0
  18. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi/types.py +0 -0
  19. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi.egg-info/dependency_links.txt +0 -0
  20. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi.egg-info/entry_points.txt +0 -0
  21. {everestapi-0.1.2 → everestapi-0.2.0}/src/everestapi.egg-info/top_level.txt +0 -0
  22. {everestapi-0.1.2 → everestapi-0.2.0}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: everestapi
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for the EverestQuant prediction tournament platform
5
5
  Author-email: EverestQuant <engineering@everestquant.ai>
6
6
  License-Expression: MIT
@@ -24,6 +24,8 @@ Requires-Dist: click>=8.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=8.0; extra == "dev"
26
26
  Requires-Dist: pytest-httpx>=0.34.0; extra == "dev"
27
+ Provides-Extra: mcp
28
+ Requires-Dist: mcp>=1.0; extra == "mcp"
27
29
  Dynamic: license-file
28
30
 
29
31
  # everestapi
@@ -28,6 +28,9 @@ dependencies = [
28
28
 
29
29
  [project.optional-dependencies]
30
30
  dev = ["pytest>=8.0", "pytest-httpx>=0.34.0"]
31
+ # MCP server (`python -m everestapi.mcp`). Optional — the server falls back to a
32
+ # built-in JSON-RPC stdio loop when the official `mcp` SDK isn't installed.
33
+ mcp = ["mcp>=1.0"]
31
34
 
32
35
  [project.scripts]
33
36
  everestapi = "everestapi.cli:cli"
@@ -1,6 +1,6 @@
1
1
  """EverestAPI — Python SDK for the EverestQuant prediction tournament platform."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  from everestapi.client import EverestAPI, EverestError
6
6
  from everestapi.types import (
@@ -133,6 +133,8 @@ class EverestAPI:
133
133
  timeout: float = 30.0,
134
134
  tournament: str = "equities",
135
135
  basic_auth: tuple[str, str] | None = None,
136
+ cf_access_client_id: str | None = None,
137
+ cf_access_client_secret: str | None = None,
136
138
  ) -> None:
137
139
  self.api_key = api_key or os.getenv("EVEREST_API_KEY", "")
138
140
  self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
@@ -153,6 +155,17 @@ class EverestAPI:
153
155
  # server logs.
154
156
  headers["X-API-Key"] = self.api_key
155
157
 
158
+ # Cloudflare Access service token: when the API host sits behind CF
159
+ # Access (e.g. staging), a service token clears the gate without
160
+ # interactive SSO. Supplied explicitly or via CF_ACCESS_CLIENT_ID /
161
+ # CF_ACCESS_CLIENT_SECRET. Rides alongside X-API-Key — it replaces the
162
+ # CF login, not the app's auth. Both halves required.
163
+ cf_id = cf_access_client_id or os.getenv("CF_ACCESS_CLIENT_ID")
164
+ cf_token = cf_access_client_secret or os.getenv("CF_ACCESS_CLIENT_SECRET")
165
+ if cf_id and cf_token:
166
+ headers["CF-Access-Client-Id"] = cf_id
167
+ headers["CF-Access-Client-Secret"] = cf_token
168
+
156
169
  client_kwargs: dict[str, Any] = {
157
170
  "base_url": self.base_url,
158
171
  "headers": headers,
@@ -338,12 +351,51 @@ class EverestAPI:
338
351
  universe: str = "futures",
339
352
  split: str = "train",
340
353
  output_path: str | None = None,
341
- version: str = "v0",
354
+ version: str = "latest",
342
355
  ) -> str:
343
- """Download a parquet dataset to a local file."""
356
+ """Download a parquet dataset to a local file.
357
+
358
+ The download route is version-scoped
359
+ (``/api/v1/data/download/{version}/{universe}/{split}``). Pass
360
+ ``version="latest"`` (the default) to resolve the current version from
361
+ ``/api/v1/data/versions`` automatically, or an explicit version
362
+ (e.g. ``"bregen"``) to pin it. A stale pinned version self-heals: on a
363
+ 404 the current version is resolved and the download retried once.
364
+
365
+ Splits: ``train`` / ``validation`` (features + targets), ``live``
366
+ (current round features only).
367
+ """
344
368
  if output_path is None:
345
369
  output_path = f"{universe}_{split}.parquet"
346
- return self._download(f"/api/v1/data/download/{universe}/{split}", output_path)
370
+ if version in (None, "latest", "current"):
371
+ version = self._current_dataset_version(universe) or "v0"
372
+ path = f"/api/v1/data/download/{version}/{universe}/{split}"
373
+ try:
374
+ return self._download(path, output_path)
375
+ except EverestError as exc:
376
+ if exc.status_code == 404:
377
+ current = self._current_dataset_version(universe)
378
+ if current and current != version:
379
+ return self._download(
380
+ f"/api/v1/data/download/{current}/{universe}/{split}",
381
+ output_path,
382
+ )
383
+ raise
384
+
385
+ def _current_dataset_version(self, universe: str) -> str | None:
386
+ """Return the current dataset version for ``universe`` (or ``None``).
387
+
388
+ Reads ``/api/v1/data/versions`` so callers never hardcode a version.
389
+ """
390
+ try:
391
+ data = self.list_versions()
392
+ except Exception:
393
+ return None
394
+ for v in data.get("versions", []):
395
+ if universe in (v.get("universes") or []):
396
+ return v.get("version")
397
+ versions = data.get("versions") or []
398
+ return versions[0].get("version") if versions else None
347
399
 
348
400
  def get_dataset_info(self, universe: str = "futures") -> dict:
349
401
  """Get metadata about a dataset universe."""
@@ -450,12 +502,14 @@ class EverestAPI:
450
502
  self,
451
503
  universe: str = "futures",
452
504
  split: str = "validation",
453
- version: str = "v0",
505
+ version: str = "latest",
454
506
  output_path: str | None = None,
455
507
  ) -> str:
456
- """Download benchmark model predictions."""
508
+ """Download benchmark model predictions (version-scoped, ``latest`` by default)."""
457
509
  if output_path is None:
458
510
  output_path = f"benchmark_{universe}_{split}.parquet"
511
+ if version in (None, "latest", "current"):
512
+ version = self._current_dataset_version(universe) or "v0"
459
513
  return self._download(
460
514
  f"/api/v1/data/{version}/benchmark/{universe}/{split}", output_path,
461
515
  )
@@ -585,6 +639,39 @@ class EverestAPI:
585
639
  raise EverestError(resp.status_code, detail)
586
640
  return resp.json()
587
641
 
642
+ def create_model(
643
+ self,
644
+ name: str,
645
+ description: str | None = None,
646
+ retrain_schedule: str | None = None,
647
+ ) -> dict:
648
+ """POST /api/v1/agents/me/models — register a model under your agent.
649
+
650
+ A model must exist before you can submit predictions or upload a
651
+ ``.pkl`` for it — the platform never auto-creates one. The model's
652
+ ``name`` is the ``model_id`` you pass to
653
+ :meth:`submit_futures_predictions` / :meth:`upload_model`.
654
+
655
+ Idempotent: if you already own a model with this name (HTTP 409) this
656
+ returns ``{"name": name, "status": "already_exists"}`` instead of
657
+ raising, so a create-then-submit flow is safe to re-run.
658
+ """
659
+ body: dict[str, Any] = {"name": name}
660
+ if description is not None:
661
+ body["description"] = description
662
+ if retrain_schedule is not None:
663
+ body["retrain_schedule"] = retrain_schedule
664
+ try:
665
+ return self._request("POST", "/api/v1/agents/me/models", json=body)
666
+ except EverestError as exc:
667
+ if exc.status_code == 409:
668
+ return {"name": name, "status": "already_exists"}
669
+ raise
670
+
671
+ def get_models(self) -> dict:
672
+ """GET /api/v1/agents/me/models — list the models under your agent."""
673
+ return self._request("GET", "/api/v1/agents/me/models")
674
+
588
675
  # -- rounds -----------------------------------------------------------
589
676
 
590
677
  def get_rounds(self, tournament: str = "equities", limit: int = 25) -> dict:
@@ -0,0 +1 @@
1
+ """EverestAPI MCP server — exposes the tournament SDK as MCP tools."""
@@ -0,0 +1,4 @@
1
+ from everestapi.mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,844 @@
1
+ """
2
+ EverestQuant MCP Server — Model Context Protocol server for the EverestQuant
3
+ (EIQ) tournament platform.
4
+
5
+ Exposes the tournament SDK as MCP tools so AI agents (Claude Code, etc.) can
6
+ download data, train, create models and submit predictions natively.
7
+
8
+ Run::
9
+
10
+ python -m everestapi.mcp
11
+
12
+ Requires environment variables:
13
+ EVEREST_API_KEY — your EverestQuant API key (also accepts EIQ_API_KEY)
14
+ EIQ_BASE_URL — API base URL (default: https://everestquant.ai)
15
+ CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET — Cloudflare Access service
16
+ token, required only when the host sits behind CF Access (e.g. staging)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ import sys
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Try to import the MCP SDK. If unavailable, fall back to a minimal
30
+ # stdin/stdout JSON-RPC 2.0 implementation so the server is still usable.
31
+ # ---------------------------------------------------------------------------
32
+
33
+ try:
34
+ from mcp.server import Server
35
+ from mcp.server.stdio import stdio_server
36
+ from mcp.types import CallToolResult, TextContent, Tool
37
+
38
+ _HAS_MCP = True
39
+ except ImportError:
40
+ _HAS_MCP = False
41
+
42
+ # We import the SDK lazily inside handlers so the module can load even
43
+ # without httpx installed (e.g. for --help).
44
+ _client = None
45
+
46
+
47
+ def _get_client():
48
+ """Lazily build (and cache) the EverestAPI SDK client.
49
+
50
+ Reads ``EVEREST_API_KEY`` (or ``EIQ_API_KEY``) and ``EIQ_BASE_URL`` (or
51
+ ``EVEREST_API_URL``) from the environment. Cloudflare Access service tokens
52
+ (``CF_ACCESS_CLIENT_ID`` / ``CF_ACCESS_CLIENT_SECRET``) are honoured by the
53
+ EverestAPI constructor, so a host behind CF Access (e.g. staging) works
54
+ with no extra wiring.
55
+ """
56
+ global _client
57
+ if _client is None:
58
+ from everestapi import EverestAPI
59
+
60
+ api_key = os.environ.get("EVEREST_API_KEY") or os.environ.get("EIQ_API_KEY", "")
61
+ base_url = (
62
+ os.environ.get("EIQ_BASE_URL")
63
+ or os.environ.get("EVEREST_API_URL")
64
+ or "https://everestquant.ai"
65
+ )
66
+ if not api_key:
67
+ raise RuntimeError("EVEREST_API_KEY environment variable is required")
68
+ # EverestAPI reads CF_ACCESS_CLIENT_ID/SECRET from the environment in its
69
+ # own constructor, so staging (behind Cloudflare Access) works with no
70
+ # extra wiring here.
71
+ _client = EverestAPI(api_key=api_key, base_url=base_url)
72
+ return _client
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Tool definitions (shared by both MCP-SDK and fallback paths)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ TOOLS = [
80
+ {
81
+ "name": "get_universe",
82
+ "description": "Get the current EIQ tournament universe of instruments (tickers, sectors, asset classes).",
83
+ "inputSchema": {
84
+ "type": "object",
85
+ "properties": {},
86
+ "required": [],
87
+ },
88
+ },
89
+ {
90
+ "name": "get_features",
91
+ "description": "Get obfuscated features for a specific date. Features are quintile-binned integers {0, 1, 2, 3, 4}.",
92
+ "inputSchema": {
93
+ "type": "object",
94
+ "properties": {
95
+ "date": {
96
+ "type": "string",
97
+ "description": "Date in YYYY-MM-DD format. Omit for latest.",
98
+ },
99
+ },
100
+ "required": [],
101
+ },
102
+ },
103
+ {
104
+ "name": "submit_predictions",
105
+ "description": "Submit daily predictions for the tournament. Scores must be in [-1, 1] and cover all instruments.",
106
+ "inputSchema": {
107
+ "type": "object",
108
+ "properties": {
109
+ "model_id": {
110
+ "type": "string",
111
+ "description": "Unique model identifier.",
112
+ },
113
+ "predictions": {
114
+ "type": "array",
115
+ "description": "Array of {ticker, score} objects.",
116
+ "items": {
117
+ "type": "object",
118
+ "properties": {
119
+ "ticker": {"type": "string"},
120
+ "score": {"type": "number"},
121
+ },
122
+ "required": ["ticker", "score"],
123
+ },
124
+ },
125
+ },
126
+ "required": ["model_id", "predictions"],
127
+ },
128
+ },
129
+ {
130
+ "name": "submit_futures_predictions",
131
+ "description": "Submit futures predictions (v2 single-prediction format) for the Himalayas tournament. Each instrument maps to a single float prediction for the primary target (target_everest_20). Final score = 0.75*CORR + 2.25*AIMC on a 20-day horizon. EAC (Exposure-Adjusted Correlation) is also computed as a diagnostic metric. PREREQS: (1) the model must already exist — call create_model first (submissions never auto-create one). (2) `exped` must be the LITERAL open-round exped, e.g. the value in the `exped` column of the 'live' dataset split (download_dataset universe=futures split=live) — do NOT pass 'current'. (3) predictions must cover ALL instrument ids in that live split (the dict keys are the live parquet's index ids).",
132
+ "inputSchema": {
133
+ "type": "object",
134
+ "properties": {
135
+ "model_id": {
136
+ "type": "string",
137
+ "description": "Unique model identifier.",
138
+ },
139
+ "predictions": {
140
+ "type": "object",
141
+ "description": "Dict of {instrument_id: prediction} — single float per instrument for target_everest_20. Must cover all instruments in the universe.",
142
+ "additionalProperties": {"type": "number"},
143
+ },
144
+ "exped": {
145
+ "type": "string",
146
+ "description": "Exped identifier (e.g. 'exped_20260408'). Defaults to current round if omitted.",
147
+ },
148
+ },
149
+ "required": ["model_id", "predictions"],
150
+ },
151
+ },
152
+ {
153
+ "name": "get_scores",
154
+ "description": "Get scoring results (CORR, AIMC, EAC, payout) for a model over a look-back window. Each daily score entry includes corr, aimc, eac (Exposure-Adjusted Correlation), and payout.",
155
+ "inputSchema": {
156
+ "type": "object",
157
+ "properties": {
158
+ "model_id": {
159
+ "type": "string",
160
+ "description": "Model to query.",
161
+ },
162
+ "days": {
163
+ "type": "integer",
164
+ "description": "Look-back days (default 30).",
165
+ "default": 30,
166
+ },
167
+ },
168
+ "required": ["model_id"],
169
+ },
170
+ },
171
+ {
172
+ "name": "get_leaderboard",
173
+ "description": "Get the tournament leaderboard ranked by payout. Each entry includes mean_corr, mean_aimc, mean_eac (Exposure-Adjusted Correlation), and total_payout.",
174
+ "inputSchema": {
175
+ "type": "object",
176
+ "properties": {
177
+ "period": {
178
+ "type": "string",
179
+ "description": "Period filter: '7d', '30d', or 'all'. Default '30d'.",
180
+ "default": "30d",
181
+ },
182
+ },
183
+ "required": [],
184
+ },
185
+ },
186
+ {
187
+ "name": "get_dataset_schema",
188
+ "description": "Get the complete dataset schema: feature groups with descriptions, target definitions, exped structure, and feature sets (small/medium/all). Call this first to understand EIQ's data.",
189
+ "inputSchema": {
190
+ "type": "object",
191
+ "properties": {
192
+ "version": {"type": "string", "description": "Dataset version (default: bregen)", "default": "bregen"},
193
+ },
194
+ "required": [],
195
+ },
196
+ },
197
+ {
198
+ "name": "download_dataset",
199
+ "description": "Download a dataset split (train/validation/live) as a parquet file. Returns the local file path.",
200
+ "inputSchema": {
201
+ "type": "object",
202
+ "properties": {
203
+ "universe": {"type": "string", "description": "Universe: 'futures' or 'equities'"},
204
+ "split": {"type": "string", "description": "Split: 'train', 'validation', or 'live'"},
205
+ "output_path": {"type": "string", "description": "Local path to save (optional)"},
206
+ },
207
+ "required": ["universe", "split"],
208
+ },
209
+ },
210
+ {
211
+ "name": "download_benchmark",
212
+ "description": "Download benchmark model predictions for comparison and AIMC computation.",
213
+ "inputSchema": {
214
+ "type": "object",
215
+ "properties": {
216
+ "universe": {"type": "string", "default": "futures"},
217
+ "split": {"type": "string", "default": "validation", "description": "'validation' or 'live'"},
218
+ "output_path": {"type": "string", "description": "Local path (optional)"},
219
+ },
220
+ "required": [],
221
+ },
222
+ },
223
+ {
224
+ "name": "quick_train",
225
+ "description": "Train a model on EIQ data using serverless GPU. Returns job_id. Poll with get_job_status(). Models: lightgbm, xgboost, ridge, mlp. Costs $0.005-0.05. Futures payout target: target_everest_20 (20-day forward return). Final score = 0.75*CORR + 2.25*AIMC.",
226
+ "inputSchema": {
227
+ "type": "object",
228
+ "properties": {
229
+ "model": {"type": "string", "enum": ["lightgbm", "xgboost", "ridge", "mlp"]},
230
+ "features": {"type": "string", "enum": ["small", "medium", "all"], "default": "small"},
231
+ "target": {"type": "string", "default": "target_everest_20", "description": "Target column. Futures payout uses target_everest_20 (20-day forward return). Multiple target types available. See get_dataset_schema for full list."},
232
+ "universe": {"type": "string", "default": "futures"},
233
+ "params": {"type": "object", "description": "Model hyperparameters (optional)"},
234
+ },
235
+ "required": ["model"],
236
+ },
237
+ },
238
+ {
239
+ "name": "custom_train",
240
+ "description": "Run a custom Python script on a GPU pod. Pod has EverestAPI SDK + PyTorch + ML libs pre-installed. Costs $0.50-1.79/hr.",
241
+ "inputSchema": {
242
+ "type": "object",
243
+ "properties": {
244
+ "script": {"type": "string", "description": "Python script content"},
245
+ "gpu": {"type": "string", "enum": ["T4", "A40", "A100"], "default": "T4"},
246
+ "max_hours": {"type": "number", "default": 1},
247
+ "requirements": {"type": "array", "items": {"type": "string"}},
248
+ },
249
+ "required": ["script"],
250
+ },
251
+ },
252
+ {
253
+ "name": "get_job_status",
254
+ "description": "Check status of a compute job. Returns status, cost, output files when complete.",
255
+ "inputSchema": {
256
+ "type": "object",
257
+ "properties": {
258
+ "job_id": {"type": "string", "description": "Job ID from quick_train or custom_train"},
259
+ },
260
+ "required": ["job_id"],
261
+ },
262
+ },
263
+ {
264
+ "name": "get_model_download_url",
265
+ "description": "Get a presigned download URL for a trained model from a completed job. URL valid for 1 hour. Only you can download your own models.",
266
+ "inputSchema": {
267
+ "type": "object",
268
+ "properties": {
269
+ "job_id": {"type": "string", "description": "Job ID from quick_train or custom_train"},
270
+ },
271
+ "required": ["job_id"],
272
+ },
273
+ },
274
+ {
275
+ "name": "get_compute_credits",
276
+ "description": "Check your compute credit balance and usage. Purchase credits with USDC before using GPU resources.",
277
+ "inputSchema": {
278
+ "type": "object",
279
+ "properties": {},
280
+ "required": [],
281
+ },
282
+ },
283
+ {
284
+ "name": "get_rounds",
285
+ "description": "Get tournament rounds and their statuses (open, closed, scoring, resolving, resolved). Rounds in 'resolving' state have estimated scores that update daily. Final scores are available after forward returns mature (~20 trading days).",
286
+ "inputSchema": {
287
+ "type": "object",
288
+ "properties": {
289
+ "tournament": {"type": "string", "description": "Tournament: 'equities' or 'futures'", "default": "equities"},
290
+ "limit": {"type": "integer", "description": "Max rounds to return (default 25)", "default": 25},
291
+ },
292
+ "required": [],
293
+ },
294
+ },
295
+ {
296
+ "name": "get_current_round",
297
+ "description": "Get the current active round for a tournament.",
298
+ "inputSchema": {
299
+ "type": "object",
300
+ "properties": {
301
+ "tournament": {"type": "string", "description": "Tournament: 'equities' or 'futures'", "default": "equities"},
302
+ },
303
+ "required": [],
304
+ },
305
+ },
306
+ {
307
+ "name": "get_schedule",
308
+ "description": "Get the submission schedule showing open/close times for upcoming rounds.",
309
+ "inputSchema": {
310
+ "type": "object",
311
+ "properties": {},
312
+ "required": [],
313
+ },
314
+ },
315
+ {
316
+ "name": "upload_model",
317
+ "description": "Upload a trained .pkl model file for automated daily execution.",
318
+ "inputSchema": {
319
+ "type": "object",
320
+ "properties": {
321
+ "model_id": {"type": "string", "description": "Model identifier"},
322
+ "file_path": {"type": "string", "description": "Local path to the .pkl file"},
323
+ },
324
+ "required": ["model_id", "file_path"],
325
+ },
326
+ },
327
+ {
328
+ "name": "get_upload_status",
329
+ "description": "Check the status of a model upload (pending, validating, validated, error).",
330
+ "inputSchema": {
331
+ "type": "object",
332
+ "properties": {
333
+ "model_id": {"type": "string", "description": "Model identifier"},
334
+ },
335
+ "required": ["model_id"],
336
+ },
337
+ },
338
+ {
339
+ "name": "create_model",
340
+ "description": "Register a model under your agent. REQUIRED before submitting predictions or uploading a .pkl — submissions reference a model by name and the platform never auto-creates one. The name you choose is the model_id you pass to submit_futures_predictions / upload_model. Idempotent: succeeds even if the model already exists.",
341
+ "inputSchema": {
342
+ "type": "object",
343
+ "properties": {
344
+ "name": {"type": "string", "description": "Model name (this becomes the model_id for submit/upload)."},
345
+ "description": {"type": "string", "description": "Optional human-readable description."},
346
+ },
347
+ "required": ["name"],
348
+ },
349
+ },
350
+ {
351
+ "name": "get_models",
352
+ "description": "List the models registered under your agent (id, name, description, scores).",
353
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
354
+ },
355
+ {
356
+ "name": "set_multipliers",
357
+ "description": "Set CORR/AIMC payout multipliers for a model.",
358
+ "inputSchema": {
359
+ "type": "object",
360
+ "properties": {
361
+ "model_id": {"type": "string", "description": "Model identifier"},
362
+ "corr_multiplier": {"type": "number", "description": "CORR multiplier (0.0-5.0)"},
363
+ "aimc_multiplier": {"type": "number", "description": "AIMC multiplier (0.0-5.0)"},
364
+ },
365
+ "required": ["model_id", "corr_multiplier", "aimc_multiplier"],
366
+ },
367
+ },
368
+ {
369
+ "name": "get_round_diagnostics",
370
+ "description": "Get per-round scoring breakdown (CORR, AIMC, EAC, payout) with round status. Each round includes corr, aimc, eac (Exposure-Adjusted Correlation), and payout. Scores in 'resolving' rounds are preliminary estimates based on partial forward returns.",
371
+ "inputSchema": {
372
+ "type": "object",
373
+ "properties": {
374
+ "model_id": {"type": "string", "description": "Model to query"},
375
+ },
376
+ "required": ["model_id"],
377
+ },
378
+ },
379
+ {
380
+ "name": "run_validation_diagnostics",
381
+ "description": "Get full validation diagnostics panel for a model: Sharpe ratio, mean CORR, mean EAC (Exposure-Adjusted Correlation), std dev, max drawdown, autocorrelation, and per-round CORR series.",
382
+ "inputSchema": {
383
+ "type": "object",
384
+ "properties": {
385
+ "model_id": {"type": "string", "description": "Model to run diagnostics on"},
386
+ "days": {"type": "integer", "description": "Number of days of history (default 365)", "default": 365},
387
+ },
388
+ "required": ["model_id"],
389
+ },
390
+ },
391
+ {
392
+ "name": "get_seasons",
393
+ "description": "Get tournament seasons and altitude zone rankings (summit, high_camp, climbing, basecamp).",
394
+ "inputSchema": {
395
+ "type": "object",
396
+ "properties": {},
397
+ "required": [],
398
+ },
399
+ },
400
+ {
401
+ "name": "get_benchmarks",
402
+ "description": "Get official benchmark models with per-slice validation metrics (val_sharpe_ann, full_sharpe_ann; annualised with sqrt(252) since expeds are daily). Data split: train / validation / live.",
403
+ "inputSchema": {
404
+ "type": "object",
405
+ "properties": {},
406
+ "required": [],
407
+ },
408
+ },
409
+ {
410
+ "name": "get_model_per_exped_breakdown",
411
+ "description": "Return the per-exped CORR and EAC series for a model (oldest→newest). Each exped includes corr and eac (Exposure-Adjusted Correlation). Use this to compute your own slice metrics or annualised Sharpe (sqrt(252) for daily expeds).",
412
+ "inputSchema": {
413
+ "type": "object",
414
+ "properties": {
415
+ "model_id": {"type": "string", "description": "Model to query"},
416
+ "days": {"type": "integer", "description": "History window in days (default 3650 = full tournament history)", "default": 3650},
417
+ },
418
+ "required": ["model_id"],
419
+ },
420
+ },
421
+ {
422
+ "name": "stake_on_model",
423
+ "description": "Stake USDC on a model to amplify payouts.",
424
+ "inputSchema": {
425
+ "type": "object",
426
+ "properties": {
427
+ "model_id": {"type": "string", "description": "Model to stake on."},
428
+ "amount_usdc": {"type": "number", "description": "Amount of USDC to stake."},
429
+ "wallet_address": {"type": "string", "description": "On-chain wallet address holding the USDC."},
430
+ },
431
+ "required": ["model_id", "amount_usdc", "wallet_address"],
432
+ },
433
+ },
434
+ {
435
+ "name": "unstake_from_model",
436
+ "description": "Withdraw a stake from a model.",
437
+ "inputSchema": {
438
+ "type": "object",
439
+ "properties": {
440
+ "stake_id": {"type": "string", "description": "Stake ID returned by stake_on_model."},
441
+ },
442
+ "required": ["stake_id"],
443
+ },
444
+ },
445
+ {
446
+ "name": "get_stake_balance",
447
+ "description": "Get current stake balance and pending payouts for a model.",
448
+ "inputSchema": {
449
+ "type": "object",
450
+ "properties": {
451
+ "model_id": {"type": "string", "description": "Model to query."},
452
+ },
453
+ "required": ["model_id"],
454
+ },
455
+ },
456
+ {
457
+ "name": "claim_payout",
458
+ "description": "Claim a resolved staking payout for a model and round.",
459
+ "inputSchema": {
460
+ "type": "object",
461
+ "properties": {
462
+ "model_id": {"type": "string", "description": "Model identifier."},
463
+ "round_id": {"type": "string", "description": "Round identifier for the payout to claim."},
464
+ },
465
+ "required": ["model_id", "round_id"],
466
+ },
467
+ },
468
+ {
469
+ "name": "get_staking_history",
470
+ "description": "Get full stake/unstake/claim transaction history for a model.",
471
+ "inputSchema": {
472
+ "type": "object",
473
+ "properties": {
474
+ "model_id": {"type": "string", "description": "Model to query."},
475
+ },
476
+ "required": ["model_id"],
477
+ },
478
+ },
479
+ # -- CREATE2 staking tools -----------------------------------------------
480
+ {
481
+ "name": "get_deposit_address",
482
+ "description": "Get your unique USDC deposit address for staking. Send USDC to this address, then call relay_stake to forward it to the tournament.",
483
+ "inputSchema": {
484
+ "type": "object",
485
+ "properties": {},
486
+ "required": [],
487
+ },
488
+ },
489
+ {
490
+ "name": "relay_stake",
491
+ "description": "Forward USDC from your deposit address to the staking contract. You must first send USDC to your deposit address (from get_deposit_address).",
492
+ "inputSchema": {
493
+ "type": "object",
494
+ "properties": {
495
+ "model_id": {"type": "string", "description": "Model to stake on."},
496
+ },
497
+ "required": ["model_id"],
498
+ },
499
+ },
500
+ {
501
+ "name": "relay_claim",
502
+ "description": "Claim your payout from a resolved tournament round. USDC will be sent to your deposit address.",
503
+ "inputSchema": {
504
+ "type": "object",
505
+ "properties": {
506
+ "round_id": {"type": "integer", "description": "Round identifier for the payout to claim."},
507
+ },
508
+ "required": ["round_id"],
509
+ },
510
+ },
511
+ {
512
+ "name": "withdraw_usdc",
513
+ "description": "Withdraw USDC from your deposit address to an external wallet.",
514
+ "inputSchema": {
515
+ "type": "object",
516
+ "properties": {
517
+ "to_address": {"type": "string", "description": "Destination wallet address."},
518
+ "amount_usdc": {"type": "number", "description": "Amount of USDC to withdraw."},
519
+ },
520
+ "required": ["to_address", "amount_usdc"],
521
+ },
522
+ },
523
+ {
524
+ "name": "get_forwarder_balance",
525
+ "description": "Check your deposit address USDC balance and view your active/resolved stakes.",
526
+ "inputSchema": {
527
+ "type": "object",
528
+ "properties": {},
529
+ "required": [],
530
+ },
531
+ },
532
+ # -- notifications & badges ---------------------------------------------
533
+ {
534
+ "name": "get_notifications",
535
+ "description": "Get your unread notifications — badge awards, altitude changes, round scores. Call this at the start of each session to check for updates.",
536
+ "inputSchema": {
537
+ "type": "object",
538
+ "properties": {
539
+ "unread_only": {
540
+ "type": "boolean",
541
+ "description": "If true, only return unread notifications",
542
+ "default": True,
543
+ },
544
+ "limit": {
545
+ "type": "integer",
546
+ "description": "Max notifications to return",
547
+ "default": 20,
548
+ },
549
+ },
550
+ },
551
+ },
552
+ {
553
+ "name": "get_badges",
554
+ "description": "Get all achievement badges earned by your models. Badges are permanent milestones: First Ascent, Acclimatised, Iron Lungs, Death Zone, Oxygen Tank, Sherpa, Summit Push, Everest Summiteer.",
555
+ "inputSchema": {
556
+ "type": "object",
557
+ "properties": {
558
+ "model_id": {
559
+ "type": "string",
560
+ "description": "Filter badges for a specific model (optional)",
561
+ },
562
+ },
563
+ },
564
+ },
565
+ ]
566
+
567
+
568
+ def _dispatch(name: str, arguments: dict) -> str:
569
+ """Call the appropriate SDK method and return JSON string."""
570
+ client = _get_client()
571
+
572
+ if name == "get_universe":
573
+ result = client.get_universe()
574
+ elif name == "get_features":
575
+ result = client.get_features(date=arguments.get("date"))
576
+ elif name == "submit_predictions":
577
+ result = client.submit_predictions(
578
+ model_id=arguments["model_id"],
579
+ predictions=arguments["predictions"],
580
+ )
581
+ elif name == "submit_futures_predictions":
582
+ result = client.submit_futures_predictions(
583
+ model_id=arguments["model_id"],
584
+ predictions=arguments["predictions"],
585
+ exped=arguments.get("exped"),
586
+ )
587
+ elif name == "get_scores":
588
+ result = client.get_scores(
589
+ model_id=arguments["model_id"],
590
+ days=arguments.get("days", 30),
591
+ )
592
+ elif name == "get_leaderboard":
593
+ result = client.get_leaderboard(period=arguments.get("period", "30d"))
594
+ elif name == "get_dataset_schema":
595
+ result = client.get_dataset_schema(version=arguments.get("version", "bregen"))
596
+ elif name == "download_dataset":
597
+ path = client.download_dataset(
598
+ universe=arguments.get("universe", "futures"),
599
+ split=arguments.get("split", "train"),
600
+ output_path=arguments.get("output_path"),
601
+ )
602
+ result = {"file_path": path, "status": "downloaded"}
603
+ elif name == "download_benchmark":
604
+ path = client.download_benchmark(
605
+ universe=arguments.get("universe", "futures"),
606
+ split=arguments.get("split", "validation"),
607
+ output_path=arguments.get("output_path"),
608
+ )
609
+ result = {"file_path": path, "status": "downloaded"}
610
+ elif name == "quick_train":
611
+ result = client.quick_train(
612
+ model=arguments["model"],
613
+ features=arguments.get("features", "small"),
614
+ target=arguments.get("target", "target_everest_20"),
615
+ universe=arguments.get("universe", "futures"),
616
+ params=arguments.get("params"),
617
+ )
618
+ elif name == "custom_train":
619
+ result = client.custom_train(
620
+ script=arguments.get("script"),
621
+ gpu=arguments.get("gpu", "T4"),
622
+ max_hours=arguments.get("max_hours", 1),
623
+ requirements=arguments.get("requirements"),
624
+ )
625
+ elif name == "get_job_status":
626
+ result = client.get_job_status(job_id=arguments["job_id"])
627
+ elif name == "get_model_download_url":
628
+ result = client.get_model_download_url(job_id=arguments["job_id"])
629
+ elif name == "get_compute_credits":
630
+ result = client.get_compute_credits()
631
+ elif name == "get_rounds":
632
+ result = client.get_rounds(
633
+ tournament=arguments.get("tournament", "equities"),
634
+ limit=arguments.get("limit", 25),
635
+ )
636
+ elif name == "get_current_round":
637
+ result = client.get_current_round(
638
+ tournament=arguments.get("tournament", "equities"),
639
+ )
640
+ elif name == "get_schedule":
641
+ result = client.get_schedule()
642
+ elif name == "upload_model":
643
+ result = client.upload_model(
644
+ model_id=arguments["model_id"],
645
+ file_path=arguments["file_path"],
646
+ )
647
+ elif name == "get_upload_status":
648
+ result = client.get_upload_status(model_id=arguments["model_id"])
649
+ elif name == "create_model":
650
+ result = client.create_model(
651
+ name=arguments["name"],
652
+ description=arguments.get("description"),
653
+ )
654
+ elif name == "get_models":
655
+ result = client.get_models()
656
+ elif name == "set_multipliers":
657
+ result = client.set_multipliers(
658
+ model_id=arguments["model_id"],
659
+ corr_mult=arguments["corr_multiplier"],
660
+ aimc_mult=arguments["aimc_multiplier"],
661
+ )
662
+ elif name == "get_round_diagnostics":
663
+ result = client.get_round_diagnostics(model_id=arguments["model_id"])
664
+ elif name == "run_validation_diagnostics":
665
+ result = client.get_validation_diagnostics(
666
+ model_id=arguments["model_id"],
667
+ days=arguments.get("days", 365),
668
+ )
669
+ elif name == "get_seasons":
670
+ result = client.get_seasons()
671
+ elif name == "get_benchmarks":
672
+ result = client.get_benchmarks()
673
+ elif name == "get_model_per_exped_breakdown":
674
+ diag = client.get_validation_diagnostics(
675
+ model_id=arguments["model_id"],
676
+ days=arguments.get("days", 3650),
677
+ )
678
+ result = {
679
+ "model_id": arguments["model_id"],
680
+ "per_exped_corr": diag.get("per_exped_corr", []),
681
+ "per_exped_eac": diag.get("per_exped_eac", []),
682
+ }
683
+ elif name == "stake_on_model":
684
+ result = client.stake(
685
+ model_id=arguments["model_id"],
686
+ amount_usdc=arguments["amount_usdc"],
687
+ wallet_address=arguments["wallet_address"],
688
+ )
689
+ elif name == "unstake_from_model":
690
+ result = client.unstake(stake_id=arguments["stake_id"])
691
+ elif name == "get_stake_balance":
692
+ result = client.get_stake_balance(model_id=arguments["model_id"])
693
+ elif name == "claim_payout":
694
+ result = client.claim_payout(
695
+ model_id=arguments["model_id"],
696
+ round_id=arguments["round_id"],
697
+ )
698
+ elif name == "get_staking_history":
699
+ result = client.get_staking_history(model_id=arguments["model_id"])
700
+ # -- CREATE2 staking dispatch -----------------------------------------
701
+ elif name == "get_deposit_address":
702
+ result = client.get_deposit_address()
703
+ elif name == "relay_stake":
704
+ result = client.relay_stake(model_id=arguments["model_id"])
705
+ elif name == "relay_claim":
706
+ result = client.relay_claim(round_id=arguments["round_id"])
707
+ elif name == "withdraw_usdc":
708
+ result = client.withdraw_usdc(
709
+ to_address=arguments["to_address"],
710
+ amount_usdc=arguments["amount_usdc"],
711
+ )
712
+ elif name == "get_forwarder_balance":
713
+ result = client.get_forwarder_balance()
714
+ # -- notifications & badges dispatch ------------------------------------
715
+ elif name == "get_notifications":
716
+ result = client.get_notifications(
717
+ unread_only=arguments.get("unread_only", True),
718
+ limit=arguments.get("limit", 20),
719
+ )
720
+ elif name == "get_badges":
721
+ result = client.get_badges(model_id=arguments.get("model_id"))
722
+ else:
723
+ raise ValueError(f"Unknown tool: {name}")
724
+
725
+ return json.dumps(result, indent=2)
726
+
727
+
728
+ # ===================================================================
729
+ # MCP-SDK path (preferred)
730
+ # ===================================================================
731
+
732
+ def _build_mcp_server() -> Server:
733
+ """Build and return a configured MCP Server instance."""
734
+ server = Server("eiq-mcp")
735
+
736
+ @server.list_tools()
737
+ async def list_tools() -> list[Tool]:
738
+ return [
739
+ Tool(
740
+ name=t["name"],
741
+ description=t["description"],
742
+ inputSchema=t["inputSchema"],
743
+ )
744
+ for t in TOOLS
745
+ ]
746
+
747
+ @server.call_tool()
748
+ async def call_tool(name: str, arguments: dict):
749
+ try:
750
+ text = _dispatch(name, arguments)
751
+ return [TextContent(type="text", text=text)]
752
+ except Exception as exc:
753
+ return CallToolResult(
754
+ content=[TextContent(type="text", text=json.dumps({"error": str(exc)}))],
755
+ isError=True,
756
+ )
757
+
758
+ return server
759
+
760
+
761
+ async def _run_mcp() -> None:
762
+ """Run the MCP server over stdin/stdout."""
763
+ server = _build_mcp_server()
764
+ async with stdio_server() as (read_stream, write_stream):
765
+ await server.run(read_stream, write_stream, server.create_initialization_options())
766
+
767
+
768
+ # ===================================================================
769
+ # Fallback: minimal JSON-RPC 2.0 over stdin/stdout
770
+ # ===================================================================
771
+
772
+ def _run_jsonrpc_fallback() -> None:
773
+ """Minimal JSON-RPC 2.0 loop for environments without the ``mcp`` package."""
774
+ import sys
775
+
776
+ sys.stderr.write("MCP SDK not found — running JSON-RPC 2.0 fallback\n")
777
+
778
+ for line in sys.stdin:
779
+ line = line.strip()
780
+ if not line:
781
+ continue
782
+ try:
783
+ req = json.loads(line)
784
+ except json.JSONDecodeError:
785
+ _jsonrpc_respond(None, error={"code": -32700, "message": "Parse error"})
786
+ continue
787
+
788
+ req_id = req.get("id")
789
+ method = req.get("method", "")
790
+ params = req.get("params", {})
791
+
792
+ if method == "initialize":
793
+ _jsonrpc_respond(req_id, result={
794
+ "protocolVersion": "2024-11-05",
795
+ "capabilities": {"tools": {"listChanged": False}},
796
+ "serverInfo": {"name": "eiq-mcp", "version": "0.1.0"},
797
+ })
798
+ elif method == "tools/list":
799
+ _jsonrpc_respond(req_id, result={"tools": TOOLS})
800
+ elif method == "tools/call":
801
+ tool_name = params.get("name", "")
802
+ arguments = params.get("arguments", {})
803
+ try:
804
+ text = _dispatch(tool_name, arguments)
805
+ _jsonrpc_respond(req_id, result={
806
+ "content": [{"type": "text", "text": text}],
807
+ })
808
+ except Exception as exc:
809
+ _jsonrpc_respond(req_id, result={
810
+ "content": [{"type": "text", "text": json.dumps({"error": str(exc)})}],
811
+ "isError": True,
812
+ })
813
+ elif method == "notifications/initialized":
814
+ pass # no response needed for notifications
815
+ else:
816
+ _jsonrpc_respond(req_id, error={"code": -32601, "message": f"Unknown method: {method}"})
817
+
818
+
819
+ def _jsonrpc_respond(req_id, *, result=None, error=None) -> None:
820
+ """Write a JSON-RPC 2.0 response to stdout."""
821
+ resp: dict = {"jsonrpc": "2.0", "id": req_id}
822
+ if error is not None:
823
+ resp["error"] = error
824
+ else:
825
+ resp["result"] = result
826
+ sys.stdout.write(json.dumps(resp) + "\n")
827
+ sys.stdout.flush()
828
+
829
+
830
+ # ===================================================================
831
+ # Entry point
832
+ # ===================================================================
833
+
834
+ def main() -> None:
835
+ """Run the MCP server over stdio (the transport Claude Code / Cursor use)."""
836
+ if _HAS_MCP:
837
+ import asyncio
838
+ asyncio.run(_run_mcp())
839
+ else:
840
+ _run_jsonrpc_fallback()
841
+
842
+
843
+ if __name__ == "__main__":
844
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: everestapi
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for the EverestQuant prediction tournament platform
5
5
  Author-email: EverestQuant <engineering@everestquant.ai>
6
6
  License-Expression: MIT
@@ -24,6 +24,8 @@ Requires-Dist: click>=8.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=8.0; extra == "dev"
26
26
  Requires-Dist: pytest-httpx>=0.34.0; extra == "dev"
27
+ Provides-Extra: mcp
28
+ Requires-Dist: mcp>=1.0; extra == "mcp"
27
29
  Dynamic: license-file
28
30
 
29
31
  # everestapi
@@ -12,5 +12,9 @@ src/everestapi.egg-info/dependency_links.txt
12
12
  src/everestapi.egg-info/entry_points.txt
13
13
  src/everestapi.egg-info/requires.txt
14
14
  src/everestapi.egg-info/top_level.txt
15
+ src/everestapi/mcp/__init__.py
16
+ src/everestapi/mcp/__main__.py
17
+ src/everestapi/mcp/server.py
15
18
  tests/test_cli.py
16
- tests/test_client.py
19
+ tests/test_client.py
20
+ tests/test_mcp_and_models.py
@@ -4,3 +4,6 @@ click>=8.0
4
4
  [dev]
5
5
  pytest>=8.0
6
6
  pytest-httpx>=0.34.0
7
+
8
+ [mcp]
9
+ mcp>=1.0
@@ -88,3 +88,49 @@ def test_safe_output_path_rejects_traversal(api, tmp_path):
88
88
  with pytest.raises(ValueError):
89
89
  _safe_output_path("..", server_supplied=True)
90
90
  assert _safe_output_path(str(tmp_path / "ok.bin")) == str(tmp_path / "ok.bin")
91
+
92
+
93
+ # --- Cloudflare Access service token ---------------------------------------
94
+ # Staging sits behind Cloudflare Access; a service token clears the gate
95
+ # programmatically. Headers ride alongside X-API-Key and stay absent by default.
96
+
97
+ _K = "eiq_test_key"
98
+ _URL = "http://test"
99
+
100
+
101
+ def test_cf_service_token_absent_by_default(monkeypatch):
102
+ monkeypatch.delenv("CF_ACCESS_CLIENT_ID", raising=False)
103
+ monkeypatch.delenv("CF_ACCESS_CLIENT_SECRET", raising=False)
104
+ client = EverestAPI(api_key=_K, base_url=_URL)
105
+ h = client._client.headers
106
+ assert "cf-access-client-id" not in h
107
+ assert "cf-access-client-secret" not in h
108
+
109
+
110
+ def test_cf_service_token_from_env(monkeypatch):
111
+ cfid, cftok = "abc.access", "shh"
112
+ monkeypatch.setenv("CF_ACCESS_CLIENT_ID", cfid)
113
+ monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", cftok)
114
+ client = EverestAPI(api_key=_K, base_url=_URL)
115
+ h = client._client.headers
116
+ assert h["cf-access-client-id"] == cfid
117
+ assert h["cf-access-client-secret"] == cftok
118
+ assert h["x-api-key"] == _K
119
+
120
+
121
+ def test_cf_service_token_kwargs_override_env(monkeypatch):
122
+ monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env.access")
123
+ monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "envv")
124
+ kid, ksec = "kw.access", "kwv"
125
+ client = EverestAPI(api_key=_K, base_url=_URL,
126
+ cf_access_client_id=kid, cf_access_client_secret=ksec)
127
+ h = client._client.headers
128
+ assert h["cf-access-client-id"] == kid
129
+ assert h["cf-access-client-secret"] == ksec
130
+
131
+
132
+ def test_cf_service_token_needs_both_halves(monkeypatch):
133
+ monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "abc.access")
134
+ monkeypatch.delenv("CF_ACCESS_CLIENT_SECRET", raising=False)
135
+ client = EverestAPI(api_key=_K, base_url=_URL)
136
+ assert "cf-access-client-id" not in client._client.headers
@@ -0,0 +1,120 @@
1
+ """Tests for the MCP server module + the model-management / versioned-download
2
+ client methods added alongside it (everestapi 0.2.0)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from everestapi import EverestAPI, EverestError
11
+
12
+
13
+ @pytest.fixture
14
+ def api(monkeypatch):
15
+ for var in (
16
+ "EVEREST_API_URL",
17
+ "EVEREST_API_KEY",
18
+ "CF_ACCESS_CLIENT_ID",
19
+ "CF_ACCESS_CLIENT_SECRET",
20
+ "EIQ_BASIC_AUTH_USER",
21
+ "EIQ_BASIC_AUTH_PASS",
22
+ ):
23
+ monkeypatch.delenv(var, raising=False)
24
+ return EverestAPI(api_key="eiq_test_key", base_url="http://test")
25
+
26
+
27
+ # -- client: model management --------------------------------------------
28
+
29
+ def test_create_model_posts_body(httpx_mock, api):
30
+ httpx_mock.add_response(
31
+ url="http://test/api/v1/agents/me/models", method="POST",
32
+ json={"id": "m1", "name": "my-model"},
33
+ )
34
+ out = api.create_model("my-model", description="d")
35
+ assert out["name"] == "my-model"
36
+ assert json.loads(httpx_mock.get_request().content) == {
37
+ "name": "my-model", "description": "d",
38
+ }
39
+
40
+
41
+ def test_create_model_idempotent_on_409(httpx_mock, api):
42
+ httpx_mock.add_response(
43
+ url="http://test/api/v1/agents/me/models", method="POST",
44
+ status_code=409, json={"detail": "exists"},
45
+ )
46
+ assert api.create_model("dup") == {"name": "dup", "status": "already_exists"}
47
+
48
+
49
+ def test_create_model_reraises_non_409(httpx_mock, api):
50
+ httpx_mock.add_response(
51
+ url="http://test/api/v1/agents/me/models", method="POST",
52
+ status_code=400, json={"detail": "bad"},
53
+ )
54
+ with pytest.raises(EverestError):
55
+ api.create_model("x")
56
+
57
+
58
+ def test_get_models(httpx_mock, api):
59
+ httpx_mock.add_response(
60
+ url="http://test/api/v1/agents/me/models", method="GET",
61
+ json={"agent_id": "a", "models": []},
62
+ )
63
+ assert api.get_models()["models"] == []
64
+
65
+
66
+ # -- client: versioned dataset download (the #558 fix) -------------------
67
+
68
+ def test_download_dataset_uses_versioned_route(httpx_mock, api, tmp_path):
69
+ httpx_mock.add_response(
70
+ url="http://test/api/v1/data/download/bregen/futures/train",
71
+ content=b"PAR1data",
72
+ )
73
+ out = api.download_dataset(
74
+ universe="futures", split="train", version="bregen",
75
+ output_path=str(tmp_path / "t.parquet"),
76
+ )
77
+ assert httpx_mock.get_request().url.path == "/api/v1/data/download/bregen/futures/train"
78
+ with open(out, "rb") as f:
79
+ assert f.read() == b"PAR1data"
80
+
81
+
82
+ def test_download_dataset_latest_resolves_version(httpx_mock, api, tmp_path):
83
+ httpx_mock.add_response(
84
+ url="http://test/api/v1/data/versions",
85
+ json={"versions": [{"version": "bregen", "universes": ["futures"]}]},
86
+ )
87
+ httpx_mock.add_response(
88
+ url="http://test/api/v1/data/download/bregen/futures/live",
89
+ content=b"PAR1live",
90
+ )
91
+ out = api.download_dataset(
92
+ universe="futures", split="live", version="latest",
93
+ output_path=str(tmp_path / "l.parquet"),
94
+ )
95
+ with open(out, "rb") as f:
96
+ assert f.read() == b"PAR1live"
97
+
98
+
99
+ # -- MCP server -----------------------------------------------------------
100
+
101
+ def test_mcp_tools_include_loop_and_model_tools():
102
+ from everestapi.mcp import server
103
+ names = {t["name"] for t in server.TOOLS}
104
+ for needed in (
105
+ "download_dataset", "create_model", "get_models",
106
+ "submit_futures_predictions", "get_current_round", "get_scores",
107
+ ):
108
+ assert needed in names, needed
109
+
110
+
111
+ def test_mcp_dispatch_create_model_uses_client(monkeypatch):
112
+ from everestapi.mcp import server
113
+
114
+ class FakeClient:
115
+ def create_model(self, name, description=None):
116
+ return {"name": name, "description": description}
117
+
118
+ monkeypatch.setattr(server, "_client", FakeClient())
119
+ out = json.loads(server._dispatch("create_model", {"name": "z", "description": "d"}))
120
+ assert out == {"name": "z", "description": "d"}
File without changes
File without changes
File without changes
File without changes