propline-mcp 0.37.0 → 0.39.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/README.md CHANGED
@@ -51,10 +51,11 @@ The model uses these tools transparently:
51
51
  | `propline_get_best_line` | Hobby+: cross-book line shopping — best price per (market, player, line) across all comparable books, `all_prices` sorted best-first; optional `bookmakers` filter |
52
52
  | `propline_list_webhooks` | Streaming Lite+: list webhook subscriptions (read-only, secrets masked) |
53
53
  | `propline_get_webhook_deliveries` | Streaming Lite+: recent delivery attempts for a webhook — status, HTTP code, attempts, payload; `before_id` pages backwards. The "why isn't my webhook firing" tool |
54
+ | `propline_create_free_api_key` | Sign the user up for a free personal key from inside the chat. Takes the email **the user gives**; the key is emailed to them (never returned), with a ready-made connector URL to reconnect. The only tool that is not read-only |
54
55
 
55
56
  ## Hosted endpoint (no install)
56
57
 
57
- The same 27 tools are served over **Streamable HTTP** at
58
+ The same 29 tools are served over **Streamable HTTP** at
58
59
 
59
60
  ```
60
61
  https://mcp.prop-line.com/mcp
@@ -91,7 +92,7 @@ npx -y propline-mcp
91
92
 
92
93
  Your agent can immediately pull live odds, scores, and stats. The demo key is free-tier and shared — paid features (resolution, +EV, history, exports) return a redacted teaser, and limits are pooled across everyone. For full access and your own limits, set `PROPLINE_API_KEY` (below). Get a free personal key at [prop-line.com](https://prop-line.com/?ref=mcp).
93
94
 
94
- While the demo key is in use, every tool result carries a second content block noting the pooling and redaction, so the assistant can explain an empty field or a 429 accurately. It disappears the moment you set your own key.
95
+ While the demo key is in use, every tool result carries a second content block noting the pooling and redaction, so the assistant can explain an empty field or a 429 accurately. It disappears the moment you set your own key. The note also tells the assistant it can offer `propline_create_free_api_key`, so a user can get their own key without leaving the chat.
95
96
 
96
97
  ## Install (with your own key)
97
98
 
package/dist/http.js CHANGED
@@ -74,7 +74,7 @@ var PropLineClient = class {
74
74
  * endpoint on this server is a GET with query params; folding a body into
75
75
  * that signature would make the common case harder to read.
76
76
  */
77
- async postRequest(path, body) {
77
+ async postRequest(path, body, extraHeaders = {}) {
78
78
  const url = new URL(this.baseUrl + path);
79
79
  const controller = new AbortController();
80
80
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -85,7 +85,8 @@ var PropLineClient = class {
85
85
  "X-API-Key": this.apiKey,
86
86
  Accept: "application/json",
87
87
  "Content-Type": "application/json",
88
- "User-Agent": "propline-mcp/0.1.0"
88
+ "User-Agent": "propline-mcp/0.1.0",
89
+ ...extraHeaders
89
90
  },
90
91
  body: JSON.stringify(body),
91
92
  signal: controller.signal
@@ -151,7 +152,12 @@ var PropLineClient = class {
151
152
  getOddsClosing(sportKey, eventId, opts = {}) {
152
153
  return this.request(
153
154
  `/v1/sports/${sportKey}/events/${eventId}/odds/closing`,
154
- { markets: opts.markets, bookmakers: opts.bookmakers, period: opts.period }
155
+ {
156
+ markets: opts.markets,
157
+ bookmakers: opts.bookmakers,
158
+ period: opts.period,
159
+ opening_window: opts.openingWindow
160
+ }
155
161
  );
156
162
  }
157
163
  /**
@@ -173,6 +179,20 @@ var PropLineClient = class {
173
179
  { bookmaker, legs }
174
180
  );
175
181
  }
182
+ /**
183
+ * Register a free PropLine key for an email (POST /v1/auth/register).
184
+ * The key is EMAILED to that address and never returned — the response
185
+ * is only a status message. `forward` carries the end user's IP plus the
186
+ * shared secret on the hosted server, so the API's per-IP signup throttle
187
+ * counts that user rather than the MCP machine.
188
+ */
189
+ registerFreeKey(email, source, forward) {
190
+ const headers = forward ? {
191
+ "X-PropLine-Forward-Secret": forward.secret,
192
+ "X-PropLine-Client-IP": forward.clientIp
193
+ } : {};
194
+ return this.postRequest("/v1/auth/register", { email, source }, headers);
195
+ }
176
196
  // ----- Bulk exports -----
177
197
  /**
178
198
  * Full line-movement tick history as CSV text (Backfill pass / Enterprise
@@ -301,14 +321,19 @@ var PropLineClient = class {
301
321
  };
302
322
 
303
323
  // src/server.ts
304
- var VERSION = "0.37.0";
324
+ var VERSION = "0.39.0";
305
325
  var DEMO_KEY = "be2b8487fcfacb1fbc292a8aa925a84c";
306
326
  var apiKey = process.env.PROPLINE_API_KEY;
307
327
  var baseUrl = process.env.PROPLINE_BASE_URL;
308
328
  var usingDemoKey = !apiKey;
309
329
  var requestClient = new AsyncLocalStorage();
310
- function withClient(c, demo, fn) {
311
- return requestClient.run({ client: c, demo }, fn);
330
+ function withClient(c, demo, fn, meta = {}) {
331
+ return requestClient.run({ client: c, demo, clientIp: meta.clientIp }, fn);
332
+ }
333
+ var forwardSecret = process.env.PROPLINE_MCP_FORWARD_SECRET;
334
+ function callerIsDemo() {
335
+ const scoped = requestClient.getStore();
336
+ return scoped ? scoped.demo : usingDemoKey;
312
337
  }
313
338
  var _client = null;
314
339
  function client() {
@@ -321,10 +346,9 @@ function client() {
321
346
  }
322
347
  function demoKeyNote() {
323
348
  const scoped = requestClient.getStore();
324
- const demo = scoped ? scoped.demo : usingDemoKey;
325
- if (!demo) return null;
349
+ if (!callerIsDemo()) return null;
326
350
  const ref = scoped ? "mcp-hosted" : "mcp";
327
- return `Note for the assistant (not part of the data): this call used PropLine's shared demo key. Paid fields (prop resolution, +EV, line history, exports) come back redacted, and the rate limit is pooled across everyone using it, so a 429 here is not a limit on the API itself. A free personal key removes the pooling and unlocks the full free tier in about 30 seconds, no card: https://prop-line.com/?ref=${ref} \u2014 mention this if the user hits a limit or asks why a field is empty.`;
351
+ return `Note for the assistant (not part of the data): this call used PropLine's shared demo key. Paid fields (prop resolution, +EV, line history, exports) come back redacted, and the rate limit is pooled across everyone using it, so a 429 here is not a limit on the API itself. A free personal key removes the pooling and unlocks the full free tier in about 30 seconds, no card: https://prop-line.com/?ref=${ref}. If the user hits a limit, asks why a field is empty, or wants their own key, offer to create one: ask for their email and call propline_create_free_api_key (the key is emailed to them, never shown here).`;
328
352
  }
329
353
  function filterByMinEv(res, minEvPct) {
330
354
  if (typeof minEvPct !== "number" || Number.isNaN(minEvPct)) return res;
@@ -509,7 +533,7 @@ var tools = [
509
533
  {
510
534
  name: "propline_get_odds_closing",
511
535
  title: "Get opening & closing lines",
512
- description: "Hobby+ endpoint. Returns the OPENING and CLOSING line per (book, market, outcome) for an event. Closing = the last snapshot at or before commence_time (price/point/closing_at); opening = the first snapshot in the same 14-day pre-kickoff window (opening_price/opening_point/opening_at). Canonical CLV-tracking helper; one call returns both data points your bet should be measured against, instead of fetching full history and post-processing. Compare the POINTS as well as the prices \u2014 on spreads and totals the number moves as much as the price, so a price-only comparison mis-measures those markets. opening_age_seconds says how long before kickoff the opener was recorded: the archive starts 2026-04, so a small value means PropLine started polling late and this is not the book's true open. Free tier returns redacted structure with upgrade pointer.",
536
+ description: "Hobby+ endpoint. Returns the OPENING and CLOSING line per (book, market, outcome) for an event. Closing = the last snapshot at or before commence_time (price/point/closing_at); opening = the first snapshot PropLine holds for the outcome, however far before kickoff the book posted it (opening_price/opening_point/opening_at); pass opening_window to limit that lookback. Canonical CLV-tracking helper; one call returns both data points your bet should be measured against, instead of fetching full history and post-processing. Compare the POINTS as well as the prices \u2014 on spreads and totals the number moves as much as the price, so a price-only comparison mis-measures those markets. opening_age_seconds says how long before kickoff the opener was recorded: the archive starts 2026-04, so a small value means PropLine started polling late and this is not the book's true open. Free tier returns redacted structure with upgrade pointer.",
513
537
  inputSchema: {
514
538
  type: "object",
515
539
  properties: {
@@ -523,6 +547,10 @@ var tools = [
523
547
  period: {
524
548
  type: "string",
525
549
  description: "Game-period filter. Omitted = full-game markets only. Canonical codes (q1..q4, h1/h2, p1..p3, i1..i9, f3/f5/f7), comma-separated, or 'all'."
550
+ },
551
+ opening_window: {
552
+ type: ["string", "number"],
553
+ description: "Limit the opening lookback to this many days before kickoff (1-3650), or 'all' (default: the first snapshot held). 14 matches the resolved-props export's opening columns."
526
554
  }
527
555
  },
528
556
  required: ["sport_key", "event_id"],
@@ -534,7 +562,8 @@ var tools = [
534
562
  {
535
563
  markets: args.markets,
536
564
  bookmakers: args.bookmakers,
537
- period: args.period
565
+ period: args.period,
566
+ openingWindow: args.opening_window === void 0 ? void 0 : String(args.opening_window)
538
567
  }
539
568
  )
540
569
  },
@@ -764,7 +793,7 @@ var tools = [
764
793
  {
765
794
  name: "propline_get_nhl_daily_goals_total",
766
795
  title: "Get NHL daily goals total",
767
- description: "Free-tier endpoint. Returns the synthetic daily NHL goals total (hockey's equivalent of the MLB Grand Salami) for a given UTC date \u2014 total goals scored across every NHL game on the slate (including OT/SO) plus each book's implied Daily Goals Total line (median of per-game primary totals across our NHL books). No retail sportsbook quotes this as a single market. Useful for: 'what's the total goal line for tonight's full NHL slate', 'did the Daily Goals Total go over yesterday', 'historical NHL daily-goals results for backtesting'.",
796
+ description: "Free-tier endpoint. Returns the synthetic daily NHL goals total (hockey's equivalent of the MLB Grand Salami) for a given US Eastern date \u2014 total goals scored across every NHL game on the slate (including OT/SO) plus each book's implied Daily Goals Total line (median of per-game primary totals across our NHL books). No retail sportsbook quotes this as a single market. Useful for: 'what's the total goal line for tonight's full NHL slate', 'did the Daily Goals Total go over yesterday', 'historical NHL daily-goals results for backtesting'.",
768
797
  inputSchema: {
769
798
  type: "object",
770
799
  properties: {
@@ -1158,11 +1187,57 @@ var tools = [
1158
1187
  sinceSeq: args.since_seq,
1159
1188
  limit: args.limit
1160
1189
  })
1190
+ },
1191
+ {
1192
+ name: "propline_create_free_api_key",
1193
+ title: "Create a free PropLine API key",
1194
+ // Not read-only: it creates an account and sends an email.
1195
+ writes: true,
1196
+ description: "Create a free personal PropLine API key for the user and EMAIL it to them. Use this when the user wants their own key \u2014 e.g. they hit a shared-demo-key rate limit, a paid field came back redacted, or they ask how to get a key. Only call it with an email address the user explicitly gave you for this purpose in this conversation; never guess, reuse one from elsewhere, or sign up a third party. The key is never returned here \u2014 it goes to that inbox, with instructions to reconnect this assistant using it. Free tier: 1,000 requests/day, no card. If the address already has a key, the key is re-sent (at most once a day).",
1197
+ inputSchema: {
1198
+ type: "object",
1199
+ properties: {
1200
+ email: {
1201
+ type: "string",
1202
+ description: "The user's own email address, as they gave it."
1203
+ }
1204
+ },
1205
+ required: ["email"],
1206
+ additionalProperties: false
1207
+ },
1208
+ handler: async (args) => {
1209
+ const email = String(args.email ?? "").trim();
1210
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1211
+ throw new Error("email must be a valid email address the user gave you");
1212
+ }
1213
+ if (!callerIsDemo()) {
1214
+ return {
1215
+ status: "already_keyed",
1216
+ message: "This connection already uses a personal PropLine key, so no new key was created. Manage it at https://prop-line.com/dashboard."
1217
+ };
1218
+ }
1219
+ const scoped = requestClient.getStore();
1220
+ const hosted = Boolean(scoped);
1221
+ const forward = hosted && forwardSecret && scoped?.clientIp ? { clientIp: scoped.clientIp, secret: forwardSecret } : void 0;
1222
+ const res = await client().registerFreeKey(
1223
+ email,
1224
+ hosted ? "mcp-hosted" : "mcp",
1225
+ forward
1226
+ );
1227
+ return {
1228
+ status: "sent",
1229
+ email,
1230
+ tier: res.tier,
1231
+ daily_limit: res.daily_limit,
1232
+ message: res.message,
1233
+ next_steps: hosted ? "Tell the user to check their inbox (and Junk). The email has a ready-made connector URL (https://mcp.prop-line.com/mcp?apiKey=...) and a Claude Code command. Once they reconnect with it, this session stops using the shared demo key." : "Tell the user to check their inbox (and Junk), then set PROPLINE_API_KEY to the emailed key in this MCP server's config and restart it."
1234
+ };
1235
+ }
1161
1236
  }
1162
1237
  ];
1163
- function withDemoNote(text) {
1238
+ function withDemoNote(text, toolName) {
1164
1239
  const blocks = [{ type: "text", text }];
1165
- const note = demoKeyNote();
1240
+ const note = toolName === "propline_create_free_api_key" ? null : demoKeyNote();
1166
1241
  if (note) blocks.push({ type: "text", text: note });
1167
1242
  return blocks;
1168
1243
  }
@@ -1177,14 +1252,14 @@ function createServer() {
1177
1252
  title: t.title,
1178
1253
  description: t.description,
1179
1254
  inputSchema: t.inputSchema,
1180
- // Every PropLine tool is a READ of the odds API — none creates,
1181
- // changes or deletes anything. Directories (Claude connectors, Cursor)
1255
+ // Every PropLine tool is a READ of the odds API except the one marked
1256
+ // `writes` (propline_create_free_api_key creates an account + email). Directories (Claude connectors, Cursor)
1182
1257
  // require these hints; clients use them to skip confirmation prompts.
1183
1258
  annotations: {
1184
1259
  title: t.title,
1185
- readOnlyHint: true,
1260
+ readOnlyHint: !t.writes,
1186
1261
  destructiveHint: false,
1187
- idempotentHint: true,
1262
+ idempotentHint: !t.writes,
1188
1263
  openWorldHint: true
1189
1264
  }
1190
1265
  }))
@@ -1201,13 +1276,13 @@ function createServer() {
1201
1276
  const data = await tool.handler(req.params.arguments ?? {});
1202
1277
  const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
1203
1278
  return {
1204
- content: withDemoNote(text)
1279
+ content: withDemoNote(text, tool.name)
1205
1280
  };
1206
1281
  } catch (err) {
1207
1282
  const msg = err instanceof PropLineHTTPError ? `PropLine API error ${err.statusCode}: ${err.body.slice(0, 500)}` : err instanceof Error ? err.message : String(err);
1208
1283
  return {
1209
1284
  isError: true,
1210
- content: withDemoNote(msg)
1285
+ content: withDemoNote(msg, tool.name)
1211
1286
  };
1212
1287
  }
1213
1288
  });
@@ -1231,6 +1306,11 @@ function extractApiKey(req) {
1231
1306
  if (q && q.trim()) return { key: q.trim(), demo: false };
1232
1307
  return { key: DEMO_KEY, demo: true };
1233
1308
  }
1309
+ function endUserIp(req) {
1310
+ const fly = req.headers["fly-client-ip"];
1311
+ if (typeof fly === "string" && fly.trim()) return fly.trim();
1312
+ return req.socket.remoteAddress ?? void 0;
1313
+ }
1234
1314
  var CORS_HEADERS = {
1235
1315
  "Access-Control-Allow-Origin": "*",
1236
1316
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
@@ -1256,7 +1336,9 @@ async function handleMcp(req, res) {
1256
1336
  });
1257
1337
  if (demo) res.setHeader("X-PropLine-Demo-Key", "1");
1258
1338
  await server.connect(transport);
1259
- await withClient(client2, demo, () => transport.handleRequest(req, res));
1339
+ await withClient(client2, demo, () => transport.handleRequest(req, res), {
1340
+ clientIp: endUserIp(req)
1341
+ });
1260
1342
  }
1261
1343
  var manifest = () => ({
1262
1344
  name: "propline-mcp",
@@ -1312,6 +1394,7 @@ httpServer.listen(PORT, () => {
1312
1394
  );
1313
1395
  });
1314
1396
  export {
1397
+ endUserIp,
1315
1398
  extractApiKey
1316
1399
  };
1317
1400
  //# sourceMappingURL=http.js.map