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