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,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
|
+
}
|