@claudinho/mcp 0.8.19 → 0.9.1

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 (2) hide show
  1. package/dist/index.js +137 -34
  2. package/package.json +6 -4
package/dist/index.js CHANGED
@@ -385,7 +385,8 @@ function normalizeLang(lang) {
385
385
  }
386
386
  function t(lang, key, vars) {
387
387
  const dict = CATALOGS[normalizeLang(lang)];
388
- let s = dict[key] ?? EN[key] ?? key;
388
+ const en = EN;
389
+ let s = dict[key] ?? en[key] ?? key;
389
390
  if (vars) {
390
391
  for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
391
392
  }
@@ -2907,11 +2908,27 @@ function rosterAtZero(matches) {
2907
2908
  var ESPN_SOCCER = "https://site.api.espn.com/apis/site/v2/sports/soccer";
2908
2909
  var DEFAULT_COMPETITION = "fifa.world";
2909
2910
  var DEFAULT_BASE = `${ESPN_SOCCER}/${DEFAULT_COMPETITION}`;
2910
- var USER_AGENT = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
2911
+ var USER_AGENT = `claudinho/${"0.9.1"} (+https://github.com/arturogarrido/claudinho)`;
2911
2912
  var MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
2912
2913
  function competitionBase(slug) {
2913
2914
  return `${ESPN_SOCCER}/${slug}`;
2914
2915
  }
2916
+ var DEFAULT_TIMEOUT_MS = 6e3;
2917
+ var STANDINGS_SHARE_MS = 3e4;
2918
+ var ProviderError = class extends Error {
2919
+ kind;
2920
+ status;
2921
+ constructor(message, kind, status) {
2922
+ super(message);
2923
+ this.name = "ProviderError";
2924
+ this.kind = kind;
2925
+ this.status = status;
2926
+ }
2927
+ /** 429/403 — the upstream is refusing us; retrying at the live cadence makes it worse. */
2928
+ get throttled() {
2929
+ return this.kind === "http" && (this.status === 429 || this.status === 403);
2930
+ }
2931
+ };
2915
2932
  function mapStatus(st) {
2916
2933
  const name = (st?.type?.name ?? "").toUpperCase();
2917
2934
  const state = st?.type?.state ?? "";
@@ -3053,6 +3070,20 @@ var EspnAdapter = class {
3053
3070
  capabilities = { push: false, latencyHintSec: 45 };
3054
3071
  /** Cached team-code -> group-letter map (built lazily from standings). */
3055
3072
  groupMap;
3073
+ /**
3074
+ * One in-flight/recent standings fetch shared by fetchStandings and
3075
+ * fetchGroupMap, so a command like `bracket` hits the endpoint once instead
3076
+ * of twice, and a long-lived MCP server stays fresh (short TTL). A rejected
3077
+ * fetch clears the slot — a transient failure is never cached.
3078
+ */
3079
+ standingsShared;
3080
+ /**
3081
+ * The most recent request failure (best-effort under concurrency; cleared
3082
+ * when a new request starts). A post-hoc hint for callers whose result path
3083
+ * fails closed to `degraded` booleans but who still need to distinguish a
3084
+ * throttle (persist a backoff) from an ordinary blip.
3085
+ */
3086
+ lastError;
3056
3087
  async fetchByDate(dateISO) {
3057
3088
  return this.fetchScoreboard(toEspnDate(dateISO));
3058
3089
  }
@@ -3074,37 +3105,58 @@ var EspnAdapter = class {
3074
3105
  const base = this.opts.baseUrl ?? DEFAULT_BASE;
3075
3106
  return `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
3076
3107
  }
3108
+ /** The shared standings fetch (see {@link standingsShared}). */
3109
+ sharedStandings() {
3110
+ const now = Date.now();
3111
+ if (this.standingsShared && now - this.standingsShared.at < STANDINGS_SHARE_MS) {
3112
+ return this.standingsShared.promise;
3113
+ }
3114
+ const promise = this.get(this.standingsUrl()).then(
3115
+ (d) => parseStandings(d)
3116
+ );
3117
+ this.standingsShared = { at: now, promise };
3118
+ promise.catch(() => {
3119
+ if (this.standingsShared?.promise === promise) this.standingsShared = void 0;
3120
+ });
3121
+ return promise;
3122
+ }
3077
3123
  /**
3078
3124
  * Authoritative, cumulative group tables from the standings endpoint. Throws
3079
3125
  * on fetch/parse failure (the caller decides the fallback). Group-stage only:
3080
3126
  * non-group `children` are filtered out by {@link parseStandings}.
3081
3127
  */
3082
3128
  async fetchStandings() {
3083
- return parseStandings(await this.get(this.standingsUrl()));
3129
+ return this.sharedStandings();
3084
3130
  }
3085
3131
  /**
3086
3132
  * Build (and cache) a team-code -> group-letter map from the standings
3087
- * endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
3088
- * same parse as {@link fetchStandings}, so the two never drift.
3133
+ * endpoint. Best-effort: returns {} if standings are unavailable but a
3134
+ * transient failure is NOT cached (only a successful parse pins the map), so
3135
+ * one blip can't silently drop group letters for the adapter's lifetime.
3136
+ * Reuses the same parse/fetch as {@link fetchStandings}, so the two never
3137
+ * drift and one command never fetches standings twice.
3089
3138
  */
3090
3139
  async fetchGroupMap(force = false) {
3091
3140
  if (this.groupMap && !force) return this.groupMap;
3092
- const map = {};
3093
3141
  try {
3094
- const tables = parseStandings(await this.get(this.standingsUrl()));
3142
+ const tables = await this.sharedStandings();
3143
+ const map = {};
3095
3144
  for (const t2 of tables) for (const r of t2.rows) map[r.team.code] = t2.group;
3145
+ this.groupMap = map;
3146
+ return map;
3096
3147
  } catch {
3148
+ return {};
3097
3149
  }
3098
- this.groupMap = map;
3099
- return map;
3100
3150
  }
3101
3151
  async fetchScoreboard(dates) {
3102
3152
  const base = this.opts.baseUrl ?? DEFAULT_BASE;
3103
3153
  const url = new URL(`${base}/scoreboard`);
3104
3154
  url.searchParams.set("limit", "300");
3105
3155
  if (dates) url.searchParams.set("dates", dates);
3106
- const groupByTeam = this.opts.enrichGroups === false ? {} : await this.fetchGroupMap();
3107
- const data = await this.get(url.toString());
3156
+ const [groupByTeam, data] = await Promise.all([
3157
+ this.opts.enrichGroups === false ? Promise.resolve({}) : this.fetchGroupMap(),
3158
+ this.get(url.toString())
3159
+ ]);
3108
3160
  return (data.events ?? []).map((ev) => mapEspnEvent(ev, { groupByTeam }));
3109
3161
  }
3110
3162
  async get(url) {
@@ -3112,24 +3164,51 @@ var EspnAdapter = class {
3112
3164
  const controller = new AbortController();
3113
3165
  const timer = setTimeout(
3114
3166
  () => controller.abort(),
3115
- this.opts.timeoutMs ?? 15e3
3167
+ this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
3116
3168
  );
3169
+ this.lastError = void 0;
3117
3170
  try {
3118
- const res = await doFetch(url, {
3119
- signal: controller.signal,
3120
- // ESPN never legitimately redirects; following one would sidestep the
3121
- // fixed-host guarantee, so treat any redirect as a failure (degraded).
3122
- redirect: "error",
3123
- headers: { Accept: "application/json", "User-Agent": USER_AGENT }
3124
- });
3171
+ let res;
3172
+ try {
3173
+ res = await doFetch(url, {
3174
+ signal: controller.signal,
3175
+ // ESPN never legitimately redirects; following one would sidestep the
3176
+ // fixed-host guarantee, so treat any redirect as a failure (degraded).
3177
+ redirect: "error",
3178
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT }
3179
+ });
3180
+ } catch (e) {
3181
+ throw e?.name === "AbortError" ? new ProviderError(`ESPN request timed out: ${url}`, "timeout") : new ProviderError(`ESPN request failed: ${e?.message ?? e}`, "http");
3182
+ }
3125
3183
  if (!res.ok) {
3126
- throw new Error(`ESPN request failed: ${res.status} ${res.statusText}`);
3184
+ throw new ProviderError(
3185
+ `ESPN request failed: ${res.status} ${res.statusText}`,
3186
+ "http",
3187
+ res.status
3188
+ );
3127
3189
  }
3128
3190
  const length = Number(res.headers?.get?.("content-length"));
3129
3191
  if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
3130
- throw new Error(`ESPN response too large: ${length} bytes`);
3192
+ throw new ProviderError(`ESPN response too large: ${length} bytes`, "parse");
3193
+ }
3194
+ try {
3195
+ return await res.json();
3196
+ } catch (e) {
3197
+ throw new ProviderError(
3198
+ `ESPN response unparseable: ${e?.message ?? e}`,
3199
+ "parse"
3200
+ );
3201
+ }
3202
+ } catch (e) {
3203
+ const pe = e instanceof ProviderError ? e : new ProviderError(String(e?.message ?? e), "http");
3204
+ this.lastError = pe;
3205
+ if (typeof process !== "undefined" && process.env?.CLAUDINHO_DEBUG) {
3206
+ process.stderr.write(
3207
+ `claudinho: espn ${pe.kind}${pe.status ? ` ${pe.status}` : ""}: ${url}
3208
+ `
3209
+ );
3131
3210
  }
3132
- return await res.json();
3211
+ throw pe;
3133
3212
  } finally {
3134
3213
  clearTimeout(timer);
3135
3214
  }
@@ -3853,13 +3932,18 @@ function resolveCompetition(explicit) {
3853
3932
  }
3854
3933
  return DEFAULT_COMPETITION;
3855
3934
  }
3935
+ var KNOWN_SOURCES = ["espn"];
3856
3936
  function makeAdapter(source = "espn") {
3857
3937
  switch (source) {
3858
- default: {
3938
+ case "espn": {
3859
3939
  const competition = resolveCompetition();
3860
3940
  const baseUrl = competition === DEFAULT_COMPETITION ? void 0 : competitionBase(competition);
3861
3941
  return new EspnAdapter({ baseUrl });
3862
3942
  }
3943
+ default:
3944
+ throw new Error(
3945
+ `Unknown data source "${source}" (available: ${KNOWN_SOURCES.join(", ")})`
3946
+ );
3863
3947
  }
3864
3948
  }
3865
3949
  function mergeLive(base, live) {
@@ -3897,8 +3981,16 @@ async function getStandings(adapter, group) {
3897
3981
  const tables = letters.map((g) => ({ group: g, rows: rosterAtZero(fixturesByGroup(g)) })).filter((t2) => t2.rows.length > 0);
3898
3982
  return { tables, degraded: true };
3899
3983
  }
3900
- var KNOCKOUT_WINDOW_START = "20260628";
3901
- var KNOCKOUT_WINDOW_END = "20260719";
3984
+ var knockoutWindowMemo;
3985
+ function knockoutWindow() {
3986
+ if (knockoutWindowMemo !== void 0) return knockoutWindowMemo;
3987
+ const days = allFixtures().filter((m) => m.stage !== "GROUP" && m.stage !== "FRIENDLY").map((m) => m.kickoff.slice(0, 10).replace(/-/g, "")).filter((d) => d.length === 8);
3988
+ knockoutWindowMemo = days.length ? {
3989
+ start: days.reduce((a, b) => a < b ? a : b),
3990
+ end: days.reduce((a, b) => a > b ? a : b)
3991
+ } : null;
3992
+ return knockoutWindowMemo;
3993
+ }
3902
3994
  async function getBracket(adapter, opts = {}) {
3903
3995
  const topology = loadBracketTopology();
3904
3996
  const base = allFixtures().filter((m) => m.stage !== "GROUP" && m.stage !== "FRIENDLY");
@@ -3906,7 +3998,8 @@ async function getBracket(adapter, opts = {}) {
3906
3998
  let liveDegraded = true;
3907
3999
  let source;
3908
4000
  try {
3909
- const live = adapter.fetchWindow ? await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END) : [];
4001
+ const win = knockoutWindow();
4002
+ const live = adapter.fetchWindow && win ? await adapter.fetchWindow(win.start, win.end) : [];
3910
4003
  matches = mergeLive(base, live);
3911
4004
  liveDegraded = false;
3912
4005
  source = adapter.name;
@@ -3939,8 +4032,9 @@ async function marketFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Dat
3939
4032
  let fixtures = allFixtures();
3940
4033
  let overlayFailed = false;
3941
4034
  try {
3942
- if (adapter.fetchWindow) {
3943
- fixtures = mergeLive(fixtures, await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END));
4035
+ const win = knockoutWindow();
4036
+ if (adapter.fetchWindow && win) {
4037
+ fixtures = mergeLive(fixtures, await adapter.fetchWindow(win.start, win.end));
3944
4038
  }
3945
4039
  } catch {
3946
4040
  overlayFailed = true;
@@ -3963,7 +4057,8 @@ async function getNextFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Da
3963
4057
  let degraded = true;
3964
4058
  let liveById;
3965
4059
  try {
3966
- const live = adapter.fetchWindow ? await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END) : [];
4060
+ const win = knockoutWindow();
4061
+ const live = adapter.fetchWindow && win ? await adapter.fetchWindow(win.start, win.end) : [];
3967
4062
  matches = mergeLive(base, live);
3968
4063
  degraded = false;
3969
4064
  liveById = new Set(live.map((m) => m.id));
@@ -4211,7 +4306,7 @@ var mapping_2026_default = {
4211
4306
  var DEFAULT_BASE2 = "https://gamma-api.polymarket.com";
4212
4307
  var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["gamma-api.polymarket.com"]);
4213
4308
  var USER_AGENT2 = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
4214
- var DEFAULT_TIMEOUT_MS = 8e3;
4309
+ var DEFAULT_TIMEOUT_MS2 = 8e3;
4215
4310
  var WC_SERIES_SLUG = "soccer-fifwc";
4216
4311
  var WC_SPORT = "fifwc";
4217
4312
  var KICKOFF_TOLERANCE_MS = 6 * 60 * 6e4;
@@ -4249,7 +4344,7 @@ var PolymarketProvider = class {
4249
4344
  const entry = (this.opts.mapping ?? BUNDLED_MAPPING)[match.id];
4250
4345
  const slugs = entry?.eventSlug ? [entry.eventSlug] : deriveEventSlugs(match);
4251
4346
  if (slugs.length === 0) return { checked: true };
4252
- const configured = options?.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4347
+ const configured = options?.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
4253
4348
  try {
4254
4349
  for (const slug of slugs) {
4255
4350
  const remaining = deadline - Date.now();
@@ -4269,7 +4364,7 @@ var PolymarketProvider = class {
4269
4364
  const url = `${base}/events?slug=${encodeURIComponent(slug)}`;
4270
4365
  const doFetch = this.opts.fetchImpl ?? fetch;
4271
4366
  const res = await doFetch(url, {
4272
- signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
4367
+ signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2),
4273
4368
  // Gamma never legitimately redirects; following one would sidestep the
4274
4369
  // host allow-list (it only validates the base URL), so reject redirects.
4275
4370
  redirect: "error",
@@ -4743,8 +4838,16 @@ function standingsTable(group, rows) {
4743
4838
  var DISCLAIMER = "Claudinho is an independent fan project \u2014 not affiliated with or endorsed by FIFA or Anthropic.";
4744
4839
 
4745
4840
  // src/tools.ts
4841
+ var adapters = /* @__PURE__ */ new Map();
4746
4842
  function resolveAdapter(args) {
4747
- return args.adapter ?? makeAdapter(args.source);
4843
+ if (args.adapter) return args.adapter;
4844
+ const key = `${args.source ?? "espn"}::${resolveCompetition()}`;
4845
+ let adapter = adapters.get(key);
4846
+ if (!adapter) {
4847
+ adapter = makeAdapter(args.source);
4848
+ adapters.set(key, adapter);
4849
+ }
4850
+ return adapter;
4748
4851
  }
4749
4852
  function resolveMarketProvider(args) {
4750
4853
  return args.marketProvider ?? makeMarketProvider();
@@ -5273,7 +5376,7 @@ async function toolGetShareSnippet(args) {
5273
5376
 
5274
5377
  // src/server.ts
5275
5378
  var SERVER_NAME = "claudinho";
5276
- var SERVER_VERSION = "0.8.19";
5379
+ var SERVER_VERSION = "0.9.1";
5277
5380
  var VOICE = asFlavorLevel(process.env.CLAUDINHO_FLAVOR) === "off" ? "" : `
5278
5381
  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.`;
5279
5382
  var INSTRUCTIONS = `Claudinho serves live scores, fixtures, and group standings for the 2026 men's football tournament.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@claudinho/mcp",
3
- "version": "0.8.19",
3
+ "version": "0.9.1",
4
4
  "mcpName": "io.github.arturogarrido/claudinho",
5
- "description": "MCP server for the 2026 men's football tournament — live scores, fixtures, standings, read-only prediction-market signals, and paste-ready match cards. Works with Claude Code, Cursor, Codex, Windsurf, Zed. Not affiliated with FIFA or Anthropic.",
5
+ "description": "World Cup MCP server for the 2026 men's football tournament — live scores, fixtures, standings, read-only prediction-market signals, and paste-ready match cards. Works with Claude Code, Cursor, Codex, Windsurf, Zed. Not affiliated with FIFA or Anthropic.",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "author": "Arturo Garrido",
@@ -55,10 +55,11 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.20.0",
58
+ "@vitest/coverage-v8": "^4.1.9",
58
59
  "tsup": "^8.0.0",
59
60
  "typescript": "^5.7.0",
60
61
  "vitest": "^4.1.9",
61
- "@claudinho/core": "0.8.19"
62
+ "@claudinho/core": "0.9.1"
62
63
  },
63
64
  "scripts": {
64
65
  "build": "tsup",
@@ -66,6 +67,7 @@
66
67
  "dev": "tsup --watch",
67
68
  "test": "vitest run",
68
69
  "typecheck": "tsc --noEmit",
69
- "start": "node dist/index.js"
70
+ "start": "node dist/index.js",
71
+ "smoke:stdio": "node scripts/stdio-smoke.mjs"
70
72
  }
71
73
  }