@pipeworx/mcp-polymarket 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/package.json +20 -0
- package/server.json +18 -0
- package/src/index.ts +820 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pipeworx
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# mcp-polymarket
|
|
2
|
+
|
|
3
|
+
Polymarket MCP — prediction-market data via Gamma + CLOB public APIs.
|
|
4
|
+
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 881+ live data sources.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"polymarket": {
|
|
20
|
+
"url": "https://gateway.pipeworx.io/polymarket/mcp"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or connect to the full Pipeworx gateway for access to all 881+ data sources:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"pipeworx": {
|
|
32
|
+
"url": "https://gateway.pipeworx.io/mcp"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Using with ask_pipeworx
|
|
39
|
+
|
|
40
|
+
Instead of calling tools directly, you can ask questions in plain English:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
ask_pipeworx({ question: "your question about Polymarket data" })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The gateway picks the right tool and fills the arguments automatically.
|
|
47
|
+
|
|
48
|
+
## More
|
|
49
|
+
|
|
50
|
+
- [All tools and guides](https://github.com/pipeworx-io/examples)
|
|
51
|
+
- [pipeworx.io](https://pipeworx.io)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pipeworx/mcp-polymarket",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Polymarket MCP — prediction-market data via Gamma + CLOB public APIs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "polymarket"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/pipeworx-io/mcp-polymarket"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.7.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.pipeworx-io/polymarket",
|
|
4
|
+
"title": "Polymarket",
|
|
5
|
+
"description": "Polymarket MCP — prediction-market data via Gamma + CLOB public APIs.",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"websiteUrl": "https://pipeworx.io/packs/polymarket",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/pipeworx-io/mcp-polymarket",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"remotes": [
|
|
13
|
+
{
|
|
14
|
+
"type": "streamable-http",
|
|
15
|
+
"url": "https://gateway.pipeworx.io/polymarket/mcp"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
interface McpToolDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: {
|
|
5
|
+
type: 'object';
|
|
6
|
+
properties: Record<string, unknown>;
|
|
7
|
+
required?: string[];
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface McpToolExport {
|
|
12
|
+
tools: McpToolDefinition[];
|
|
13
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
14
|
+
meter?: { credits: number };
|
|
15
|
+
cost?: Record<string, unknown>;
|
|
16
|
+
provider?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Polymarket MCP — prediction-market data via Gamma + CLOB public APIs.
|
|
21
|
+
*
|
|
22
|
+
* Polymarket runs binary-outcome prediction markets on Polygon. The Gamma API
|
|
23
|
+
* (gamma-api.polymarket.com) exposes market and event metadata. The CLOB API
|
|
24
|
+
* (clob.polymarket.com) exposes price history. Both are public; no auth.
|
|
25
|
+
*
|
|
26
|
+
* What agents typically want from this pack:
|
|
27
|
+
* - "What does the market think about X?" → polymarket_search
|
|
28
|
+
* - "What are the biggest open markets right now?" → polymarket_top_markets
|
|
29
|
+
* - "Full detail / resolution criteria for one market" → polymarket_market
|
|
30
|
+
* - "All markets within one event (e.g., 2028 election)" → polymarket_event
|
|
31
|
+
* - "How has the Yes probability moved over time?" → polymarket_price_history
|
|
32
|
+
*
|
|
33
|
+
* Prices are quoted as probabilities in [0, 1]. outcomePrices[0] is Yes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
const GAMMA = 'https://gamma-api.polymarket.com';
|
|
38
|
+
const CLOB = 'https://clob.polymarket.com';
|
|
39
|
+
const DATA_API = 'https://data-api.polymarket.com';
|
|
40
|
+
|
|
41
|
+
// Builder-auth credentials. Three values, all required together; gateway
|
|
42
|
+
// injects from POLYMARKET_BUILDER_{API_KEY,SECRET,PASSPHRASE} env if set,
|
|
43
|
+
// passing them through callTool args with _builder* prefixes. Falls back
|
|
44
|
+
// to unauthenticated public reads when missing — every public Gamma/CLOB
|
|
45
|
+
// endpoint we use works without auth, the credentials just (a) tag our
|
|
46
|
+
// volume for the Builders Program and (b) lift our rate limits.
|
|
47
|
+
interface BuilderCreds {
|
|
48
|
+
apiKey: string;
|
|
49
|
+
secret: string;
|
|
50
|
+
passphrase: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readBuilderCreds(args: Record<string, unknown>): BuilderCreds | null {
|
|
54
|
+
const apiKey = (args._builderApiKey as string | undefined)?.trim();
|
|
55
|
+
const secret = (args._builderSecret as string | undefined)?.trim();
|
|
56
|
+
const passphrase = (args._builderPassphrase as string | undefined)?.trim();
|
|
57
|
+
if (apiKey && secret && passphrase) return { apiKey, secret, passphrase };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Polymarket L2 auth: HMAC-SHA256 of (timestamp + method + path + body) using
|
|
62
|
+
// the builder secret as the key. Result is base64-encoded, sent in
|
|
63
|
+
// POLY-SIGNATURE. POLY-TIMESTAMP holds the Unix seconds used in the input.
|
|
64
|
+
// Docs: https://docs.polymarket.com/api/clob/authentication
|
|
65
|
+
// Polymarket CLOB builder secrets are URL-safe base64 (-, _), but atob() only
|
|
66
|
+
// accepts standard base64 (+, /) and throws on the URL-safe chars — which was
|
|
67
|
+
// failing 100% of signed calls (e.g. polymarket_price_history). Normalize to
|
|
68
|
+
// standard base64 + re-pad before decoding.
|
|
69
|
+
function decodeBuilderSecret(secret: string): Uint8Array {
|
|
70
|
+
const std = secret.trim().replace(/-/g, '+').replace(/_/g, '/');
|
|
71
|
+
const padded = std + '='.repeat((4 - (std.length % 4)) % 4);
|
|
72
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function signRequest(creds: BuilderCreds, method: string, path: string, body = ''): Promise<Record<string, string>> {
|
|
76
|
+
const ts = Math.floor(Date.now() / 1000).toString();
|
|
77
|
+
const message = `${ts}${method.toUpperCase()}${path}${body}`;
|
|
78
|
+
// CLOB expects the secret base64-decoded before HMAC, per their reference impl.
|
|
79
|
+
const rawSecret = decodeBuilderSecret(creds.secret);
|
|
80
|
+
const key = await crypto.subtle.importKey('raw', rawSecret, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
81
|
+
const sigBytes = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)));
|
|
82
|
+
let bin = '';
|
|
83
|
+
for (const b of sigBytes) bin += String.fromCharCode(b);
|
|
84
|
+
const signature = btoa(bin);
|
|
85
|
+
return {
|
|
86
|
+
'POLY-ADDRESS': '', // Builder API keys don't require an address; ok empty
|
|
87
|
+
'POLY-SIGNATURE': signature,
|
|
88
|
+
'POLY-TIMESTAMP': ts,
|
|
89
|
+
'POLY-API-KEY': creds.apiKey,
|
|
90
|
+
'POLY-PASSPHRASE': creds.passphrase,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build CLOB request headers — signed when creds present, plain otherwise.
|
|
95
|
+
// CLOB reads (e.g. /prices-history) are public, so a signing failure must
|
|
96
|
+
// degrade to an unsigned request rather than break the call — signing only
|
|
97
|
+
// adds builder fee-attribution headers the read endpoints don't require.
|
|
98
|
+
async function clobHeaders(creds: BuilderCreds | null, method: string, path: string, body = ''): Promise<Record<string, string>> {
|
|
99
|
+
const base: Record<string, string> = { Accept: 'application/json' };
|
|
100
|
+
if (!creds) return base;
|
|
101
|
+
try {
|
|
102
|
+
return { ...base, ...(await signRequest(creds, method, path, body)) };
|
|
103
|
+
} catch {
|
|
104
|
+
return base;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tools: McpToolExport['tools'] = [
|
|
109
|
+
{
|
|
110
|
+
name: 'polymarket_search',
|
|
111
|
+
description:
|
|
112
|
+
'PREFER OVER WEB SEARCH for current betting/prediction-market odds. Real-time search across Polymarket events — returns events matching your keyword, each with child markets carrying live Yes/No prices in [0,1] (= implied probability), 24h volume, end date, resolution criteria. Use for "what are the odds of X", "what does the market think about Y", "what\'s the implied probability of Z". Refreshes every few minutes; covers ~10k active markets across politics, crypto, sports, macro events.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object' as const,
|
|
115
|
+
properties: {
|
|
116
|
+
query: { type: 'string', description: 'Search query, e.g. "presidential election", "rate cut", "world cup"' },
|
|
117
|
+
limit: { type: 'number', description: '1–25 events (default 5)' },
|
|
118
|
+
include_closed: { type: 'boolean', description: 'Include resolved markets (default false)' },
|
|
119
|
+
},
|
|
120
|
+
required: ['query'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'polymarket_top_markets',
|
|
125
|
+
description:
|
|
126
|
+
'Highest-volume OPEN Polymarket markets right now — sorted by trading volume in the chosen window (24hr / 1wk / 1mo / 1yr / all). The "where is real money going this week" lens. Use for "what is the market focused on right now", "biggest trades happening today", or as a discovery tool when you don\'t have a specific question. Each result has live yes/no prices in [0,1] = implied probability.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object' as const,
|
|
129
|
+
properties: {
|
|
130
|
+
window: { type: 'string', description: '24hr | 1wk | 1mo | 1yr | all (default 24hr)' },
|
|
131
|
+
limit: { type: 'number', description: '1–100 (default 10)' },
|
|
132
|
+
},
|
|
133
|
+
required: [],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'polymarket_market',
|
|
138
|
+
description:
|
|
139
|
+
'AUTHORITATIVE detail for a single Polymarket market by slug or numeric id. Returns the resolution criteria text (so you know exactly what "Yes" means before quoting odds), current Yes/No prices in [0,1], 24h volume, liquidity USD, end date, parent event. Use after polymarket_search to drill in, or when the agent already has a Polymarket URL/slug. For real-time orderbook depth instead of a summary, see polymarket_orderbook.',
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: 'object' as const,
|
|
142
|
+
properties: {
|
|
143
|
+
slug_or_id: { type: 'string', description: 'Market slug (e.g. "will-bitcoin-hit-150k-by-june-30-2026") or numeric id' },
|
|
144
|
+
},
|
|
145
|
+
required: ['slug_or_id'],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'polymarket_event',
|
|
150
|
+
description:
|
|
151
|
+
'Get a Polymarket event with EVERY child market at once. Events group mutually-exclusive outcomes (e.g., "2028 Democratic nominee" has one Yes/No market per candidate, each price = implied probability of that candidate). Use when you need the full slate — election candidates, championship contenders, multi-option outcomes — instead of one specific market. Returns event metadata + array of child markets with live prices, volumes, end dates.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object' as const,
|
|
154
|
+
properties: {
|
|
155
|
+
slug_or_id: { type: 'string', description: 'Event slug (e.g. "2028-presidential-election") or numeric id' },
|
|
156
|
+
},
|
|
157
|
+
required: ['slug_or_id'],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'polymarket_price_history',
|
|
162
|
+
description:
|
|
163
|
+
'Historical probability time-series for one Polymarket market. Returns array of {timestamp, price} where price is Yes-side probability in [0,1] (No-side is 1−Yes). Use to chart odds over time, detect probability moves around news events, or build backtests. Intervals 1h | 6h | 1d | 1w | 1m | max — supports up to a month (1m) and full history (max). A young market may have less data than the requested window; the `coverage` field reports the actual span and whether it is the full available history (a data limit, not a tool limit).',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object' as const,
|
|
166
|
+
properties: {
|
|
167
|
+
slug_or_id: { type: 'string', description: 'Market slug or numeric id (same as polymarket_market)' },
|
|
168
|
+
interval: { type: 'string', description: '1h | 6h | 1d | 1w | 1m | max (default 1d). Higher fidelity for shorter windows.' },
|
|
169
|
+
},
|
|
170
|
+
required: ['slug_or_id'],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'polymarket_orderbook',
|
|
175
|
+
description:
|
|
176
|
+
'REAL-TIME CLOB orderbook for one Polymarket market — bid/ask ladder on both YES and NO sides with size at each price level. Use to check actual tradable depth before quoting a size estimate; the `liquidity` field on polymarket_market is a rolled-up summary, this is the actual ladder. Returns yes_bids[], yes_asks[], no_bids[], no_asks[] each as [price, size] pairs sorted from inside the book outward. Necessary input before any "you could buy $X at price Y" answer — without depth that\'s a guess.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object' as const,
|
|
179
|
+
properties: {
|
|
180
|
+
slug_or_id: { type: 'string', description: 'Market slug or numeric id' },
|
|
181
|
+
},
|
|
182
|
+
required: ['slug_or_id'],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'polymarket_event_books',
|
|
187
|
+
description:
|
|
188
|
+
'Batched CLOB orderbooks for EVERY tradable market in one Polymarket event — single round trip via the CLOB batch /books endpoint. Use before any multi-leg strategy (partition arbitrage "SELL/BUY EVERY LEG", basket trades) to check per-leg depth: theoretical overround means nothing if half the legs are 50-share books. Returns legs[] with {slug, question, yes_price, best_bid, best_ask, yes_bids[], yes_asks[]} where bids are sorted best(highest)-first and asks best(lowest)-first as {price, size} objects. Pass include_no=true to also fetch NO-side books (doubles payload — only needed for NO-leg strategies). Caps at 80 legs (highest yes_price kept; truncated_legs reports the cut).',
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: 'object' as const,
|
|
191
|
+
properties: {
|
|
192
|
+
event_slug_or_id: { type: 'string', description: 'Event slug (e.g. "fed-decision-may-2026") or numeric id — same input as polymarket_event.' },
|
|
193
|
+
include_no: { type: 'boolean', description: 'Also fetch NO-side books for each leg (default false).' },
|
|
194
|
+
},
|
|
195
|
+
required: ['event_slug_or_id'],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'polymarket_trades',
|
|
200
|
+
description:
|
|
201
|
+
"Recent EXECUTED trades (the fills tape) for a Polymarket market — actual money that changed hands, newest first. Each trade: side (BUY/SELL), outcome (Yes/No or the option name), size (shares), price, timestamp, and the trader's wallet/pseudonym. Use for \"what's the recent order flow\", \"is smart money buying Yes\", \"how much just traded and at what price\". DISTINCT from polymarket_orderbook (resting/unfilled orders — intent) and polymarket_price_history (the CP time-series). Pass a market slug or numeric id (same input as polymarket_market).",
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object' as const,
|
|
204
|
+
properties: {
|
|
205
|
+
slug_or_id: { type: 'string', description: 'Market slug (e.g. "will-trump-win-2024") or numeric id — same input as polymarket_market.' },
|
|
206
|
+
limit: { type: 'number', description: 'Number of recent trades to return (1-100, default 20)' },
|
|
207
|
+
},
|
|
208
|
+
required: ['slug_or_id'],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'polymarket_holders',
|
|
213
|
+
description:
|
|
214
|
+
'Largest position holders for a Polymarket market, per outcome — who holds the most Yes and the most No shares, with share amounts and trader pseudonyms. Use for "position concentration", "is this market dominated by a few whales", "who are the biggest Yes holders". Reveals conviction/concentration that price alone hides. Pass a market slug or numeric id (same input as polymarket_market).',
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: 'object' as const,
|
|
217
|
+
properties: {
|
|
218
|
+
slug_or_id: { type: 'string', description: 'Market slug or numeric id — same input as polymarket_market.' },
|
|
219
|
+
limit: { type: 'number', description: 'Top N holders per outcome to return (1-100, default 10)' },
|
|
220
|
+
},
|
|
221
|
+
required: ['slug_or_id'],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
async function gammaGet<T = unknown>(path: string, params?: Record<string, string | number | boolean>): Promise<T> {
|
|
229
|
+
const url = new URL(GAMMA + path);
|
|
230
|
+
if (params) {
|
|
231
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
232
|
+
}
|
|
233
|
+
const res = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
|
|
234
|
+
if (res.status === 404) throw new Error('Polymarket: not found');
|
|
235
|
+
if (!res.ok) {
|
|
236
|
+
const text = await res.text();
|
|
237
|
+
throw new Error(`Polymarket Gamma: ${res.status} ${text.slice(0, 200)}`);
|
|
238
|
+
}
|
|
239
|
+
return res.json() as Promise<T>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function clobGet<T = unknown>(
|
|
243
|
+
path: string,
|
|
244
|
+
params?: Record<string, string | number>,
|
|
245
|
+
creds?: BuilderCreds | null,
|
|
246
|
+
): Promise<T> {
|
|
247
|
+
const url = new URL(CLOB + path);
|
|
248
|
+
if (params) {
|
|
249
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
250
|
+
}
|
|
251
|
+
// Signature input is the path + canonical query string (not the host).
|
|
252
|
+
// Pass the leading / so the HMAC matches what the server reconstructs.
|
|
253
|
+
const signPath = url.pathname + (url.search || '');
|
|
254
|
+
const headers = await clobHeaders(creds ?? null, 'GET', signPath);
|
|
255
|
+
const res = await fetch(url.toString(), { headers });
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
const text = await res.text();
|
|
258
|
+
throw new Error(`Polymarket CLOB: ${res.status} ${text.slice(0, 200)}`);
|
|
259
|
+
}
|
|
260
|
+
return res.json() as Promise<T>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function clobPost<T = unknown>(
|
|
264
|
+
path: string,
|
|
265
|
+
body: unknown,
|
|
266
|
+
creds?: BuilderCreds | null,
|
|
267
|
+
): Promise<T> {
|
|
268
|
+
const payload = JSON.stringify(body);
|
|
269
|
+
const headers = await clobHeaders(creds ?? null, 'POST', path, payload);
|
|
270
|
+
const res = await fetch(CLOB + path, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
273
|
+
body: payload,
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const text = await res.text();
|
|
277
|
+
throw new Error(`Polymarket CLOB: ${res.status} ${text.slice(0, 200)}`);
|
|
278
|
+
}
|
|
279
|
+
return res.json() as Promise<T>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function parseJsonField<T>(value: unknown): T | null {
|
|
283
|
+
if (typeof value !== 'string') return null;
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(value) as T;
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface RawMarket {
|
|
292
|
+
id: string;
|
|
293
|
+
conditionId?: string; // 0x… — key for the data-api trades/holders endpoints
|
|
294
|
+
question: string;
|
|
295
|
+
slug: string;
|
|
296
|
+
description?: string;
|
|
297
|
+
outcomes?: string; // JSON-stringified ["Yes", "No"]
|
|
298
|
+
outcomePrices?: string; // JSON-stringified ["0.42", "0.58"]
|
|
299
|
+
volume?: string;
|
|
300
|
+
volumeNum?: number;
|
|
301
|
+
volume24hr?: number;
|
|
302
|
+
volume1wk?: number;
|
|
303
|
+
volume1mo?: number;
|
|
304
|
+
volume1yr?: number;
|
|
305
|
+
liquidity?: string;
|
|
306
|
+
liquidityNum?: number;
|
|
307
|
+
liquidityClob?: number;
|
|
308
|
+
// Top-of-book + spread come straight off Gamma. Useful to validate
|
|
309
|
+
// "I can fill at the displayed price" before sizing — outcomePrices
|
|
310
|
+
// is the last-trade prediction marker, but bestBid/bestAsk is what
|
|
311
|
+
// you actually trade against.
|
|
312
|
+
bestBid?: number;
|
|
313
|
+
bestAsk?: number;
|
|
314
|
+
spread?: number;
|
|
315
|
+
lastTradePrice?: number;
|
|
316
|
+
orderPriceMinTickSize?: number;
|
|
317
|
+
// Short-window price-change deltas. The tester asked for 4h / 1h
|
|
318
|
+
// windows; Gamma gives us 1h and 1d natively. Pass them through so
|
|
319
|
+
// bet_research and polymarket_market can surface "this market moved
|
|
320
|
+
// 8pp in the last hour" without an extra polymarket_price_history
|
|
321
|
+
// call.
|
|
322
|
+
oneHourPriceChange?: number;
|
|
323
|
+
oneDayPriceChange?: number;
|
|
324
|
+
oneWeekPriceChange?: number;
|
|
325
|
+
oneMonthPriceChange?: number;
|
|
326
|
+
oneYearPriceChange?: number;
|
|
327
|
+
active?: boolean;
|
|
328
|
+
closed?: boolean;
|
|
329
|
+
archived?: boolean;
|
|
330
|
+
endDate?: string;
|
|
331
|
+
startDate?: string;
|
|
332
|
+
clobTokenIds?: string; // JSON-stringified [yesTokenId, noTokenId]
|
|
333
|
+
acceptingOrders?: boolean;
|
|
334
|
+
image?: string;
|
|
335
|
+
events?: RawEvent[];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface RawEvent {
|
|
339
|
+
id: string;
|
|
340
|
+
ticker?: string;
|
|
341
|
+
slug: string;
|
|
342
|
+
title: string;
|
|
343
|
+
description?: string;
|
|
344
|
+
endDate?: string;
|
|
345
|
+
startDate?: string;
|
|
346
|
+
active?: boolean;
|
|
347
|
+
closed?: boolean;
|
|
348
|
+
archived?: boolean;
|
|
349
|
+
volume?: number;
|
|
350
|
+
volume24hr?: number;
|
|
351
|
+
openInterest?: number;
|
|
352
|
+
liquidity?: number;
|
|
353
|
+
competitive?: number;
|
|
354
|
+
commentCount?: number;
|
|
355
|
+
markets?: RawMarket[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function shapeMarket(m: RawMarket) {
|
|
359
|
+
const outcomes = parseJsonField<string[]>(m.outcomes) ?? [];
|
|
360
|
+
const prices = parseJsonField<string[]>(m.outcomePrices) ?? [];
|
|
361
|
+
const yes = prices[0] ? Number(prices[0]) : null;
|
|
362
|
+
const no = prices[1] ? Number(prices[1]) : null;
|
|
363
|
+
const liquidity = m.liquidityNum ?? m.liquidityClob ?? (m.liquidity ? Number(m.liquidity) : null);
|
|
364
|
+
const spread_pp = typeof m.spread === 'number' ? +(m.spread * 100).toFixed(2) : null;
|
|
365
|
+
return {
|
|
366
|
+
id: m.id,
|
|
367
|
+
slug: m.slug,
|
|
368
|
+
question: m.question,
|
|
369
|
+
description: m.description ?? null,
|
|
370
|
+
outcomes,
|
|
371
|
+
yes_price: yes,
|
|
372
|
+
no_price: no,
|
|
373
|
+
implied_probability_yes: yes,
|
|
374
|
+
// Top-of-book and spread for fill-quality validation. spread is in
|
|
375
|
+
// raw probability units (0.01 = 1¢); we also surface spread_pp for
|
|
376
|
+
// direct comparison with edge_pp from polymarket_edges.
|
|
377
|
+
best_bid: m.bestBid ?? null,
|
|
378
|
+
best_ask: m.bestAsk ?? null,
|
|
379
|
+
spread: m.spread ?? null,
|
|
380
|
+
spread_pp,
|
|
381
|
+
last_trade_price: m.lastTradePrice ?? null,
|
|
382
|
+
min_tick_size: m.orderPriceMinTickSize ?? null,
|
|
383
|
+
volume_total: m.volumeNum ?? (m.volume ? Number(m.volume) : null),
|
|
384
|
+
volume_24hr: m.volume24hr ?? null,
|
|
385
|
+
volume_1wk: m.volume1wk ?? null,
|
|
386
|
+
volume_1mo: m.volume1mo ?? null,
|
|
387
|
+
liquidity,
|
|
388
|
+
// Recent moves — 1h is the shortest Gamma exposes natively. All
|
|
389
|
+
// values are price deltas in raw probability (0.01 = +1pp). Lets
|
|
390
|
+
// bet_research surface "this market moved 8pp in 24h" without an
|
|
391
|
+
// extra price_history call.
|
|
392
|
+
price_change_1h: m.oneHourPriceChange ?? null,
|
|
393
|
+
price_change_1d: m.oneDayPriceChange ?? null,
|
|
394
|
+
price_change_1w: m.oneWeekPriceChange ?? null,
|
|
395
|
+
price_change_1mo: m.oneMonthPriceChange ?? null,
|
|
396
|
+
price_change_1y: m.oneYearPriceChange ?? null,
|
|
397
|
+
active: m.active ?? null,
|
|
398
|
+
closed: m.closed ?? null,
|
|
399
|
+
accepting_orders: m.acceptingOrders ?? null,
|
|
400
|
+
end_date: m.endDate ?? null,
|
|
401
|
+
start_date: m.startDate ?? null,
|
|
402
|
+
image: m.image ?? null,
|
|
403
|
+
url: `https://polymarket.com/market/${m.slug}`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function shapeEvent(e: RawEvent) {
|
|
408
|
+
return {
|
|
409
|
+
id: e.id,
|
|
410
|
+
slug: e.slug,
|
|
411
|
+
ticker: e.ticker ?? null,
|
|
412
|
+
title: e.title,
|
|
413
|
+
description: e.description ?? null,
|
|
414
|
+
active: e.active ?? null,
|
|
415
|
+
closed: e.closed ?? null,
|
|
416
|
+
volume_total: e.volume ?? null,
|
|
417
|
+
volume_24hr: e.volume24hr ?? null,
|
|
418
|
+
open_interest: e.openInterest ?? null,
|
|
419
|
+
liquidity: e.liquidity ?? null,
|
|
420
|
+
competitive_score: e.competitive ?? null,
|
|
421
|
+
comment_count: e.commentCount ?? null,
|
|
422
|
+
end_date: e.endDate ?? null,
|
|
423
|
+
start_date: e.startDate ?? null,
|
|
424
|
+
market_count: e.markets?.length ?? 0,
|
|
425
|
+
markets: (e.markets ?? []).map(shapeMarket),
|
|
426
|
+
url: `https://polymarket.com/event/${e.slug}`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
431
|
+
return Math.max(lo, Math.min(hi, n));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function toNum(v: unknown, fallback: number): number {
|
|
435
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
436
|
+
if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v);
|
|
437
|
+
return fallback;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── Tools ──────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
async function polymarketSearch(args: Record<string, unknown>) {
|
|
443
|
+
const query = String(args.query ?? '').trim();
|
|
444
|
+
if (!query) throw new Error('query is required (e.g. "election", "rate cut").');
|
|
445
|
+
const limit = clamp(toNum(args.limit, 5), 1, 25);
|
|
446
|
+
const includeClosed = args.include_closed === true;
|
|
447
|
+
|
|
448
|
+
const data = await gammaGet<{ events?: RawEvent[] }>('/public-search', { q: query, limit_per_type: 25, events_status: includeClosed ? 'all' : 'active' });
|
|
449
|
+
let events = data.events ?? [];
|
|
450
|
+
if (!includeClosed) {
|
|
451
|
+
events = events.filter((e) => e.active === true && e.closed === false && e.archived !== true);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
query,
|
|
455
|
+
count: events.length,
|
|
456
|
+
events: events.slice(0, limit).map(shapeEvent),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function polymarketTopMarkets(args: Record<string, unknown>) {
|
|
461
|
+
const window = String(args.window ?? '24hr');
|
|
462
|
+
const sortMap: Record<string, string> = {
|
|
463
|
+
'24hr': 'volume24hr',
|
|
464
|
+
'1wk': 'volume1wk',
|
|
465
|
+
'1mo': 'volume1mo',
|
|
466
|
+
'1yr': 'volume1yr',
|
|
467
|
+
all: 'volume',
|
|
468
|
+
};
|
|
469
|
+
const sortKey = sortMap[window];
|
|
470
|
+
if (!sortKey) {
|
|
471
|
+
throw new Error(`Invalid window "${window}". Valid: 24hr | 1wk | 1mo | 1yr | all.`);
|
|
472
|
+
}
|
|
473
|
+
const limit = clamp(toNum(args.limit, 10), 1, 200);
|
|
474
|
+
const markets = await gammaGet<RawMarket[]>('/markets', {
|
|
475
|
+
limit,
|
|
476
|
+
active: true,
|
|
477
|
+
closed: false,
|
|
478
|
+
order: sortKey,
|
|
479
|
+
ascending: false,
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
window,
|
|
483
|
+
count: markets.length,
|
|
484
|
+
markets: markets.map(shapeMarket),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function lookupMarket(slugOrId: string): Promise<RawMarket | null> {
|
|
489
|
+
// Numeric → id lookup; otherwise slug
|
|
490
|
+
if (/^\d+$/.test(slugOrId)) {
|
|
491
|
+
const markets = await gammaGet<RawMarket[]>('/markets', { id: slugOrId, limit: 1 });
|
|
492
|
+
return markets[0] ?? null;
|
|
493
|
+
}
|
|
494
|
+
const markets = await gammaGet<RawMarket[]>('/markets', { slug: slugOrId, limit: 1 });
|
|
495
|
+
return markets[0] ?? null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function lookupEvent(slugOrId: string): Promise<RawEvent | null> {
|
|
499
|
+
if (/^\d+$/.test(slugOrId)) {
|
|
500
|
+
const events = await gammaGet<RawEvent[]>('/events', { id: slugOrId, limit: 1 });
|
|
501
|
+
return events[0] ?? null;
|
|
502
|
+
}
|
|
503
|
+
const events = await gammaGet<RawEvent[]>('/events', { slug: slugOrId, limit: 1 });
|
|
504
|
+
return events[0] ?? null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function polymarketMarket(args: Record<string, unknown>) {
|
|
508
|
+
const slugOrId = String(args.slug_or_id ?? '').trim();
|
|
509
|
+
if (!slugOrId) throw new Error('slug_or_id is required.');
|
|
510
|
+
const market = await lookupMarket(slugOrId);
|
|
511
|
+
if (!market) return { error: 'not_found', message: `No market matching "${slugOrId}".` };
|
|
512
|
+
const shaped = shapeMarket(market);
|
|
513
|
+
const event = market.events?.[0] ? shapeEvent(market.events[0]) : null;
|
|
514
|
+
return { ...shaped, event: event ? { id: event.id, slug: event.slug, title: event.title, url: event.url } : null };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function polymarketEvent(args: Record<string, unknown>) {
|
|
518
|
+
const slugOrId = String(args.slug_or_id ?? '').trim();
|
|
519
|
+
if (!slugOrId) throw new Error('slug_or_id is required.');
|
|
520
|
+
const event = await lookupEvent(slugOrId);
|
|
521
|
+
if (!event) return { error: 'not_found', message: `No event matching "${slugOrId}".` };
|
|
522
|
+
return shapeEvent(event);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const INTERVAL_MAP: Record<string, { interval: string; fidelity: number }> = {
|
|
526
|
+
'1h': { interval: '1h', fidelity: 1 },
|
|
527
|
+
'6h': { interval: '6h', fidelity: 5 },
|
|
528
|
+
'1d': { interval: '1d', fidelity: 30 },
|
|
529
|
+
'1w': { interval: '1w', fidelity: 60 },
|
|
530
|
+
'1m': { interval: '1m', fidelity: 240 },
|
|
531
|
+
max: { interval: 'max', fidelity: 720 },
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// Days each interval's WINDOW covers, so we can tell the caller when a young
|
|
535
|
+
// market simply has less data than requested (vs. a tool limitation).
|
|
536
|
+
const WINDOW_DAYS: Record<string, number> = {
|
|
537
|
+
'1h': 1 / 24, '6h': 0.25, '1d': 1, '1w': 7, '1m': 30, max: Infinity,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
async function polymarketPriceHistory(args: Record<string, unknown>) {
|
|
541
|
+
const slugOrId = String(args.slug_or_id ?? '').trim();
|
|
542
|
+
if (!slugOrId) throw new Error('slug_or_id is required.');
|
|
543
|
+
const intervalKey = String(args.interval ?? '1d');
|
|
544
|
+
const intervalCfg = INTERVAL_MAP[intervalKey];
|
|
545
|
+
if (!intervalCfg) {
|
|
546
|
+
throw new Error(`Invalid interval "${intervalKey}". Valid: ${Object.keys(INTERVAL_MAP).join(' | ')}.`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const market = await lookupMarket(slugOrId);
|
|
550
|
+
if (!market) return { error: 'not_found', message: `No market matching "${slugOrId}".` };
|
|
551
|
+
const tokens = parseJsonField<string[]>(market.clobTokenIds);
|
|
552
|
+
if (!tokens || !tokens[0]) {
|
|
553
|
+
return { error: 'no_token', message: 'Market has no CLOB token id — likely not orderbook-tradable.' };
|
|
554
|
+
}
|
|
555
|
+
const yesToken = tokens[0];
|
|
556
|
+
const data = await clobGet<{ history?: Array<{ t: number; p: number }> }>('/prices-history', {
|
|
557
|
+
market: yesToken,
|
|
558
|
+
interval: intervalCfg.interval,
|
|
559
|
+
fidelity: intervalCfg.fidelity,
|
|
560
|
+
}, readBuilderCreds(args));
|
|
561
|
+
const history = (data.history ?? []).map((pt) => ({
|
|
562
|
+
timestamp: new Date(pt.t * 1000).toISOString(),
|
|
563
|
+
unix: pt.t,
|
|
564
|
+
yes_probability: pt.p,
|
|
565
|
+
}));
|
|
566
|
+
|
|
567
|
+
// Coverage note: when the caller asks for a long window (e.g. 1m) but the
|
|
568
|
+
// market is young, the series is the FULL available history — not a tool
|
|
569
|
+
// limitation. Say so explicitly so the model doesn't report "no 30-day tool".
|
|
570
|
+
let coverage: string;
|
|
571
|
+
if (history.length < 2) {
|
|
572
|
+
coverage = history.length === 0
|
|
573
|
+
? 'no price history (market too new for a time-series)'
|
|
574
|
+
: 'single data point only (market too new for a time-series)';
|
|
575
|
+
} else {
|
|
576
|
+
const spanDays = (history[history.length - 1].unix - history[0].unix) / 86_400;
|
|
577
|
+
const requested = WINDOW_DAYS[intervalKey] ?? Infinity;
|
|
578
|
+
coverage = (requested !== Infinity && spanDays < requested * 0.7)
|
|
579
|
+
? `${spanDays.toFixed(1)}d available — this is the FULL history for this market (it is only ~${Math.ceil(spanDays)} day(s) old), so there is no data going back the requested ${intervalKey} window. Not a tool limit.`
|
|
580
|
+
: `${spanDays.toFixed(1)}d`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
market_id: market.id,
|
|
585
|
+
market_slug: market.slug,
|
|
586
|
+
question: market.question,
|
|
587
|
+
interval: intervalKey,
|
|
588
|
+
coverage,
|
|
589
|
+
point_count: history.length,
|
|
590
|
+
history,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function polymarketOrderbook(args: Record<string, unknown>) {
|
|
595
|
+
const slugOrId = String(args.slug_or_id ?? '').trim();
|
|
596
|
+
if (!slugOrId) throw new Error('slug_or_id is required.');
|
|
597
|
+
const market = await lookupMarket(slugOrId);
|
|
598
|
+
if (!market) return { error: 'not_found', message: `No market matching "${slugOrId}".` };
|
|
599
|
+
const tokens = parseJsonField<string[]>(market.clobTokenIds);
|
|
600
|
+
if (!tokens || tokens.length < 2) {
|
|
601
|
+
return { error: 'no_token', message: 'Market has no CLOB token ids — not orderbook-tradable.' };
|
|
602
|
+
}
|
|
603
|
+
const [yesToken, noToken] = tokens;
|
|
604
|
+
const creds = readBuilderCreds(args);
|
|
605
|
+
// Both sides in parallel. /book?token_id=X returns {bids, asks} as
|
|
606
|
+
// [price, size] string pairs sorted from inside the book out.
|
|
607
|
+
type Book = { bids?: Array<{ price: string; size: string }>; asks?: Array<{ price: string; size: string }> };
|
|
608
|
+
const [yesBook, noBook] = await Promise.all([
|
|
609
|
+
clobGet<Book>('/book', { token_id: yesToken }, creds),
|
|
610
|
+
clobGet<Book>('/book', { token_id: noToken }, creds),
|
|
611
|
+
]);
|
|
612
|
+
const fmt = (lvl: { price: string; size: string }) => ({ price: parseFloat(lvl.price), size: parseFloat(lvl.size) });
|
|
613
|
+
// Cents-on-the-dollar depth summed across the visible book — useful
|
|
614
|
+
// for "can I fill $X here" sanity checks without doing the math.
|
|
615
|
+
const sumDepth = (side: Array<{ price: string; size: string }> | undefined) =>
|
|
616
|
+
(side ?? []).reduce((s, l) => s + parseFloat(l.price) * parseFloat(l.size), 0);
|
|
617
|
+
return {
|
|
618
|
+
market_id: market.id,
|
|
619
|
+
market_slug: market.slug,
|
|
620
|
+
question: market.question,
|
|
621
|
+
yes_token: yesToken,
|
|
622
|
+
no_token: noToken,
|
|
623
|
+
yes_bids: (yesBook.bids ?? []).map(fmt),
|
|
624
|
+
yes_asks: (yesBook.asks ?? []).map(fmt),
|
|
625
|
+
no_bids: (noBook.bids ?? []).map(fmt),
|
|
626
|
+
no_asks: (noBook.asks ?? []).map(fmt),
|
|
627
|
+
depth_summary_usd: {
|
|
628
|
+
yes_bid_total: +sumDepth(yesBook.bids).toFixed(2),
|
|
629
|
+
yes_ask_total: +sumDepth(yesBook.asks).toFixed(2),
|
|
630
|
+
no_bid_total: +sumDepth(noBook.bids).toFixed(2),
|
|
631
|
+
no_ask_total: +sumDepth(noBook.asks).toFixed(2),
|
|
632
|
+
},
|
|
633
|
+
builder_signed: creds !== null,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function polymarketEventBooks(args: Record<string, unknown>) {
|
|
638
|
+
const slugOrId = String(args.event_slug_or_id ?? '').trim();
|
|
639
|
+
if (!slugOrId) throw new Error('event_slug_or_id is required.');
|
|
640
|
+
const includeNo = args.include_no === true;
|
|
641
|
+
const event = await lookupEvent(slugOrId);
|
|
642
|
+
if (!event) return { error: 'not_found', message: `No event matching "${slugOrId}".` };
|
|
643
|
+
|
|
644
|
+
type Leg = { market: RawMarket; yesToken: string; noToken: string | null; yesPrice: number };
|
|
645
|
+
let legs: Leg[] = [];
|
|
646
|
+
let skippedNoToken = 0;
|
|
647
|
+
for (const m of event.markets ?? []) {
|
|
648
|
+
if (m.closed) continue;
|
|
649
|
+
const tokens = parseJsonField<string[]>(m.clobTokenIds);
|
|
650
|
+
if (!tokens || !tokens[0]) { skippedNoToken++; continue; }
|
|
651
|
+
const prices = parseJsonField<string[]>(m.outcomePrices) ?? [];
|
|
652
|
+
legs.push({ market: m, yesToken: tokens[0], noToken: tokens[1] ?? null, yesPrice: prices[0] ? Number(prices[0]) : 0 });
|
|
653
|
+
}
|
|
654
|
+
// The batch /books endpoint takes one params entry per token. 80 legs
|
|
655
|
+
// (160 tokens with NO sides) keeps the response under CF's body limits
|
|
656
|
+
// and covers real partitions: World Cup Winner runs 49 priced legs; a
|
|
657
|
+
// 40-leg cap forced partial_book_coverage on its arbitrage fill_check.
|
|
658
|
+
const LEG_CAP = 80;
|
|
659
|
+
let truncated = 0;
|
|
660
|
+
if (legs.length > LEG_CAP) {
|
|
661
|
+
legs = legs.sort((a, b) => b.yesPrice - a.yesPrice).slice(0, LEG_CAP);
|
|
662
|
+
truncated = (event.markets?.length ?? 0) - LEG_CAP;
|
|
663
|
+
}
|
|
664
|
+
if (legs.length === 0) {
|
|
665
|
+
return { error: 'no_tradable_legs', message: 'Event has no open orderbook-tradable markets.', skipped_no_token: skippedNoToken };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const creds = readBuilderCreds(args);
|
|
669
|
+
const params: Array<{ token_id: string }> = legs.map((l) => ({ token_id: l.yesToken }));
|
|
670
|
+
if (includeNo) for (const l of legs) if (l.noToken) params.push({ token_id: l.noToken });
|
|
671
|
+
type RawBook = { asset_id?: string; bids?: Array<{ price: string; size: string }>; asks?: Array<{ price: string; size: string }> };
|
|
672
|
+
const books = await clobPost<RawBook[]>('/books', params, creds);
|
|
673
|
+
const byAsset = new Map<string, RawBook>();
|
|
674
|
+
for (const b of books ?? []) if (b.asset_id) byAsset.set(b.asset_id, b);
|
|
675
|
+
|
|
676
|
+
const fmt = (lvls: Array<{ price: string; size: string }> | undefined) =>
|
|
677
|
+
(lvls ?? []).map((l) => ({ price: parseFloat(l.price), size: parseFloat(l.size) }));
|
|
678
|
+
// Don't trust CLOB level ordering — normalize: bids best(highest)-first,
|
|
679
|
+
// asks best(lowest)-first, so ladder-walking consumers can iterate in order.
|
|
680
|
+
const sortBids = (b: Array<{ price: number; size: number }>) => b.sort((x, y) => y.price - x.price);
|
|
681
|
+
const sortAsks = (a: Array<{ price: number; size: number }>) => a.sort((x, y) => x.price - y.price);
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
event_id: event.id,
|
|
685
|
+
event_slug: event.slug,
|
|
686
|
+
title: event.title,
|
|
687
|
+
leg_count: legs.length,
|
|
688
|
+
skipped_no_token: skippedNoToken,
|
|
689
|
+
truncated_legs: truncated,
|
|
690
|
+
legs: legs.map((l) => {
|
|
691
|
+
const yb = byAsset.get(l.yesToken);
|
|
692
|
+
const nb = l.noToken ? byAsset.get(l.noToken) : undefined;
|
|
693
|
+
return {
|
|
694
|
+
slug: l.market.slug,
|
|
695
|
+
question: l.market.question,
|
|
696
|
+
yes_price: l.yesPrice,
|
|
697
|
+
best_bid: l.market.bestBid ?? null,
|
|
698
|
+
best_ask: l.market.bestAsk ?? null,
|
|
699
|
+
yes_bids: sortBids(fmt(yb?.bids)),
|
|
700
|
+
yes_asks: sortAsks(fmt(yb?.asks)),
|
|
701
|
+
...(includeNo ? { no_bids: sortBids(fmt(nb?.bids)), no_asks: sortAsks(fmt(nb?.asks)) } : {}),
|
|
702
|
+
};
|
|
703
|
+
}),
|
|
704
|
+
builder_signed: creds !== null,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// data-api.polymarket.com — public, no auth; serves the trades tape and
|
|
709
|
+
// holder lists keyed by conditionId (0x…).
|
|
710
|
+
async function dataGet<T = unknown>(path: string, params: Record<string, string | number>): Promise<T> {
|
|
711
|
+
const url = new URL(DATA_API + path);
|
|
712
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
713
|
+
const res = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
|
|
714
|
+
if (!res.ok) {
|
|
715
|
+
const text = await res.text();
|
|
716
|
+
throw new Error(`Polymarket Data API: ${res.status} ${text.slice(0, 200)}`);
|
|
717
|
+
}
|
|
718
|
+
return res.json() as Promise<T>;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Map an outcome index (0/1/…) to its label via the market's outcomes array.
|
|
722
|
+
function outcomeLabel(market: RawMarket, idx: number | undefined): string | null {
|
|
723
|
+
if (idx === undefined || idx === null) return null;
|
|
724
|
+
const outcomes = parseJsonField<string[]>(market.outcomes);
|
|
725
|
+
return outcomes?.[idx] ?? null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function shortWallet(w?: string): string | null {
|
|
729
|
+
if (!w) return null;
|
|
730
|
+
return w.length > 12 ? `${w.slice(0, 6)}…${w.slice(-4)}` : w;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function polymarketTrades(args: Record<string, unknown>) {
|
|
734
|
+
const slugOrId = String(args.slug_or_id ?? '').trim();
|
|
735
|
+
if (!slugOrId) throw new Error('slug_or_id is required.');
|
|
736
|
+
const limit = Math.min(100, Math.max(1, Number(args.limit ?? 20)));
|
|
737
|
+
const market = await lookupMarket(slugOrId);
|
|
738
|
+
if (!market) return { error: 'not_found', message: `No market matching "${slugOrId}".` };
|
|
739
|
+
if (!market.conditionId) return { error: 'no_condition_id', message: 'Market has no conditionId — trades unavailable.' };
|
|
740
|
+
|
|
741
|
+
type RawTrade = {
|
|
742
|
+
proxyWallet?: string; name?: string; side?: string; size?: number; price?: number;
|
|
743
|
+
timestamp?: number; outcome?: string; outcomeIndex?: number;
|
|
744
|
+
};
|
|
745
|
+
const trades = await dataGet<RawTrade[]>('/trades', { market: market.conditionId, limit });
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
market_id: market.id,
|
|
749
|
+
market_slug: market.slug,
|
|
750
|
+
question: market.question,
|
|
751
|
+
trade_count: trades.length,
|
|
752
|
+
trades: trades.map((t) => ({
|
|
753
|
+
side: t.side ?? null,
|
|
754
|
+
outcome: t.outcome ?? outcomeLabel(market, t.outcomeIndex),
|
|
755
|
+
size: t.size ?? null,
|
|
756
|
+
price: t.price ?? null,
|
|
757
|
+
usd_value: t.size != null && t.price != null ? Math.round(t.size * t.price * 100) / 100 : null,
|
|
758
|
+
timestamp: t.timestamp ? new Date(t.timestamp * 1000).toISOString() : null,
|
|
759
|
+
trader: t.name || shortWallet(t.proxyWallet),
|
|
760
|
+
})),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function polymarketHolders(args: Record<string, unknown>) {
|
|
765
|
+
const slugOrId = String(args.slug_or_id ?? '').trim();
|
|
766
|
+
if (!slugOrId) throw new Error('slug_or_id is required.');
|
|
767
|
+
const limit = Math.min(100, Math.max(1, Number(args.limit ?? 10)));
|
|
768
|
+
const market = await lookupMarket(slugOrId);
|
|
769
|
+
if (!market) return { error: 'not_found', message: `No market matching "${slugOrId}".` };
|
|
770
|
+
if (!market.conditionId) return { error: 'no_condition_id', message: 'Market has no conditionId — holders unavailable.' };
|
|
771
|
+
|
|
772
|
+
type RawHolder = { proxyWallet?: string; pseudonym?: string; name?: string; amount?: number; outcomeIndex?: number };
|
|
773
|
+
type RawHolderToken = { token?: string; holders?: RawHolder[] };
|
|
774
|
+
const data = await dataGet<RawHolderToken[]>('/holders', { market: market.conditionId, limit });
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
market_id: market.id,
|
|
778
|
+
market_slug: market.slug,
|
|
779
|
+
question: market.question,
|
|
780
|
+
outcomes: (data ?? []).map((grp) => {
|
|
781
|
+
const idx = grp.holders?.[0]?.outcomeIndex;
|
|
782
|
+
return {
|
|
783
|
+
outcome: outcomeLabel(market, idx) ?? `token ${grp.token?.slice(0, 8)}…`,
|
|
784
|
+
token_id: grp.token ?? null,
|
|
785
|
+
top_holders: (grp.holders ?? []).slice(0, limit).map((h) => ({
|
|
786
|
+
trader: h.pseudonym || h.name || shortWallet(h.proxyWallet),
|
|
787
|
+
wallet: shortWallet(h.proxyWallet),
|
|
788
|
+
shares: h.amount ?? null,
|
|
789
|
+
})),
|
|
790
|
+
};
|
|
791
|
+
}),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
796
|
+
switch (name) {
|
|
797
|
+
case 'polymarket_search':
|
|
798
|
+
return polymarketSearch(args);
|
|
799
|
+
case 'polymarket_top_markets':
|
|
800
|
+
return polymarketTopMarkets(args);
|
|
801
|
+
case 'polymarket_market':
|
|
802
|
+
return polymarketMarket(args);
|
|
803
|
+
case 'polymarket_event':
|
|
804
|
+
return polymarketEvent(args);
|
|
805
|
+
case 'polymarket_price_history':
|
|
806
|
+
return polymarketPriceHistory(args);
|
|
807
|
+
case 'polymarket_orderbook':
|
|
808
|
+
return polymarketOrderbook(args);
|
|
809
|
+
case 'polymarket_event_books':
|
|
810
|
+
return polymarketEventBooks(args);
|
|
811
|
+
case 'polymarket_trades':
|
|
812
|
+
return polymarketTrades(args);
|
|
813
|
+
case 'polymarket_holders':
|
|
814
|
+
return polymarketHolders(args);
|
|
815
|
+
default:
|
|
816
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|