@claudinho/mcp 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +16 -5
  2. package/dist/index.js +615 -20
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @claudinho/mcp ⚽
2
2
 
3
3
  **An MCP server for the 2026 men's football tournament.** Ask your agent about
4
- live scores, fixtures, and group standings — in Claude Code, Cursor, Codex, and
5
- any other MCP client.
4
+ live scores, fixtures, group standings, and prediction-market odds — in Claude
5
+ Code, Cursor, Codex, and any other MCP client.
6
6
 
7
7
  > ⚠️ **Not affiliated with, endorsed by, or connected to FIFA or Anthropic.**
8
8
  > Claudinho is an independent, open-source fan project. Factual match data with
@@ -48,12 +48,22 @@ any other MCP client with no changes.
48
48
  | `get_match` | a single match by id |
49
49
  | `get_standings` | group table(s) — one group `A`–`L`, or all |
50
50
  | `get_next_fixture` | a team's next match (3-letter code, e.g. `MEX`) |
51
+ | `get_market_signal` | read-only prediction-market odds for a match, a team's next fixture, or a date — informational only |
51
52
 
52
53
  All tools are **read-only** (annotated `readOnlyHint`) and accept optional
53
54
  `tz` (IANA timezone), `lang` (`en`/`es`/`pt`/`fr`), and `flavor`
54
55
  (`off`/`subtle`/`full`) arguments. Each response includes both human-readable
55
56
  text and structured JSON.
56
57
 
58
+ `get_today` and `get_match` also include reliable prediction-market context when a
59
+ market is available. Match→market event slugs are derived automatically, so no
60
+ mapping is needed. Market data is **read-only and informational only — not betting
61
+ advice** (market-implied percentages with attribution, never links or trade
62
+ calls), sourced from Polymarket public data and shown only when a market maps
63
+ cleanly to the result. Disable it with `CLAUDINHO_MARKETS=off`; set
64
+ `CLAUDINHO_MARKETS_SOURCE=fake` (in the server `env`) for a network-free synthetic
65
+ preview.
66
+
57
67
  ## Resources & prompts
58
68
 
59
69
  - Resources: `standings://{group}` (e.g. `standings://A`), `fixtures://{date}` (UTC date, e.g. `fixtures://2026-06-11`)
@@ -87,9 +97,10 @@ to the `env` block of your MCP server entry.
87
97
 
88
98
  ## How it works
89
99
 
90
- The fixture list ships bundled in the package; only live state hits the network
91
- (via a swappable data provider). The server writes nothing to stdout
92
- except the MCP protocol; diagnostics go to stderr.
100
+ The fixture list ships bundled in the package; only live state hits the network
101
+ live scores from **ESPN's** public scoreboard (a swappable provider, attributed
102
+ in the `source` field and text) and market odds from Polymarket. The server writes
103
+ nothing to stdout except the MCP protocol; diagnostics go to stderr.
93
104
 
94
105
  ## License
95
106
 
package/dist/index.js CHANGED
@@ -2831,12 +2831,16 @@ function mergeLive(base, live) {
2831
2831
  for (const m of live) byId.set(m.id, m);
2832
2832
  return [...byId.values()];
2833
2833
  }
2834
+ function liveSourceLabel(source) {
2835
+ const known = { espn: "ESPN" };
2836
+ return known[source] ?? source.charAt(0).toUpperCase() + source.slice(1);
2837
+ }
2834
2838
  async function getMatchesForDate(adapter, dateISO) {
2835
2839
  const base = allFixtures();
2836
2840
  const day = dateISO.slice(0, 10);
2837
2841
  try {
2838
2842
  const live = adapter.fetchWindow ? await adapter.fetchWindow(shiftUtcDate(day, -1), shiftUtcDate(day, 1)) : await adapter.fetchByDate(day);
2839
- return { matches: mergeLive(base, live), degraded: false };
2843
+ return { matches: mergeLive(base, live), degraded: false, source: adapter.name };
2840
2844
  } catch {
2841
2845
  return { matches: base, degraded: true };
2842
2846
  }
@@ -2847,11 +2851,427 @@ function shiftUtcDate(dateISO, days) {
2847
2851
  }
2848
2852
  async function getLiveMatches(adapter) {
2849
2853
  try {
2850
- return { matches: await adapter.fetchLive(), degraded: false };
2854
+ return { matches: await adapter.fetchLive(), degraded: false, source: adapter.name };
2851
2855
  } catch {
2852
2856
  return { matches: [], degraded: true };
2853
2857
  }
2854
2858
  }
2859
+ var DEFAULT_MAX_AGE_MS = 15 * 6e4;
2860
+ function normalizeOutcomes(outcomes) {
2861
+ const sum = outcomes.reduce(
2862
+ (s, o) => s + (Number.isFinite(o.probability) && o.probability > 0 ? o.probability : 0),
2863
+ 0
2864
+ );
2865
+ if (sum <= 0) return outcomes.map((o) => ({ ...o, probability: 0 }));
2866
+ return outcomes.map((o) => ({
2867
+ ...o,
2868
+ probability: Number.isFinite(o.probability) && o.probability > 0 ? o.probability / sum : 0
2869
+ }));
2870
+ }
2871
+ function favoriteStrength(probability) {
2872
+ if (probability >= 0.65) return "clear";
2873
+ if (probability >= 0.52) return "slight";
2874
+ return "close";
2875
+ }
2876
+ function deriveFavorite(outcomes) {
2877
+ let top;
2878
+ for (const o of outcomes) {
2879
+ if (o.kind === "other") continue;
2880
+ if (!top || o.probability > top.probability) top = o;
2881
+ }
2882
+ if (!top || top.probability <= 0 || top.kind === "other") return void 0;
2883
+ return {
2884
+ kind: top.kind,
2885
+ teamCode: top.teamCode,
2886
+ probability: top.probability,
2887
+ strength: favoriteStrength(top.probability)
2888
+ };
2889
+ }
2890
+ function mapsCleanly(match, outcomes) {
2891
+ if (outcomes.some((o) => o.kind === "other")) return false;
2892
+ const home = outcomes.find((o) => o.kind === "home");
2893
+ const away = outcomes.find((o) => o.kind === "away");
2894
+ const draw = outcomes.find((o) => o.kind === "draw");
2895
+ if (!home || !away) return false;
2896
+ if (home.teamCode && home.teamCode.toUpperCase() !== match.home.code.toUpperCase()) {
2897
+ return false;
2898
+ }
2899
+ if (away.teamCode && away.teamCode.toUpperCase() !== match.away.code.toUpperCase()) {
2900
+ return false;
2901
+ }
2902
+ if (match.stage === "GROUP" && !draw) return false;
2903
+ return true;
2904
+ }
2905
+ function hasSaneDistribution(outcomes) {
2906
+ const priced = outcomes.filter((o) => Number.isFinite(o.probability) && o.probability > 0);
2907
+ if (priced.length < 2) return false;
2908
+ const sum = priced.reduce((s, o) => s + o.probability, 0);
2909
+ return sum > 0.97 && sum < 1.03;
2910
+ }
2911
+ function isStaleSignal(signal, options = {}) {
2912
+ const maxAge = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2913
+ const asOf = Date.parse(signal.asOf);
2914
+ if (!Number.isFinite(asOf)) return true;
2915
+ const now = (options.now ?? /* @__PURE__ */ new Date()).getTime();
2916
+ return now - asOf > maxAge;
2917
+ }
2918
+ function isReliableMarketSignal(signal, options = {}) {
2919
+ if (options.includeUnreliable) return true;
2920
+ if (signal.ambiguous) return false;
2921
+ if (!signal.favorite) return false;
2922
+ if (!hasSaneDistribution(signal.outcomes)) return false;
2923
+ if (signal.stale || isStaleSignal(signal, options)) return false;
2924
+ if (options.minLiquidity != null) {
2925
+ if (signal.liquidity == null || signal.liquidity < options.minLiquidity) return false;
2926
+ }
2927
+ return true;
2928
+ }
2929
+ function buildMarketSignal(input) {
2930
+ const outcomes = normalizeOutcomes(input.outcomes);
2931
+ const ambiguous = input.ambiguous === true || !mapsCleanly(input.match, outcomes);
2932
+ const favorite = ambiguous ? void 0 : deriveFavorite(outcomes);
2933
+ const signal = {
2934
+ matchId: input.match.id,
2935
+ source: input.source,
2936
+ sourceMarketId: input.sourceMarketId,
2937
+ asOf: input.asOf,
2938
+ fetchedAt: input.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2939
+ outcomes,
2940
+ favorite,
2941
+ liquidity: input.liquidity,
2942
+ volume24h: input.volume24h,
2943
+ stale: false,
2944
+ ambiguous
2945
+ };
2946
+ signal.stale = isStaleSignal(signal, { now: input.now, maxAgeMs: input.maxAgeMs });
2947
+ return signal;
2948
+ }
2949
+ function pct(p) {
2950
+ return Math.round(p * 100);
2951
+ }
2952
+ function marketSourceLabel(source) {
2953
+ if (source === "polymarket") return "Polymarket";
2954
+ if (source === "fake") return "demo data";
2955
+ return source.charAt(0).toUpperCase() + source.slice(1);
2956
+ }
2957
+ function outcomeLabel(o, match) {
2958
+ if (o.kind === "home") return match.home.name;
2959
+ if (o.kind === "away") return match.away.name;
2960
+ if (o.kind === "draw") return "Draw";
2961
+ return o.label;
2962
+ }
2963
+ function utcHhmm(iso) {
2964
+ const t = Date.parse(iso);
2965
+ if (!Number.isFinite(t)) return "";
2966
+ return `${new Date(t).toISOString().slice(11, 16)} UTC`;
2967
+ }
2968
+ function marketFavoriteText(signal, match) {
2969
+ const fav = signal.favorite;
2970
+ if (!fav || fav.strength === "close") return "Prediction markets see this match as close.";
2971
+ if (fav.kind === "draw") return "Prediction markets see a draw as the top outcome.";
2972
+ const name = fav.kind === "home" ? match.home.name : match.away.name;
2973
+ return fav.strength === "clear" ? `Prediction markets favor ${name}.` : `Prediction markets slightly favor ${name}.`;
2974
+ }
2975
+ function marketProbabilityText(signal, match) {
2976
+ const order = ["home", "draw", "away"];
2977
+ const parts = [];
2978
+ for (const kind of order) {
2979
+ const o = signal.outcomes.find((x) => x.kind === kind);
2980
+ if (o) parts.push(`${outcomeLabel(o, match)} ${pct(o.probability)}%`);
2981
+ }
2982
+ for (const o of signal.outcomes) {
2983
+ if (o.kind === "other") parts.push(`${outcomeLabel(o, match)} ${pct(o.probability)}%`);
2984
+ }
2985
+ return parts.join(" \xB7 ");
2986
+ }
2987
+ function marketAttributionText(signal) {
2988
+ const time = utcHhmm(signal.asOf);
2989
+ const src = `Source: ${marketSourceLabel(signal.source)}`;
2990
+ return time ? `${src} \xB7 updated ${time}` : src;
2991
+ }
2992
+ function marketBlock(signal, match) {
2993
+ const lines = [];
2994
+ if (signal.stale) lines.push("Market signal is stale; the reading may be out of date.");
2995
+ lines.push(marketFavoriteText(signal, match));
2996
+ lines.push(marketProbabilityText(signal, match));
2997
+ lines.push(`${marketAttributionText(signal)} \xB7 informational only`);
2998
+ return lines;
2999
+ }
3000
+ var FakeMarketProvider = class {
3001
+ constructor(opts = {}) {
3002
+ this.opts = opts;
3003
+ }
3004
+ opts;
3005
+ name = "fake";
3006
+ async findSignal(match, options) {
3007
+ const preset = this.opts.signals?.[match.id];
3008
+ if (preset) return preset;
3009
+ if (this.opts.synthesize) return this.synthesize(match, options);
3010
+ return void 0;
3011
+ }
3012
+ async findSignals(matches, options) {
3013
+ const signals = /* @__PURE__ */ new Map();
3014
+ const checked = /* @__PURE__ */ new Set();
3015
+ for (const m of matches) {
3016
+ checked.add(m.id);
3017
+ const s = await this.findSignal(m, options);
3018
+ if (s) signals.set(m.id, s);
3019
+ }
3020
+ return { signals, checked };
3021
+ }
3022
+ synthesize(match, options) {
3023
+ const seed = hash(`${match.home.code}-${match.away.code}`);
3024
+ const home = 0.3 + seed % 33 / 100;
3025
+ const away = 0.18 + (seed >> 3) % 23 / 100;
3026
+ const draw = Math.max(0.05, 1 - home - away);
3027
+ const outcomes = [
3028
+ { kind: "home", teamCode: match.home.code, label: match.home.name, probability: home },
3029
+ { kind: "draw", label: "Draw", probability: draw },
3030
+ { kind: "away", teamCode: match.away.code, label: match.away.name, probability: away }
3031
+ ];
3032
+ const now = this.opts.now ?? options?.now ?? /* @__PURE__ */ new Date();
3033
+ const asOf = new Date(now.getTime() - 6e4).toISOString();
3034
+ return buildMarketSignal({
3035
+ match,
3036
+ source: "fake",
3037
+ sourceMarketId: `fake-${match.id}`,
3038
+ asOf,
3039
+ fetchedAt: now.toISOString(),
3040
+ outcomes,
3041
+ liquidity: 5e4,
3042
+ now,
3043
+ maxAgeMs: options?.maxAgeMs
3044
+ });
3045
+ }
3046
+ };
3047
+ function hash(s) {
3048
+ let h = 0;
3049
+ for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) >>> 0;
3050
+ return h % 1e5;
3051
+ }
3052
+ var mapping_2026_default = {
3053
+ version: 1,
3054
+ note: "Optional matchId -> Polymarket EVENT-slug OVERRIDES. By default the slug is DERIVED from each fixture (fifwc-{home}-{away}-{UTC-date}, e.g. fifwc-mex-rsa-2026-06-11), so most matches need NO entry here. Add an entry only for a fixture whose real Polymarket slug differs (e.g. a team abbreviation that isn't the FIFA code, or a tz-shifted date). The event payload carries the three moneyline binary markets; each outcome's probability is its 'Yes' price; validation fails closed. Entry: { eventSlug, eventId? }.",
3055
+ markets: {}
3056
+ };
3057
+ var DEFAULT_BASE2 = "https://gamma-api.polymarket.com";
3058
+ var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["gamma-api.polymarket.com"]);
3059
+ var USER_AGENT2 = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
3060
+ var DEFAULT_TIMEOUT_MS = 8e3;
3061
+ var WC_SERIES_SLUG = "soccer-fifwc";
3062
+ var WC_SPORT = "fifwc";
3063
+ var KICKOFF_TOLERANCE_MS = 6 * 60 * 6e4;
3064
+ var NON_REGULAR_TIME = /extra time|penalt|to advance|to qualif|win the (group|tournament|cup|title)/i;
3065
+ var BUNDLED_MAPPING = mapping_2026_default.markets;
3066
+ var PolymarketProvider = class {
3067
+ constructor(opts = {}) {
3068
+ this.opts = opts;
3069
+ }
3070
+ opts;
3071
+ name = "polymarket";
3072
+ async findSignal(match, options) {
3073
+ return (await this.resolveOne(match, options)).signal;
3074
+ }
3075
+ async findSignals(matches, options) {
3076
+ const signals = /* @__PURE__ */ new Map();
3077
+ const checked = /* @__PURE__ */ new Set();
3078
+ const deadline = options?.deadlineMs != null ? Date.now() + options.deadlineMs : Number.POSITIVE_INFINITY;
3079
+ for (const m of matches) {
3080
+ if (Date.now() >= deadline) break;
3081
+ const r = await this.resolveOne(m, options);
3082
+ if (r.checked) checked.add(m.id);
3083
+ if (r.signal) signals.set(m.id, r.signal);
3084
+ }
3085
+ return { signals, checked };
3086
+ }
3087
+ /**
3088
+ * Resolve one match. `checked` distinguishes a DEFINITIVE result (reached the
3089
+ * source and found no usable market, or the fixture is unmappable) from a
3090
+ * provider/network error — so transient failures are retried, not
3091
+ * negative-cached.
3092
+ */
3093
+ async resolveOne(match, options) {
3094
+ const entry = (this.opts.mapping ?? BUNDLED_MAPPING)[match.id];
3095
+ const eventSlug = entry?.eventSlug ?? deriveEventSlug(match);
3096
+ if (!eventSlug) return { checked: true };
3097
+ try {
3098
+ const event = await this.fetchEvent(eventSlug, options?.timeoutMs);
3099
+ const signal = event ? this.toSignal(match, eventSlug, event, options) : void 0;
3100
+ return { signal, checked: true };
3101
+ } catch {
3102
+ return { checked: false };
3103
+ }
3104
+ }
3105
+ async fetchEvent(slug, timeoutMs) {
3106
+ const base = this.opts.baseUrl ?? DEFAULT_BASE2;
3107
+ assertAllowedHost(base);
3108
+ const url = `${base}/events?slug=${encodeURIComponent(slug)}`;
3109
+ const doFetch = this.opts.fetchImpl ?? fetch;
3110
+ const res = await doFetch(url, {
3111
+ signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
3112
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT2 }
3113
+ });
3114
+ if (res.status === 404) return void 0;
3115
+ if (!res.ok) {
3116
+ throw new Error(`Polymarket request failed: ${res.status} ${res.statusText}`);
3117
+ }
3118
+ const data = await res.json();
3119
+ const event = Array.isArray(data) ? data[0] : data;
3120
+ return event && typeof event === "object" ? event : void 0;
3121
+ }
3122
+ toSignal(match, eventSlug, event, options) {
3123
+ if (event.active === false || event.closed === true) return void 0;
3124
+ if (event.seriesSlug != null && event.seriesSlug !== WC_SERIES_SLUG && event.sport?.sport !== WC_SPORT) {
3125
+ return void 0;
3126
+ }
3127
+ if (event.slug != null && event.slug !== eventSlug) return void 0;
3128
+ const start = event.startTime ? Date.parse(event.startTime) : Number.NaN;
3129
+ const kick = Date.parse(match.kickoff);
3130
+ if (Number.isFinite(start) && Number.isFinite(kick) && Math.abs(start - kick) > KICKOFF_TOLERANCE_MS) {
3131
+ return void 0;
3132
+ }
3133
+ const moneyline = (event.markets ?? []).filter(
3134
+ (m) => (m.sportsMarketType ?? "moneyline") === "moneyline"
3135
+ );
3136
+ const homeMarket = pickMarket(moneyline, match.home.code, match.home.name);
3137
+ const awayMarket = pickMarket(moneyline, match.away.code, match.away.name);
3138
+ const drawMarket = pickDraw(moneyline);
3139
+ if (!homeMarket || !awayMarket) return void 0;
3140
+ const legIds = [homeMarket, awayMarket, drawMarket].filter((m) => m != null).map((m) => m.id ?? m.slug ?? "");
3141
+ if (new Set(legIds).size !== legIds.length) return void 0;
3142
+ const legs = [
3143
+ ["home", homeMarket, match.home.code, match.home.name],
3144
+ ["draw", drawMarket, void 0, "Draw"],
3145
+ ["away", awayMarket, match.away.code, match.away.name]
3146
+ ];
3147
+ const outcomes = [];
3148
+ let asOf = event.updatedAt;
3149
+ let liquidity;
3150
+ for (const [kind, market, teamCode, label] of legs) {
3151
+ if (!market) continue;
3152
+ if (market.closed === true || market.active === false) return void 0;
3153
+ if (market.description && NON_REGULAR_TIME.test(market.description)) return void 0;
3154
+ const yes = yesPrice(market);
3155
+ if (yes == null) return void 0;
3156
+ outcomes.push({ kind, teamCode, label, probability: yes });
3157
+ if (market.updatedAt && (!asOf || market.updatedAt < asOf)) asOf = market.updatedAt;
3158
+ const liq = numberish(market.liquidityNum ?? market.liquidity);
3159
+ if (liq != null) liquidity = liquidity == null ? liq : Math.min(liquidity, liq);
3160
+ }
3161
+ const rawSum = outcomes.reduce((s, o) => s + o.probability, 0);
3162
+ if (rawSum < 0.9 || rawSum > 1.15) return void 0;
3163
+ const signal = buildMarketSignal({
3164
+ match,
3165
+ source: "polymarket",
3166
+ sourceMarketId: event.id ?? eventSlug,
3167
+ asOf: asOf ?? (/* @__PURE__ */ new Date()).toISOString(),
3168
+ outcomes,
3169
+ liquidity,
3170
+ now: options?.now ?? this.opts.now,
3171
+ maxAgeMs: options?.maxAgeMs ?? this.opts.maxAgeMs
3172
+ });
3173
+ return signal.ambiguous ? void 0 : signal;
3174
+ }
3175
+ };
3176
+ function deriveEventSlug(match) {
3177
+ const home = match.home.code.toLowerCase();
3178
+ const away = match.away.code.toLowerCase();
3179
+ if (home === away || home === "tbd" || away === "tbd") return void 0;
3180
+ if (!/^[a-z]{3}$/.test(home) || !/^[a-z]{3}$/.test(away)) return void 0;
3181
+ const date = match.kickoff.slice(0, 10);
3182
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return void 0;
3183
+ return `fifwc-${home}-${away}-${date}`;
3184
+ }
3185
+ function slugToken(m) {
3186
+ return (m.slug ?? "").toLowerCase().split("-").pop() ?? "";
3187
+ }
3188
+ function isDrawMarket(m) {
3189
+ return slugToken(m) === "draw" || (m.groupItemTitle ?? "").trim().toLowerCase().startsWith("draw");
3190
+ }
3191
+ function pickMarket(markets, teamCode, teamName) {
3192
+ const code = teamCode.toLowerCase();
3193
+ const name = teamName.trim().toLowerCase();
3194
+ const teamMarkets = markets.filter((m) => !isDrawMarket(m));
3195
+ const bySlug = teamMarkets.find((m) => slugToken(m) === code);
3196
+ if (bySlug) return bySlug;
3197
+ return teamMarkets.find((m) => (m.groupItemTitle ?? "").trim().toLowerCase() === name);
3198
+ }
3199
+ function pickDraw(markets) {
3200
+ return markets.find(isDrawMarket);
3201
+ }
3202
+ function assertAllowedHost(base) {
3203
+ let host;
3204
+ try {
3205
+ host = new URL(base).host;
3206
+ } catch {
3207
+ throw new Error(`Invalid Polymarket base URL: ${base}`);
3208
+ }
3209
+ if (!ALLOWED_HOSTS.has(host)) {
3210
+ throw new Error(`Polymarket host not allow-listed: ${host}`);
3211
+ }
3212
+ }
3213
+ function yesPrice(market) {
3214
+ const labels = parseJsonArray(market.outcomes);
3215
+ const prices = parseJsonArray(market.outcomePrices).map((p2) => Number(p2));
3216
+ if (labels.length === 0 || labels.length !== prices.length) return void 0;
3217
+ const i = labels.findIndex((l) => l.trim().toLowerCase() === "yes");
3218
+ if (i < 0) return void 0;
3219
+ const p = prices[i];
3220
+ return typeof p === "number" && Number.isFinite(p) && p > 0 && p <= 1 ? p : void 0;
3221
+ }
3222
+ function parseJsonArray(v) {
3223
+ if (Array.isArray(v)) return v.map((x) => String(x));
3224
+ if (typeof v === "string") {
3225
+ try {
3226
+ const parsed = JSON.parse(v);
3227
+ return Array.isArray(parsed) ? parsed.map((x) => String(x)) : [];
3228
+ } catch {
3229
+ return [];
3230
+ }
3231
+ }
3232
+ return [];
3233
+ }
3234
+ function numberish(v) {
3235
+ if (typeof v === "number") return Number.isFinite(v) ? v : void 0;
3236
+ if (typeof v === "string") {
3237
+ const n = Number(v);
3238
+ return Number.isFinite(n) ? n : void 0;
3239
+ }
3240
+ return void 0;
3241
+ }
3242
+ function resolveMarketSource(explicit) {
3243
+ if (explicit) return explicit;
3244
+ if (typeof process !== "undefined" && process.env?.CLAUDINHO_MARKETS_SOURCE) {
3245
+ return process.env.CLAUDINHO_MARKETS_SOURCE;
3246
+ }
3247
+ return "polymarket";
3248
+ }
3249
+ function makeMarketProvider(source) {
3250
+ switch (resolveMarketSource(source)) {
3251
+ case "fake":
3252
+ return new FakeMarketProvider({ synthesize: true });
3253
+ case "none":
3254
+ case "off":
3255
+ return new FakeMarketProvider();
3256
+ // no synth → yields no signals, no network
3257
+ default:
3258
+ return new PolymarketProvider();
3259
+ }
3260
+ }
3261
+ async function getMarketSignal(provider, match, options) {
3262
+ try {
3263
+ return await provider.findSignal(match, options);
3264
+ } catch {
3265
+ return void 0;
3266
+ }
3267
+ }
3268
+ async function getMarketSignals(provider, matches, options) {
3269
+ try {
3270
+ return await provider.findSignals(matches, options);
3271
+ } catch {
3272
+ return { signals: /* @__PURE__ */ new Map(), checked: /* @__PURE__ */ new Set() };
3273
+ }
3274
+ }
2855
3275
 
2856
3276
  // src/format.ts
2857
3277
  var STATUS_LABEL = {
@@ -2920,6 +3340,88 @@ var DISCLAIMER = "Claudinho is an independent fan project \u2014 not affiliated
2920
3340
  function resolveAdapter(args) {
2921
3341
  return args.adapter ?? makeAdapter(args.source);
2922
3342
  }
3343
+ function resolveMarketProvider(args) {
3344
+ return args.marketProvider ?? makeMarketProvider();
3345
+ }
3346
+ function marketDisplayable(sig) {
3347
+ return !sig.ambiguous && sig.favorite != null && hasSaneDistribution(sig.outcomes);
3348
+ }
3349
+ function marketHeader(m) {
3350
+ return `${m.home.flag} ${m.home.name} vs ${m.away.name} ${m.away.flag}`;
3351
+ }
3352
+ function marketText(m, sig) {
3353
+ return `${marketHeader(m)}
3354
+ ${marketBlock(sig, m).join("\n")}`;
3355
+ }
3356
+ function marketData(sig) {
3357
+ return {
3358
+ matchId: sig.matchId,
3359
+ source: sig.source,
3360
+ asOf: sig.asOf,
3361
+ fetchedAt: sig.fetchedAt,
3362
+ market: { id: sig.sourceMarketId ?? null, url: null },
3363
+ outcomes: sig.outcomes,
3364
+ favorite: sig.favorite ?? null,
3365
+ liquidity: sig.liquidity ?? null,
3366
+ stale: sig.stale,
3367
+ ambiguous: sig.ambiguous,
3368
+ informationalOnly: true
3369
+ };
3370
+ }
3371
+ function marketsEnabled() {
3372
+ return (process.env.CLAUDINHO_MARKETS ?? "").toLowerCase() !== "off";
3373
+ }
3374
+ var marketMem = /* @__PURE__ */ new Map();
3375
+ var MEM_POSITIVE_TTL = 10 * 6e4;
3376
+ var MEM_NEGATIVE_TTL = 3 * 6e4;
3377
+ var DEFAULT_ON_MARKET_OPTS = { deadlineMs: 2e3, timeoutMs: 2500 };
3378
+ var MARKETS_TOOL_OPTS = { deadlineMs: 12e3, timeoutMs: 6e3 };
3379
+ function memKey(competition, id) {
3380
+ return `polymarket:${competition}:${id}`;
3381
+ }
3382
+ async function cachedMarketSignals(args, matches) {
3383
+ if (args.marketProvider) return (await getMarketSignals(args.marketProvider, matches)).signals;
3384
+ const source = resolveMarketSource();
3385
+ if (source !== "polymarket") {
3386
+ return (await getMarketSignals(makeMarketProvider(source), matches, DEFAULT_ON_MARKET_OPTS)).signals;
3387
+ }
3388
+ const competition = resolveCompetition();
3389
+ const now = Date.now();
3390
+ const result = /* @__PURE__ */ new Map();
3391
+ const miss = [];
3392
+ for (const m of matches) {
3393
+ const e = marketMem.get(memKey(competition, m.id));
3394
+ const ttl = e?.signal ? MEM_POSITIVE_TTL : MEM_NEGATIVE_TTL;
3395
+ if (e && now - e.at <= ttl) {
3396
+ if (e.signal) result.set(m.id, e.signal);
3397
+ } else {
3398
+ miss.push(m);
3399
+ }
3400
+ }
3401
+ if (miss.length > 0) {
3402
+ const { signals: fetched, checked } = await getMarketSignals(
3403
+ makeMarketProvider("polymarket"),
3404
+ miss,
3405
+ DEFAULT_ON_MARKET_OPTS
3406
+ );
3407
+ for (const id of checked) {
3408
+ marketMem.set(memKey(competition, id), { at: now, signal: fetched.get(id) ?? null });
3409
+ }
3410
+ for (const [id, s] of fetched) result.set(id, s);
3411
+ }
3412
+ return result;
3413
+ }
3414
+ async function reliableMarketData(args, matches) {
3415
+ if (!marketsEnabled()) return void 0;
3416
+ const signals = await cachedMarketSignals(args, matches);
3417
+ const now = /* @__PURE__ */ new Date();
3418
+ const out = {};
3419
+ for (const m of matches) {
3420
+ const s = signals.get(m.id);
3421
+ if (s && isReliableMarketSignal(s, { now })) out[m.id] = marketData(s);
3422
+ }
3423
+ return Object.keys(out).length > 0 ? out : void 0;
3424
+ }
2923
3425
  function fmtOpts(args) {
2924
3426
  return {
2925
3427
  tz: args.tz,
@@ -2927,43 +3429,55 @@ function fmtOpts(args) {
2927
3429
  flavor: asFlavorLevel(args.flavor ?? process.env.CLAUDINHO_FLAVOR)
2928
3430
  };
2929
3431
  }
2930
- function withDisclaimer(text) {
2931
- return `${text}
3432
+ function withDisclaimer(text, source) {
3433
+ const live = source ? `
3434
+ Live data: ${liveSourceLabel(source)}` : "";
3435
+ return `${text}${live}
2932
3436
 
2933
3437
  ${DISCLAIMER}`;
2934
3438
  }
2935
3439
  async function toolGetToday(args) {
2936
3440
  const adapter = resolveAdapter(args);
2937
3441
  const date = args.date ?? localDate((/* @__PURE__ */ new Date()).toISOString(), args.tz);
2938
- const { matches, degraded } = await getMatchesForDate(adapter, date);
3442
+ const { matches, degraded, source } = await getMatchesForDate(adapter, date);
2939
3443
  const todays = fixturesByDate(date, matches, args.tz);
2940
3444
  const opts = fmtOpts(args);
2941
3445
  const text = `Matches on ${date}:
2942
3446
  ${matchList(todays, "No matches scheduled.", opts)}`;
3447
+ const marketSignals = await reliableMarketData(args, todays);
2943
3448
  return {
2944
- text: withDisclaimer(text),
2945
- data: { date, degraded, count: todays.length, matches: todays }
3449
+ text: withDisclaimer(text, source),
3450
+ data: {
3451
+ date,
3452
+ degraded,
3453
+ source: source ?? null,
3454
+ count: todays.length,
3455
+ matches: todays,
3456
+ ...marketSignals ? { marketSignals } : {}
3457
+ }
2946
3458
  };
2947
3459
  }
2948
3460
  async function toolGetLive(args = {}) {
2949
3461
  const adapter = resolveAdapter(args);
2950
- const { matches, degraded } = await getLiveMatches(adapter);
3462
+ const { matches, degraded, source } = await getLiveMatches(adapter);
2951
3463
  const opts = fmtOpts(args);
2952
3464
  const text = `Live now:
2953
3465
  ${matchList(matches, "No matches in play right now.", opts)}`;
2954
3466
  return {
2955
- text: withDisclaimer(text),
2956
- data: { degraded, count: matches.length, matches }
3467
+ text: withDisclaimer(text, source),
3468
+ data: { degraded, source: source ?? null, count: matches.length, matches }
2957
3469
  };
2958
3470
  }
2959
3471
  async function toolGetMatch(args) {
2960
3472
  let match = allFixtures().find((m) => m.id === args.id);
2961
3473
  let degraded = false;
3474
+ let liveSource;
2962
3475
  if (match) {
2963
3476
  try {
2964
3477
  const adapter = resolveAdapter(args);
2965
3478
  const live = await adapter.fetchByDate(match.kickoff.slice(0, 10));
2966
3479
  match = live.find((m) => m.id === args.id) ?? match;
3480
+ liveSource = adapter.name;
2967
3481
  } catch {
2968
3482
  degraded = true;
2969
3483
  }
@@ -2972,10 +3486,26 @@ async function toolGetMatch(args) {
2972
3486
  return { text: withDisclaimer(`No match found with id ${args.id}.`), data: { match: null } };
2973
3487
  }
2974
3488
  const opts = fmtOpts(args);
2975
- return { text: withDisclaimer(matchLine(match, opts)), data: { degraded, match } };
3489
+ let marketSignal;
3490
+ if (marketsEnabled()) {
3491
+ const s = (await cachedMarketSignals(args, [match])).get(match.id);
3492
+ if (s && isReliableMarketSignal(s, { now: /* @__PURE__ */ new Date() })) marketSignal = s;
3493
+ }
3494
+ const base = matchLine(match, opts);
3495
+ const text = marketSignal ? `${base}
3496
+ ${marketBlock(marketSignal, match).join("\n")}` : base;
3497
+ return {
3498
+ text: withDisclaimer(text, liveSource),
3499
+ data: {
3500
+ degraded,
3501
+ source: liveSource ?? null,
3502
+ match,
3503
+ marketSignal: marketSignal ? marketData(marketSignal) : null
3504
+ }
3505
+ };
2976
3506
  }
2977
3507
  async function toolGetStandings(args) {
2978
- const { matches, degraded } = await getMatchesForDate(
3508
+ const { matches, degraded, source } = await getMatchesForDate(
2979
3509
  resolveAdapter(args),
2980
3510
  localDate((/* @__PURE__ */ new Date()).toISOString(), args.tz)
2981
3511
  );
@@ -2984,10 +3514,8 @@ async function toolGetStandings(args) {
2984
3514
  const g = args.group.toUpperCase();
2985
3515
  if (!known.includes(g)) {
2986
3516
  return {
2987
- text: withDisclaimer(
2988
- `No group "${g}". Groups are ${known.join(", ")}.`
2989
- ),
2990
- data: { degraded, tables: null }
3517
+ text: withDisclaimer(`No group "${g}". Groups are ${known.join(", ")}.`, source),
3518
+ data: { degraded, source: source ?? null, tables: null }
2991
3519
  };
2992
3520
  }
2993
3521
  }
@@ -2998,8 +3526,8 @@ async function toolGetStandings(args) {
2998
3526
  }));
2999
3527
  const text = tables.map((t) => standingsTable(t.group, t.standings)).join("\n\n");
3000
3528
  return {
3001
- text: withDisclaimer(text || `No group found.`),
3002
- data: { degraded, tables: args.group ? tables[0] ?? null : tables }
3529
+ text: withDisclaimer(text || `No group found.`, source),
3530
+ data: { degraded, source: source ?? null, tables: args.group ? tables[0] ?? null : tables }
3003
3531
  };
3004
3532
  }
3005
3533
  async function toolGetNextFixture(args) {
@@ -3018,14 +3546,65 @@ ${matchLine(fixture, opts)}`),
3018
3546
  data: { team: code, fixture }
3019
3547
  };
3020
3548
  }
3549
+ async function toolGetMarketSignal(args) {
3550
+ const provider = resolveMarketProvider(args);
3551
+ if (args.matchId) {
3552
+ const match = allFixtures().find((m) => m.id === args.matchId);
3553
+ const sig = match ? await getMarketSignal(provider, match) : void 0;
3554
+ const shown2 = match && sig && marketDisplayable(sig) ? sig : void 0;
3555
+ const text2 = !match ? `No match found with id ${args.matchId}.` : shown2 ? marketText(match, shown2) : `No reliable market signal for ${marketHeader(match)}.`;
3556
+ return {
3557
+ text: withDisclaimer(text2),
3558
+ data: {
3559
+ matchId: args.matchId,
3560
+ informationalOnly: true,
3561
+ signal: shown2 ? marketData(shown2) : null
3562
+ }
3563
+ };
3564
+ }
3565
+ if (args.team) {
3566
+ const code = args.team.toUpperCase();
3567
+ const fixture = nextFixtureForTeam(code);
3568
+ const sig = fixture ? await getMarketSignal(provider, fixture) : void 0;
3569
+ const shown2 = fixture && sig && marketDisplayable(sig) ? sig : void 0;
3570
+ const text2 = !fixture ? `No upcoming fixture found for ${code}.` : shown2 ? marketText(fixture, shown2) : `No reliable market signal for ${code}'s next fixture.`;
3571
+ return {
3572
+ text: withDisclaimer(text2),
3573
+ data: {
3574
+ team: code,
3575
+ matchId: fixture?.id ?? null,
3576
+ informationalOnly: true,
3577
+ signal: shown2 ? marketData(shown2) : null
3578
+ }
3579
+ };
3580
+ }
3581
+ const date = args.date ?? localDate((/* @__PURE__ */ new Date()).toISOString(), args.tz);
3582
+ const { matches } = await getMatchesForDate(resolveAdapter(args), date);
3583
+ const todays = fixturesByDate(date, matches, args.tz);
3584
+ const { signals } = await getMarketSignals(provider, todays, MARKETS_TOOL_OPTS);
3585
+ const shown = todays.map((m) => ({ match: m, signal: signals.get(m.id) })).filter(
3586
+ (r) => !!r.signal && marketDisplayable(r.signal)
3587
+ );
3588
+ const text = shown.length ? `Market signals on ${date}:
3589
+ ${shown.map(({ match, signal }) => marketText(match, signal)).join("\n\n")}` : `No reliable market signals on ${date}.`;
3590
+ return {
3591
+ text: withDisclaimer(text),
3592
+ data: {
3593
+ date,
3594
+ informationalOnly: true,
3595
+ signals: shown.map(({ signal }) => marketData(signal))
3596
+ }
3597
+ };
3598
+ }
3021
3599
 
3022
3600
  // src/server.ts
3023
3601
  var SERVER_NAME = "claudinho";
3024
- var SERVER_VERSION = "0.2.0";
3602
+ var SERVER_VERSION = "0.3.0";
3025
3603
  var VOICE = asFlavorLevel(process.env.CLAUDINHO_FLAVOR) === "off" ? "" : `
3026
3604
  Voice: when relaying scores, narrate with lively, regionally-appropriate football-commentary energy in the user's language. Each match line may end with a short exclamation ("\u2014 \xA1GOOOOL!") \u2014 use it as a tone cue. Keep every fact exact; never invent details and never impersonate or name a real commentator.`;
3027
3605
  var INSTRUCTIONS = `Claudinho serves live scores, fixtures, and group standings for the 2026 men's football tournament.
3028
- Use get_live during matches, get_today for a day's schedule, get_next_fixture for a specific team (3-letter code, e.g. MEX), and get_standings for group tables.${VOICE}
3606
+ Use get_live during matches, get_today for a day's schedule, get_next_fixture for a specific team (3-letter code, e.g. MEX), and get_standings for group tables.
3607
+ Use get_market_signal for read-only prediction-market odds (a match, a team's next fixture, or a date). Market data is informational only \u2014 relay the percentages factually and never frame it as betting or trading advice.${VOICE}
3029
3608
  ${DISCLAIMER}`;
3030
3609
  var dateArg = z.string().refine(isValidDate, "must be a real calendar date in YYYY-MM-DD form");
3031
3610
  var groupArg = z.string().regex(/^[A-La-l]$/, "a group letter A\u2013L");
@@ -3107,6 +3686,22 @@ function buildServer() {
3107
3686
  },
3108
3687
  async (args) => toContent(await toolGetNextFixture(args))
3109
3688
  );
3689
+ server.registerTool(
3690
+ "get_market_signal",
3691
+ {
3692
+ title: "Prediction-market signal",
3693
+ description: "Read-only prediction-market odds for a match (by id), a team's next fixture, or a date (default: today). Returns market-implied percentages with attribution. Informational only \u2014 relay the numbers factually; do not add betting, trading, or 'value' advice, and do not invent links.",
3694
+ inputSchema: {
3695
+ matchId: z.string().optional().describe("Match id (most specific)"),
3696
+ team: teamArg.optional().describe("3-letter team code for that team's next fixture, e.g. MEX"),
3697
+ date: dateArg.optional().describe("Date as YYYY-MM-DD (default: today) for all that day's signals"),
3698
+ ...commonArgs
3699
+ },
3700
+ // Read-only; reaches an external prediction-market data provider.
3701
+ annotations: { readOnlyHint: true, openWorldHint: true }
3702
+ },
3703
+ async (args) => toContent(await toolGetMarketSignal(args))
3704
+ );
3110
3705
  server.registerResource(
3111
3706
  "standings",
3112
3707
  new ResourceTemplate("standings://{group}", { list: void 0 }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@claudinho/mcp",
3
- "version": "0.2.0",
4
- "description": "Claudinho MCP server — ask your agent about the 2026 football tournament. Live scores, fixtures, standings. Not affiliated with FIFA or Anthropic.",
3
+ "version": "0.3.0",
4
+ "description": "Claudinho MCP server — ask your agent about the 2026 men's football tournament: live scores, fixtures, standings, prediction-market odds. Not affiliated with FIFA or Anthropic.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Arturo Garrido",
@@ -47,7 +47,7 @@
47
47
  "tsup": "^8.0.0",
48
48
  "typescript": "^5.7.0",
49
49
  "vitest": "^4.1.0",
50
- "@claudinho/core": "0.2.0"
50
+ "@claudinho/core": "0.3.0"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "tsup",