@claudinho/mcp 0.8.17 → 0.8.19

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 +4 -2
  2. package/dist/index.js +172 -22
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -51,9 +51,11 @@ Then just ask your agent naturally — it picks the right tool and answers with
51
51
  | `get_next_fixture` | a team's next match (3-letter code, e.g. `MEX`) — live-resolves a confirmed knockout tie from the feed; group fixtures offline, fails back to the bundled schedule if the feed is down |
52
52
  | `get_market_signal` | read-only prediction-market signal for a match, a team's current-or-next fixture (in-play preferred while live), or a date — informational only |
53
53
  | `get_share_snippet` | a copy-pasteable plain-text card — for a match, a team's next fixture, a group's standings table (`group`), the knockout bracket (`bracket: true`, optional `knockoutStage`), a date, or live — hand the returned snippet to the user as-is |
54
+ | `get_team` | resolve a nation name or code to its FIFA 3-letter code, flag, and group — fuzzy (`Mexico`, `mex`, `DR Congo`, `Türkiye`); call it first to turn a user's team name into the code the other tools need. Offline (no network) |
54
55
 
55
- All tools are **read-only** (`readOnlyHint`) and accept optional `tz`, `lang`
56
- (`en`/`es`/`pt`/`fr`), and `flavor` (`off`/`subtle`/`full`). Every response carries
56
+ Most tools are **read-only** (`readOnlyHint`) and accept optional `tz`, `lang`
57
+ (`en`/`es`/`pt`/`fr`), and `flavor` (`off`/`subtle`/`full`); `get_team` is read-only
58
+ **and** offline. Every response carries
57
59
  human-readable text **and** structured content, validated against each tool's
58
60
  declared `outputSchema`.
59
61
 
package/dist/index.js CHANGED
@@ -504,6 +504,33 @@ function shiftUtcDate(dateISO, days) {
504
504
  const [y, m, d] = dateISO.slice(0, 10).split("-").map(Number);
505
505
  return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days)).toISOString().slice(0, 10);
506
506
  }
507
+ var FEED_TEXT_MAX = 100;
508
+ function sanitizeFeedText(value, max = FEED_TEXT_MAX) {
509
+ let out = "";
510
+ let count = 0;
511
+ for (const ch of String(value)) {
512
+ const cp = ch.codePointAt(0) ?? 0;
513
+ const isWhitespaceControl = cp === 9 || cp === 10 || cp === 13;
514
+ if ((cp <= 31 || cp >= 127 && cp <= 159) && !isWhitespaceControl) continue;
515
+ if (count >= max) break;
516
+ out += isWhitespaceControl ? " " : ch;
517
+ count++;
518
+ }
519
+ return out;
520
+ }
521
+ var segmenter = new Intl.Segmenter();
522
+ var WIDE_CLUSTER = new RegExp("^(?:\\p{Regional_Indicator}|\\p{Extended_Pictographic})", "u");
523
+ function displayWidth(s) {
524
+ let w = 0;
525
+ for (const { segment } of segmenter.segment(s)) {
526
+ w += WIDE_CLUSTER.test(segment) ? 2 : 1;
527
+ }
528
+ return w;
529
+ }
530
+ function padVisible(s, width) {
531
+ const w = displayWidth(s);
532
+ return w >= width ? s : s + " ".repeat(width - w);
533
+ }
507
534
  function outcomeFromScore(home, away) {
508
535
  if (home > away) return "H";
509
536
  if (home < away) return "A";
@@ -2802,6 +2829,60 @@ function groups(fixtures = SCHEDULE) {
2802
2829
  for (const m of fixtures) if (m.group) set.add(m.group);
2803
2830
  return [...set].sort();
2804
2831
  }
2832
+ function norm2(s) {
2833
+ return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z]/g, "");
2834
+ }
2835
+ function allTeams(fixtures = allFixtures()) {
2836
+ const seen = /* @__PURE__ */ new Map();
2837
+ for (const m of fixtures) {
2838
+ if (m.stage !== "GROUP") continue;
2839
+ for (const t2 of [m.home, m.away]) {
2840
+ if (!/^[A-Z]{3}$/.test(t2.code) || t2.flag === "\u{1F3F3}\uFE0F") continue;
2841
+ if (!seen.has(t2.code)) seen.set(t2.code, { ...t2, group: m.group ?? void 0 });
2842
+ }
2843
+ }
2844
+ return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
2845
+ }
2846
+ var TEAM_ALIASES = {
2847
+ turkey: "TUR",
2848
+ holland: "NED",
2849
+ korea: "KOR",
2850
+ skorea: "KOR",
2851
+ korearepublic: "KOR",
2852
+ republicofkorea: "KOR",
2853
+ czechrepublic: "CZE",
2854
+ congo: "COD",
2855
+ drcongo: "COD",
2856
+ drc: "COD",
2857
+ democraticrepublicofcongo: "COD",
2858
+ democraticrepublicofthecongo: "COD",
2859
+ cotedivoire: "CIV",
2860
+ caboverde: "CPV",
2861
+ bosnia: "BIH",
2862
+ bosniaandherzegovina: "BIH",
2863
+ us: "USA",
2864
+ america: "USA",
2865
+ unitedstatesofamerica: "USA"
2866
+ };
2867
+ function lookupTeam(query, fixtures = allFixtures()) {
2868
+ const roster = allTeams(fixtures);
2869
+ const q = norm2(query);
2870
+ if (!q) return { query, team: null, matches: [] };
2871
+ const byCode = roster.find((t2) => norm2(t2.code) === q);
2872
+ if (byCode) return { query, team: byCode, matches: [byCode] };
2873
+ const byName = roster.find((t2) => norm2(t2.name) === q);
2874
+ if (byName) return { query, team: byName, matches: [byName] };
2875
+ const aliasCode = TEAM_ALIASES[q];
2876
+ if (aliasCode) {
2877
+ const t2 = roster.find((r) => r.code === aliasCode);
2878
+ if (t2) return { query, team: t2, matches: [t2] };
2879
+ }
2880
+ if (q.length < 3) return { query, team: null, matches: [] };
2881
+ const prefix = roster.filter((t2) => norm2(t2.name).startsWith(q));
2882
+ const substr = roster.filter((t2) => norm2(t2.name).includes(q) && !prefix.includes(t2));
2883
+ const matches = [...prefix, ...substr];
2884
+ return { query, team: matches.length === 1 ? matches[0] : null, matches };
2885
+ }
2805
2886
  function blankRow(team) {
2806
2887
  return {
2807
2888
  team,
@@ -2827,6 +2908,7 @@ var ESPN_SOCCER = "https://site.api.espn.com/apis/site/v2/sports/soccer";
2827
2908
  var DEFAULT_COMPETITION = "fifa.world";
2828
2909
  var DEFAULT_BASE = `${ESPN_SOCCER}/${DEFAULT_COMPETITION}`;
2829
2910
  var USER_AGENT = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
2911
+ var MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
2830
2912
  function competitionBase(slug) {
2831
2913
  return `${ESPN_SOCCER}/${slug}`;
2832
2914
  }
@@ -2870,9 +2952,15 @@ function toInt(s) {
2870
2952
  return Number.isFinite(n) ? n : void 0;
2871
2953
  }
2872
2954
  function toTeam(t2) {
2873
- const name = t2?.displayName ?? t2?.name ?? t2?.location ?? t2?.shortDisplayName ?? "TBD";
2874
- const code = (t2?.abbreviation ?? name.slice(0, 3)).toUpperCase();
2875
- return { code, name, flag: nationToFlag(t2?.displayName ?? t2?.abbreviation ?? name) };
2955
+ const name = sanitizeFeedText(
2956
+ t2?.displayName ?? t2?.name ?? t2?.location ?? t2?.shortDisplayName ?? "TBD"
2957
+ );
2958
+ const code = sanitizeFeedText(t2?.abbreviation ?? name.slice(0, 3)).toUpperCase();
2959
+ return {
2960
+ code,
2961
+ name,
2962
+ flag: nationToFlag(sanitizeFeedText(t2?.displayName ?? t2?.abbreviation ?? name))
2963
+ };
2876
2964
  }
2877
2965
  function mapEspnEvent(ev, ctx = {}) {
2878
2966
  const comp = ev.competitions?.[0];
@@ -2903,9 +2991,9 @@ function mapEspnEvent(ev, ctx = {}) {
2903
2991
  stage,
2904
2992
  group,
2905
2993
  kickoff: ev.date,
2906
- venue: comp?.venue?.fullName ?? "",
2907
- city: comp?.venue?.address?.city || void 0,
2908
- country: comp?.venue?.address?.country || void 0,
2994
+ venue: sanitizeFeedText(comp?.venue?.fullName ?? ""),
2995
+ city: sanitizeFeedText(comp?.venue?.address?.city ?? "") || void 0,
2996
+ country: sanitizeFeedText(comp?.venue?.address?.country ?? "") || void 0,
2909
2997
  home,
2910
2998
  away,
2911
2999
  score: hasScore ? { home: hs, away: as } : void 0,
@@ -3029,11 +3117,18 @@ var EspnAdapter = class {
3029
3117
  try {
3030
3118
  const res = await doFetch(url, {
3031
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",
3032
3123
  headers: { Accept: "application/json", "User-Agent": USER_AGENT }
3033
3124
  });
3034
3125
  if (!res.ok) {
3035
3126
  throw new Error(`ESPN request failed: ${res.status} ${res.statusText}`);
3036
3127
  }
3128
+ const length = Number(res.headers?.get?.("content-length"));
3129
+ if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
3130
+ throw new Error(`ESPN response too large: ${length} bytes`);
3131
+ }
3037
3132
  return await res.json();
3038
3133
  } finally {
3039
3134
  clearTimeout(timer);
@@ -4175,12 +4270,19 @@ var PolymarketProvider = class {
4175
4270
  const doFetch = this.opts.fetchImpl ?? fetch;
4176
4271
  const res = await doFetch(url, {
4177
4272
  signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
4273
+ // Gamma never legitimately redirects; following one would sidestep the
4274
+ // host allow-list (it only validates the base URL), so reject redirects.
4275
+ redirect: "error",
4178
4276
  headers: { Accept: "application/json", "User-Agent": USER_AGENT2 }
4179
4277
  });
4180
4278
  if (res.status === 404) return void 0;
4181
4279
  if (!res.ok) {
4182
4280
  throw new Error(`Polymarket request failed: ${res.status} ${res.statusText}`);
4183
4281
  }
4282
+ const length = Number(res.headers?.get?.("content-length"));
4283
+ if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
4284
+ throw new Error(`Polymarket response too large: ${length} bytes`);
4285
+ }
4184
4286
  const data = await res.json();
4185
4287
  const event = Array.isArray(data) ? data[0] : data;
4186
4288
  return event && typeof event === "object" ? event : void 0;
@@ -4623,17 +4725,21 @@ function matchList(matches, empty, opts = {}) {
4623
4725
  }
4624
4726
  function standingsTable(group, rows) {
4625
4727
  const header = `Group ${group}`;
4626
- const cols = "Team P W D L GD Pts";
4627
- const lines = rows.map((r) => {
4628
- const name = `${r.team.flag} ${r.team.name}`.padEnd(24).slice(0, 24);
4629
- const gd2 = (r.goalDiff > 0 ? `+${r.goalDiff}` : `${r.goalDiff}`).padStart(3);
4630
- return `${name} ${pad(r.played)} ${pad(r.won)} ${pad(r.drawn)} ${pad(r.lost)} ${gd2} ${pad(r.points)}`;
4631
- });
4728
+ const line = (team, p, w, d, l, gd2, pts) => `${padVisible(team, 24)} ${p.padStart(2)} ${w.padStart(2)} ${d.padStart(2)} ${l.padStart(2)} ${gd2.padStart(3)} ${pts.padStart(3)}`;
4729
+ const cols = line("Team", "P", "W", "D", "L", "GD", "Pts");
4730
+ const lines = rows.map(
4731
+ (r) => line(
4732
+ `${r.team.flag} ${r.team.name}`,
4733
+ String(r.played),
4734
+ String(r.won),
4735
+ String(r.drawn),
4736
+ String(r.lost),
4737
+ r.goalDiff > 0 ? `+${r.goalDiff}` : String(r.goalDiff),
4738
+ String(r.points)
4739
+ )
4740
+ );
4632
4741
  return [header, cols, ...lines].join("\n");
4633
4742
  }
4634
- function pad(n) {
4635
- return `${n}`.padStart(2);
4636
- }
4637
4743
  var DISCLAIMER = "Claudinho is an independent fan project \u2014 not affiliated with or endorsed by FIFA or Anthropic.";
4638
4744
 
4639
4745
  // src/tools.ts
@@ -4874,16 +4980,31 @@ async function toolGetNextFixture(args) {
4874
4980
  const msg = degraded ? `Couldn't reach the data provider \u2014 no upcoming fixture confirmed for ${code}.` : `No upcoming fixture found for ${code}.`;
4875
4981
  return {
4876
4982
  text: withDisclaimer(msg, void 0, args.lang),
4877
- data: { team: code, fixture: null, degraded }
4983
+ data: { team: code, fixture: null, degraded, source: source ?? null }
4878
4984
  };
4879
4985
  }
4880
4986
  const opts = fmtOpts(args);
4881
4987
  return {
4988
+ // `source` in data mirrors the text's "Live data: …" attribution (parity
4989
+ // with CLI `next --json`); null for a static group fixture (no live source).
4882
4990
  text: withDisclaimer(`Next up for ${code}:
4883
4991
  ${matchLine(fixture, opts)}`, source, args.lang),
4884
- data: { team: code, fixture, degraded }
4992
+ data: { team: code, fixture, degraded, source: source ?? null }
4885
4993
  };
4886
4994
  }
4995
+ function toolGetTeam(args) {
4996
+ const { team, matches } = lookupTeam(args.query ?? "");
4997
+ const data = { query: args.query ?? "", team: team ?? null, matches, count: matches.length };
4998
+ let text;
4999
+ if (team) {
5000
+ text = `${team.code} \u2014 ${team.flag} ${team.name}${team.group ? ` \xB7 Group ${team.group}` : ""}`;
5001
+ } else if (matches.length > 0) {
5002
+ text = `"${args.query}" is ambiguous. Did you mean: ${matches.map((t2) => `${t2.name} (${t2.code})`).join(", ")}?`;
5003
+ } else {
5004
+ text = `No team found for "${args.query}". Use a nation name or 3-letter code (e.g. Mexico, MEX).`;
5005
+ }
5006
+ return { text: withDisclaimer(text), data };
5007
+ }
4887
5008
  async function toolGetMarketSignal(args) {
4888
5009
  const provider = resolveMarketProvider(args);
4889
5010
  const now = args.now ?? /* @__PURE__ */ new Date();
@@ -5152,11 +5273,12 @@ async function toolGetShareSnippet(args) {
5152
5273
 
5153
5274
  // src/server.ts
5154
5275
  var SERVER_NAME = "claudinho";
5155
- var SERVER_VERSION = "0.8.17";
5276
+ var SERVER_VERSION = "0.8.19";
5156
5277
  var VOICE = asFlavorLevel(process.env.CLAUDINHO_FLAVOR) === "off" ? "" : `
5157
5278
  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.`;
5158
5279
  var INSTRUCTIONS = `Claudinho serves live scores, fixtures, and group standings for the 2026 men's football tournament.
5159
- 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), get_standings for group tables, and get_bracket for the knockout tree.
5280
+ The team-taking tools (get_next_fixture, get_market_signal, get_share_snippet) expect a 3-letter code (e.g. MEX). When the user gives a nation NAME, call get_team FIRST to resolve it \u2014 get_team is fuzzy ("Mexico", "DR Congo", "T\xFCrkiye"), offline, and returns candidates when the name is ambiguous.
5281
+ Use get_live during matches, get_today for a day's schedule, get_next_fixture for a specific team, get_standings for group tables, and get_bracket for the knockout tree.
5160
5282
  Use get_market_signal for read-only prediction-market signals (a match, a team's current-or-next fixture, or a date). Market data is informational only \u2014 relay the percentages factually and never frame it as betting or trading advice.
5161
5283
  Use get_share_snippet to produce a ready-to-paste match card (for a match, a team's next fixture, a date, or live matches) \u2014 hand the user the returned snippet text verbatim.${VOICE}
5162
5284
  ${DISCLAIMER}`;
@@ -5166,7 +5288,9 @@ var teamArg = z.string().regex(/^[A-Za-z]{3}$/, "a 3-letter team code, e.g. MEX"
5166
5288
  var flavorArg = z.enum(["off", "subtle", "full"]);
5167
5289
  var commonArgs = {
5168
5290
  tz: z.string().optional().describe("IANA timezone for kickoff times, e.g. America/Mexico_City"),
5169
- lang: z.string().optional().describe("Locale for formatting: en, es, pt, fr (other locales fall back to en)"),
5291
+ lang: z.string().optional().describe(
5292
+ "Locale for dates, provider attribution, and commentary: en, es, pt, fr (the summary scaffold stays English; other locales fall back to en)"
5293
+ ),
5170
5294
  flavor: flavorArg.optional().describe("Commentary flair: off, subtle, full (default: full)")
5171
5295
  };
5172
5296
  var teamRef = z.object({ code: z.string(), name: z.string(), flag: z.string() }).partial().passthrough();
@@ -5213,7 +5337,12 @@ var bracketOut = {
5213
5337
  standingsDegraded: z.boolean().optional(),
5214
5338
  source: src.optional()
5215
5339
  };
5216
- var nextOut = { team: z.string(), fixture: matchOut.nullable(), degraded: z.boolean() };
5340
+ var nextOut = {
5341
+ team: z.string(),
5342
+ fixture: matchOut.nullable(),
5343
+ degraded: z.boolean(),
5344
+ source: src
5345
+ };
5217
5346
  var marketOut = {
5218
5347
  matchId: z.string().nullable().optional(),
5219
5348
  team: z.string().optional(),
@@ -5240,6 +5369,13 @@ var shareOut = {
5240
5369
  matches: z.array(matchOut).optional(),
5241
5370
  marketSignals: z.record(anyObj).optional()
5242
5371
  };
5372
+ var teamInfo = z.object({ code: z.string(), name: z.string(), flag: z.string(), group: z.string() }).partial().passthrough();
5373
+ var teamOut = {
5374
+ query: z.string(),
5375
+ team: teamInfo.nullable(),
5376
+ matches: z.array(teamInfo),
5377
+ count: z.number()
5378
+ };
5243
5379
  function toContent(r) {
5244
5380
  return {
5245
5381
  content: [
@@ -5258,7 +5394,7 @@ function buildServer() {
5258
5394
  "get_today",
5259
5395
  {
5260
5396
  title: "Today's matches",
5261
- description: "All fixtures for a date (default: today), with live score and minute overlaid on any match in play. Use this for a whole day's card; for only in-play matches use get_live, for one team's match use get_next_fixture, for a single match's detail use get_match. Kickoffs render in tz; lang localizes text (en/es/pt/fr); flavor sets commentary tone.",
5397
+ description: "All fixtures for a date (default: today), with live score and minute overlaid on any match in play. Use this for a whole day's card; for only in-play matches use get_live, for one team's match use get_next_fixture, for a single match's detail use get_match. Kickoffs render in tz; lang localizes dates, attribution, and commentary (en/es/pt/fr); flavor sets commentary tone.",
5262
5398
  inputSchema: {
5263
5399
  date: dateArg.optional().describe("Date as YYYY-MM-DD (default: today)"),
5264
5400
  ...commonArgs
@@ -5375,6 +5511,20 @@ function buildServer() {
5375
5511
  },
5376
5512
  async (args) => toContent(await toolGetShareSnippet(args))
5377
5513
  );
5514
+ server.registerTool(
5515
+ "get_team",
5516
+ {
5517
+ title: "Resolve a team",
5518
+ description: `Resolve a nation name or 3-letter code to its FIFA code, flag, and group. Fuzzy and forgiving: accepts "Mexico", "mex", "USA", "DR Congo", "T\xFCrkiye"/"Turkey", "Holland", etc. Use this FIRST to turn a user's team name into the code the other tools need (get_next_fixture, get_standings, get_market_signal, get_share_snippet). Returns the single confident match (team), plus candidates (matches) when the query is ambiguous (e.g. "south" \u2192 South Africa, South Korea). Offline \u2014 reads the bundled roster, never the network.`,
5519
+ inputSchema: {
5520
+ query: z.string().describe('Team name or 3-letter code, e.g. "Mexico", "MEX", "DR Congo"')
5521
+ },
5522
+ // Read-only AND offline — resolves against the bundled roster, no provider call.
5523
+ annotations: { readOnlyHint: true, openWorldHint: false },
5524
+ outputSchema: teamOut
5525
+ },
5526
+ async (args) => toContent(toolGetTeam(args))
5527
+ );
5378
5528
  server.registerResource(
5379
5529
  "standings",
5380
5530
  new ResourceTemplate("standings://{group}", { list: void 0 }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudinho/mcp",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "mcpName": "io.github.arturogarrido/claudinho",
5
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.",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "files": [
28
28
  "dist",
29
+ "!dist/*.mcpb",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
@@ -57,7 +58,7 @@
57
58
  "tsup": "^8.0.0",
58
59
  "typescript": "^5.7.0",
59
60
  "vitest": "^4.1.9",
60
- "@claudinho/core": "0.8.17"
61
+ "@claudinho/core": "0.8.19"
61
62
  },
62
63
  "scripts": {
63
64
  "build": "tsup",