probnode-mcp 0.1.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.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: probnode-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for ProbNode: Polymarket order books, trades and gaps for AI agents
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: mcp<2,>=1.2
8
+ Requires-Dist: probnode-sdk>=0.1.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.3; extra == 'dev'
11
+ Requires-Dist: ruff>=0.6; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # probnode-mcp
15
+
16
+ An [MCP](https://modelcontextprotocol.io) server that gives an AI agent ProbNode's Polymarket
17
+ history: find markets, check recording coverage, list files with sizes and sha256, sign download
18
+ links, and pull small windows of rows.
19
+
20
+ ```sh
21
+ pip install ./packages/probnode ./packages/probnode-mcp # from a checkout; on PyPI soon
22
+ ```
23
+
24
+ ```json
25
+ {"mcpServers": {"probnode": {"command": "probnode-mcp", "env": {"PROBNODE_API_KEY": "pmd_..."}}}}
26
+ ```
27
+
28
+ Tools: `datasets`, `search_markets`, `coverage`, `list_files`, `sign_files`, `timeseries`,
29
+ `samples`. Every call goes through the same API, plan and limits as the `probnode` SDK; refusals
30
+ come back as `{"error": ..., "status": ...}`. Bulk data travels as signed URLs, not through the
31
+ model's context - `timeseries` returns at most 500 rows.
@@ -0,0 +1,18 @@
1
+ # probnode-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that gives an AI agent ProbNode's Polymarket
4
+ history: find markets, check recording coverage, list files with sizes and sha256, sign download
5
+ links, and pull small windows of rows.
6
+
7
+ ```sh
8
+ pip install ./packages/probnode ./packages/probnode-mcp # from a checkout; on PyPI soon
9
+ ```
10
+
11
+ ```json
12
+ {"mcpServers": {"probnode": {"command": "probnode-mcp", "env": {"PROBNODE_API_KEY": "pmd_..."}}}}
13
+ ```
14
+
15
+ Tools: `datasets`, `search_markets`, `coverage`, `list_files`, `sign_files`, `timeseries`,
16
+ `samples`. Every call goes through the same API, plan and limits as the `probnode` SDK; refusals
17
+ come back as `{"error": ..., "status": ...}`. Bulk data travels as signed URLs, not through the
18
+ model's context - `timeseries` returns at most 500 rows.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "probnode-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for ProbNode: Polymarket order books, trades and gaps for AI agents"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = ["probnode-sdk>=0.1.0", "mcp>=1.2,<2"]
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest>=8.3", "ruff>=0.6"]
12
+
13
+ [project.scripts]
14
+ probnode-mcp = "probnode_mcp.server:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/probnode_mcp"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
+ [tool.ruff]
27
+ line-length = 100
28
+ target-version = "py310"
29
+
30
+ [tool.ruff.lint]
31
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,3 @@
1
+ """MCP server for ProbNode. Run `probnode-mcp`."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,146 @@
1
+ """ProbNode as MCP tools, for an AI agent working with Polymarket history.
2
+
3
+ {"mcpServers": {"probnode": {"command": "probnode-mcp",
4
+ "env": {"PROBNODE_API_KEY": "pn_..."}}}}
5
+
6
+ Every tool is one ProbNode API call through `probnode.Client`, so an agent sees exactly what a
7
+ customer's script sees: the same entitlements, the same quotas, the same refusals in words. Tools
8
+ return small JSON; bulk data is handed over as signed URLs (`sign_files`) or a capped range query
9
+ (`timeseries`), never streamed through the model's context.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from mcp.server.fastmcp import FastMCP
17
+ from probnode import Client, ProbNodeError
18
+
19
+ #: Rows a `timeseries` call returns to the model at most. The API caps a window at 200,000 rows;
20
+ #: that is a file, not context. More than this, the tool says so and suggests files instead.
21
+ MAX_ROWS_TO_MODEL = 500
22
+
23
+ mcp = FastMCP("probnode")
24
+ _client: Client | None = None
25
+
26
+
27
+ def client() -> Client:
28
+ global _client
29
+ if _client is None:
30
+ _client = Client()
31
+ return _client
32
+
33
+
34
+ def _refusal(exc: ProbNodeError) -> dict[str, Any]:
35
+ return {"error": exc.detail, "status": exc.status}
36
+
37
+
38
+ @mcp.tool()
39
+ def datasets() -> Any:
40
+ """Datasets ProbNode holds (books, trades, gaps, markets, quotes, ohlcv_1m), their formats,
41
+ whether a paid plan is needed (tier "tape") and the first and last UTC hour held."""
42
+ try:
43
+ return client().datasets()
44
+ except ProbNodeError as exc:
45
+ return _refusal(exc)
46
+
47
+
48
+ @mcp.tool()
49
+ def search_markets(query: str, limit: int = 20) -> Any:
50
+ """Find Polymarket markets by words in their slug or title. Returns slugs, series, start and
51
+ end times, resolution and outcome token ids."""
52
+ try:
53
+ return client().markets(q=query, limit=min(max(limit, 1), 100))
54
+ except ProbNodeError as exc:
55
+ return _refusal(exc)
56
+
57
+
58
+ @mcp.tool()
59
+ def coverage(token_id: str, start: str | None = None, end: str | None = None) -> Any:
60
+ """How completely one outcome token was recorded, hour by hour: recorded ms, gaps and uptime.
61
+ Times are ISO UTC. Hours marked implied were recorded whole (no gap touched them)."""
62
+ try:
63
+ return client().coverage(token_id, start, end)
64
+ except ProbNodeError as exc:
65
+ return _refusal(exc)
66
+
67
+
68
+ @mcp.tool()
69
+ def list_files(
70
+ dataset: str,
71
+ format: str,
72
+ start: str,
73
+ end: str,
74
+ granularity: str = "hour",
75
+ market: list[str] | None = None,
76
+ ) -> Any:
77
+ """Files for a dataset and format over [start, end) (ISO UTC, on the hour, at most 31 days):
78
+ object path, rows, bytes and sha256 each, plus totals. granularity "hour" (all markets per
79
+ file) or "market_day" (one market, one UTC day). Needs an API key."""
80
+ try:
81
+ files = client().files(
82
+ dataset, format, start, end, granularity=granularity, market=market or ()
83
+ )
84
+ except ProbNodeError as exc:
85
+ return _refusal(exc)
86
+ return {
87
+ "total_files": len(files),
88
+ "total_bytes": sum(f.bytes for f in files),
89
+ "total_rows": sum(f.rows for f in files),
90
+ "files": [
91
+ {
92
+ "object_path": f.object_path,
93
+ "rows": f.rows,
94
+ "bytes": f.bytes,
95
+ "sha256": f.sha256,
96
+ "market": f.market,
97
+ }
98
+ for f in files[:200]
99
+ ],
100
+ "truncated": len(files) > 200,
101
+ }
102
+
103
+
104
+ @mcp.tool()
105
+ def sign_files(object_paths: list[str]) -> Any:
106
+ """Short-lived download URLs (about 15 minutes) for object paths from list_files. Each counts
107
+ against the account's daily signed-URL limit; tape data needs a paid plan."""
108
+ try:
109
+ return client().sign(object_paths)
110
+ except ProbNodeError as exc:
111
+ return _refusal(exc)
112
+
113
+
114
+ @mcp.tool()
115
+ def timeseries(dataset: str, market: str, start: str, end: str) -> Any:
116
+ """Rows of one dataset (e.g. quotes, trades, ohlcv_1m) for one market over a short window,
117
+ as JSON objects. For more than a few hundred rows use list_files + sign_files instead."""
118
+ try:
119
+ rows = client().timeseries(dataset, market, start, end, format="ndjson")
120
+ except ProbNodeError as exc:
121
+ return _refusal(exc)
122
+ if len(rows) > MAX_ROWS_TO_MODEL:
123
+ return {
124
+ "rows": rows[:MAX_ROWS_TO_MODEL],
125
+ "returned": MAX_ROWS_TO_MODEL,
126
+ "total": len(rows),
127
+ "note": "more rows than fit here; narrow the window or download files",
128
+ }
129
+ return {"rows": rows, "returned": len(rows), "total": len(rows)}
130
+
131
+
132
+ @mcp.tool()
133
+ def samples() -> Any:
134
+ """Free sample files - one market-day per dataset and format - with URLs that need no key."""
135
+ try:
136
+ return client().samples()
137
+ except ProbNodeError as exc:
138
+ return _refusal(exc)
139
+
140
+
141
+ def main() -> None:
142
+ mcp.run()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,77 @@
1
+ """The MCP tools against a stub client: they pass arguments through, cap what reaches the model,
2
+ and turn API refusals into a JSON error instead of a crashed tool call."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from typing import Any
8
+
9
+ import pytest
10
+ from probnode import DataFile, ProbNodeError
11
+
12
+ from probnode_mcp import server
13
+
14
+
15
+ class Stub:
16
+ def __init__(self) -> None:
17
+ self.calls: list[tuple[str, Any]] = []
18
+
19
+ def datasets(self) -> list[dict]:
20
+ return [{"code": "books"}]
21
+
22
+ def files(self, *args: Any, **kw: Any) -> list[DataFile]:
23
+ self.calls.append(("files", (args, kw)))
24
+ if args[0] == "secret":
25
+ raise ProbNodeError(402, "books need a paid plan")
26
+ return [
27
+ DataFile("books", "parquet", "2026-11-08", h, "s6", f"x/{h}", 10, 100, "ab" * 32)
28
+ for h in range(250)
29
+ ]
30
+
31
+ def sign(self, paths: list[str]) -> dict[str, str]:
32
+ return {p: f"https://lake/{p}" for p in paths}
33
+
34
+ def timeseries(self, *args: Any, **kw: Any) -> list[dict]:
35
+ assert kw["format"] == "ndjson"
36
+ return [{"i": i} for i in range(int(args[1]))]
37
+
38
+
39
+ @pytest.fixture(autouse=True)
40
+ def stub(monkeypatch: pytest.MonkeyPatch) -> Stub:
41
+ s = Stub()
42
+ monkeypatch.setattr(server, "_client", s)
43
+ return s
44
+
45
+
46
+ def test_list_files_totals_and_truncates(stub: Stub) -> None:
47
+ out = server.list_files("books", "parquet", "a", "b", granularity="market_day", market=["m"])
48
+ assert out["total_files"] == 250 and out["total_bytes"] == 25_000
49
+ assert len(out["files"]) == 200 and out["truncated"] is True
50
+ assert stub.calls[0][1][1] == {"granularity": "market_day", "market": ["m"]}
51
+
52
+
53
+ def test_refusal_is_returned_not_raised() -> None:
54
+ assert server.list_files("secret", "parquet", "a", "b") == {
55
+ "error": "books need a paid plan",
56
+ "status": 402,
57
+ }
58
+
59
+
60
+ def test_timeseries_caps_rows_for_the_model() -> None:
61
+ small = server.timeseries("quotes", "3", "a", "b")
62
+ assert small["returned"] == 3 and small["total"] == 3
63
+ big = server.timeseries("quotes", "1000", "a", "b")
64
+ assert big["returned"] == server.MAX_ROWS_TO_MODEL and big["total"] == 1000 and big["note"]
65
+
66
+
67
+ def test_every_tool_is_registered() -> None:
68
+ names = {t.name for t in asyncio.run(server.mcp.list_tools())}
69
+ assert names == {
70
+ "datasets",
71
+ "search_markets",
72
+ "coverage",
73
+ "list_files",
74
+ "sign_files",
75
+ "timeseries",
76
+ "samples",
77
+ }