@claudinho/mcp 0.4.3 → 0.5.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 +2 -2
  2. package/dist/index.js +177 -51
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -35,10 +35,10 @@ Developer → Edit Config, then restart. Codex config file: `~/.codex/config.tom
35
35
  | `get_today` | fixtures for a date (default: today), grouped in the caller's `tz`, live scores overlaid |
36
36
  | `get_live` | matches in play right now |
37
37
  | `get_match` | a single match by id |
38
- | `get_standings` | group table(s) — one group `A`–`L`, or all |
38
+ | `get_standings` | live cumulative group table(s) — one group `A`–`L`, or all |
39
39
  | `get_next_fixture` | a team's next match (3-letter code, e.g. `MEX`) — fully offline |
40
40
  | `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 |
41
- | `get_share_snippet` | a copy-pasteable plain-text match card — hand the returned snippet to the user as-is |
41
+ | `get_share_snippet` | a copy-pasteable plain-text card — for a match, a team's next fixture, a group's standings table (`group`), a date, or live — hand the returned snippet to the user as-is |
42
42
 
43
43
  All tools are **read-only** (`readOnlyHint`) and accept optional `tz`, `lang`
44
44
  (`en`/`es`/`pt`/`fr`), and `flavor` (`off`/`subtle`/`full`). Every response carries
package/dist/index.js CHANGED
@@ -2760,6 +2760,43 @@ function mapEspnEvent(ev, ctx = {}) {
2760
2760
  function toEspnDate(d) {
2761
2761
  return d.replace(/\D/g, "").slice(0, 8);
2762
2762
  }
2763
+ function statVal(stats, name) {
2764
+ const v = stats?.find((s) => s.name === name)?.value;
2765
+ return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : 0;
2766
+ }
2767
+ function entryToRow(e) {
2768
+ return {
2769
+ team: toTeam(e.team),
2770
+ played: statVal(e.stats, "gamesPlayed"),
2771
+ won: statVal(e.stats, "wins"),
2772
+ drawn: statVal(e.stats, "ties"),
2773
+ lost: statVal(e.stats, "losses"),
2774
+ goalsFor: statVal(e.stats, "pointsFor"),
2775
+ goalsAgainst: statVal(e.stats, "pointsAgainst"),
2776
+ goalDiff: statVal(e.stats, "pointDifferential"),
2777
+ points: statVal(e.stats, "points")
2778
+ };
2779
+ }
2780
+ function parseStandings(data) {
2781
+ const out = [];
2782
+ for (const child of data.children ?? []) {
2783
+ const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
2784
+ if (!letter) continue;
2785
+ const ranked = (child.standings?.entries ?? []).map((e) => ({
2786
+ row: entryToRow(e),
2787
+ rank: statVal(e.stats, "rank")
2788
+ }));
2789
+ ranked.sort((a, b) => {
2790
+ if (a.rank && b.rank && a.rank !== b.rank) return a.rank - b.rank;
2791
+ const r = a.row;
2792
+ const s = b.row;
2793
+ return s.points - r.points || s.goalDiff - r.goalDiff || s.goalsFor - r.goalsFor || r.team.code.localeCompare(s.team.code);
2794
+ });
2795
+ out.push({ group: letter, rows: ranked.map((x) => x.row) });
2796
+ }
2797
+ out.sort((a, b) => a.group.localeCompare(b.group));
2798
+ return out;
2799
+ }
2763
2800
  var EspnAdapter = class {
2764
2801
  constructor(opts = {}) {
2765
2802
  this.opts = opts;
@@ -2779,25 +2816,30 @@ var EspnAdapter = class {
2779
2816
  const today = await this.fetchScoreboard();
2780
2817
  return today.filter((m) => m.status === "LIVE" || m.status === "HT");
2781
2818
  }
2819
+ /** Standings endpoint URL (lives under apis/v2, not site/v2; derived from base). */
2820
+ standingsUrl() {
2821
+ const base = this.opts.baseUrl ?? DEFAULT_BASE;
2822
+ return `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
2823
+ }
2824
+ /**
2825
+ * Authoritative, cumulative group tables from the standings endpoint. Throws
2826
+ * on fetch/parse failure (the caller decides the fallback). Group-stage only:
2827
+ * non-group `children` are filtered out by {@link parseStandings}.
2828
+ */
2829
+ async fetchStandings() {
2830
+ return parseStandings(await this.get(this.standingsUrl()));
2831
+ }
2782
2832
  /**
2783
2833
  * Build (and cache) a team-code -> group-letter map from the standings
2784
- * endpoint. Best-effort: returns {} if standings are unavailable.
2834
+ * endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
2835
+ * same parse as {@link fetchStandings}, so the two never drift.
2785
2836
  */
2786
2837
  async fetchGroupMap(force = false) {
2787
2838
  if (this.groupMap && !force) return this.groupMap;
2788
- const base = this.opts.baseUrl ?? DEFAULT_BASE;
2789
- const standingsUrl = `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
2790
2839
  const map = {};
2791
2840
  try {
2792
- const data = await this.get(standingsUrl);
2793
- for (const child of data.children ?? []) {
2794
- const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
2795
- if (!letter) continue;
2796
- for (const e of child.standings?.entries ?? []) {
2797
- const code = e.team?.abbreviation?.toUpperCase();
2798
- if (code) map[code] = letter;
2799
- }
2800
- }
2841
+ const tables = parseStandings(await this.get(this.standingsUrl()));
2842
+ for (const t of tables) for (const r of t.rows) map[r.team.code] = t.group;
2801
2843
  } catch {
2802
2844
  }
2803
2845
  this.groupMap = map;
@@ -2868,6 +2910,22 @@ async function getMatchesForDate(adapter, dateISO) {
2868
2910
  return { matches: base, degraded: true };
2869
2911
  }
2870
2912
  }
2913
+ async function getStandings(adapter, group) {
2914
+ const want = group?.toUpperCase();
2915
+ if (adapter.fetchStandings) {
2916
+ try {
2917
+ const all = await adapter.fetchStandings();
2918
+ const tables2 = (want ? all.filter((t) => t.group === want) : all).sort(
2919
+ (a, b) => a.group.localeCompare(b.group)
2920
+ );
2921
+ return { tables: tables2, degraded: false, source: adapter.name };
2922
+ } catch {
2923
+ }
2924
+ }
2925
+ const letters = want ? [want] : groups();
2926
+ const tables = letters.map((g) => ({ group: g, rows: computeStandings(fixturesByGroup(g)) })).filter((t) => t.rows.length > 0);
2927
+ return { tables, degraded: true };
2928
+ }
2871
2929
  function shiftUtcDate(dateISO, days) {
2872
2930
  const [y, m, d] = dateISO.slice(0, 10).split("-").map(Number);
2873
2931
  return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days)).toISOString().slice(0, 10);
@@ -3407,11 +3465,50 @@ function formatShareSnippet(input, options = {}) {
3407
3465
  blocks.push(card.join("\n"));
3408
3466
  }
3409
3467
  }
3468
+ blocks.push(
3469
+ shareFooter({
3470
+ source: input.source,
3471
+ installLine: input.installLine,
3472
+ includeHashtag,
3473
+ includeInstall
3474
+ })
3475
+ );
3476
+ return blocks.join("\n\n");
3477
+ }
3478
+ function shareFooter(opts) {
3410
3479
  const footer = [];
3411
- if (input.source) footer.push(`Live data: ${liveSourceLabel(input.source)}`);
3412
- footer.push([includeHashtag ? SHARE_HASHTAG : "", SHARE_DISCLAIMER].filter(Boolean).join(" \xB7 "));
3413
- if (includeInstall && input.installLine) footer.push(`Try it: ${input.installLine}`);
3414
- blocks.push(footer.join("\n"));
3480
+ if (opts.source) footer.push(`Live data: ${liveSourceLabel(opts.source)}`);
3481
+ footer.push(
3482
+ [opts.includeHashtag ? SHARE_HASHTAG : "", SHARE_DISCLAIMER].filter(Boolean).join(" \xB7 ")
3483
+ );
3484
+ if (opts.includeInstall && opts.installLine) footer.push(`Try it: ${opts.installLine}`);
3485
+ return footer.join("\n");
3486
+ }
3487
+ function gd(n) {
3488
+ return n > 0 ? `+${n}` : `${n}`;
3489
+ }
3490
+ function tableRow(r, rank) {
3491
+ return `${rank}. ${r.team.flag} ${r.team.code} ${r.points} pts \xB7 ${r.won}-${r.drawn}-${r.lost} \xB7 ${gd(r.goalDiff)}`;
3492
+ }
3493
+ function formatShareTable(input, options = {}) {
3494
+ const includeHashtag = options.includeHashtag !== false;
3495
+ const includeInstall = options.includeInstallLine !== false;
3496
+ const blocks = [];
3497
+ if (input.tables.length === 0) {
3498
+ blocks.push(input.emptyNote ?? "No standings available.");
3499
+ } else {
3500
+ for (const { group, rows } of input.tables) {
3501
+ blocks.push(
3502
+ [`Group ${group} \xB7 standings`, "", ...rows.map((r, i) => tableRow(r, i + 1))].join("\n")
3503
+ );
3504
+ }
3505
+ if (input.degraded) {
3506
+ blocks.push("(Live standings unavailable \u2014 group roster, not live results.)");
3507
+ }
3508
+ }
3509
+ blocks.push(
3510
+ shareFooter({ source: input.source, installLine: input.installLine, includeHashtag, includeInstall })
3511
+ );
3415
3512
  return blocks.join("\n\n");
3416
3513
  }
3417
3514
 
@@ -3468,8 +3565,8 @@ function standingsTable(group, rows) {
3468
3565
  const cols = "Team P W D L GD Pts";
3469
3566
  const lines = rows.map((r) => {
3470
3567
  const name = `${r.team.flag} ${r.team.name}`.padEnd(24).slice(0, 24);
3471
- const gd = (r.goalDiff > 0 ? `+${r.goalDiff}` : `${r.goalDiff}`).padStart(3);
3472
- return `${name} ${pad(r.played)} ${pad(r.won)} ${pad(r.drawn)} ${pad(r.lost)} ${gd} ${pad(r.points)}`;
3568
+ const gd2 = (r.goalDiff > 0 ? `+${r.goalDiff}` : `${r.goalDiff}`).padStart(3);
3569
+ return `${name} ${pad(r.played)} ${pad(r.won)} ${pad(r.drawn)} ${pad(r.lost)} ${gd2} ${pad(r.points)}`;
3473
3570
  });
3474
3571
  return [header, cols, ...lines].join("\n");
3475
3572
  }
@@ -3644,34 +3741,34 @@ ${marketBlock(marketSignal, match).join("\n")}` : base;
3644
3741
  };
3645
3742
  }
3646
3743
  async function toolGetStandings(args) {
3647
- const { matches, degraded, source } = await getMatchesForDate(
3648
- resolveAdapter(args),
3649
- localDate((/* @__PURE__ */ new Date()).toISOString(), args.tz)
3650
- );
3651
- const known = groups(matches);
3652
- if (args.group) {
3653
- const g = args.group.toUpperCase();
3654
- if (!known.includes(g)) {
3655
- return {
3656
- text: withDisclaimer(`No group "${g}". Groups are ${known.join(", ")}.`, source),
3657
- data: { degraded, source: source ?? null, tables: null }
3658
- };
3659
- }
3744
+ const { tables, degraded, source } = await getStandings(resolveAdapter(args), args.group);
3745
+ const shaped = tables.map((tb) => ({ group: tb.group, standings: tb.rows }));
3746
+ if (shaped.length === 0) {
3747
+ const g = args.group?.toUpperCase();
3748
+ const msg = g ? `No group "${g}". Groups are A\u2013L.` : "No standings available.";
3749
+ return {
3750
+ text: withDisclaimer(degraded ? `${msg} (Live standings unavailable.)` : msg, source),
3751
+ data: { degraded, source: source ?? null, tables: args.group ? null : [] }
3752
+ };
3660
3753
  }
3661
- const wanted = args.group ? [args.group.toUpperCase()] : known;
3662
- const tables = wanted.map((g) => ({
3663
- group: g,
3664
- standings: computeStandings(fixturesByGroup(g, matches))
3665
- }));
3666
- const text = tables.map((t) => standingsTable(t.group, t.standings)).join("\n\n");
3754
+ let text = shaped.map((t) => standingsTable(t.group, t.standings)).join("\n\n");
3755
+ if (degraded) text += "\n\n(Live standings unavailable \u2014 showing the group roster.)";
3667
3756
  return {
3668
- text: withDisclaimer(text || `No group found.`, source),
3669
- data: { degraded, source: source ?? null, tables: args.group ? tables[0] ?? null : tables }
3757
+ text: withDisclaimer(text, source),
3758
+ data: { degraded, source: source ?? null, tables: args.group ? shaped[0] ?? null : shaped }
3670
3759
  };
3671
3760
  }
3761
+ async function standingsResourceText(group, adapter) {
3762
+ const g = group.toUpperCase();
3763
+ const { tables, degraded, source } = await getStandings(adapter, g);
3764
+ const tb = tables[0];
3765
+ let text = tb ? standingsTable(tb.group, tb.rows) : `No group ${g}.`;
3766
+ if (degraded && tb) text += "\n\n(Live standings unavailable \u2014 showing the group roster.)";
3767
+ return withDisclaimer(text, source);
3768
+ }
3672
3769
  async function toolGetNextFixture(args) {
3673
3770
  const code = args.team.toUpperCase();
3674
- const fixture = nextFixtureForTeam(code);
3771
+ const fixture = nextFixtureForTeam(code, { from: args.now ?? /* @__PURE__ */ new Date() });
3675
3772
  if (!fixture) {
3676
3773
  return {
3677
3774
  text: withDisclaimer(`No upcoming fixture found for ${code}.`),
@@ -3806,6 +3903,35 @@ async function toolGetShareSnippet(args) {
3806
3903
  { ...options, includeMarkets: false }
3807
3904
  );
3808
3905
  }
3906
+ if (args.group) {
3907
+ const group = args.group.toUpperCase();
3908
+ const { tables, degraded, source: source2 } = await getStandings(resolveAdapter(args), group);
3909
+ const snippet = formatShareTable(
3910
+ {
3911
+ tables,
3912
+ // Degraded ⇒ static roster, no live provider: don't attribute one, and
3913
+ // surface the not-live notice (the card gets pasted publicly).
3914
+ source: degraded ? void 0 : source2,
3915
+ installLine: `npx @claudinho/cli table ${group}`,
3916
+ emptyNote: `No group ${group}.`,
3917
+ degraded
3918
+ },
3919
+ options
3920
+ );
3921
+ return {
3922
+ text: snippet,
3923
+ data: {
3924
+ kind: "table",
3925
+ target: "table",
3926
+ group,
3927
+ source: degraded ? null : source2 ?? null,
3928
+ degraded,
3929
+ informationalOnly: true,
3930
+ snippet,
3931
+ tables: tables.map((tb) => ({ group: tb.group, standings: tb.rows }))
3932
+ }
3933
+ };
3934
+ }
3809
3935
  if (args.matchId) {
3810
3936
  const { match, source: source2 } = await getMatchById(resolveAdapter(args), args.matchId);
3811
3937
  const matches = match ? [match] : [];
@@ -3828,7 +3954,7 @@ async function toolGetShareSnippet(args) {
3828
3954
  }
3829
3955
  if (args.team) {
3830
3956
  const code = args.team.toUpperCase();
3831
- const fixture = nextFixtureForTeam(code);
3957
+ const fixture = nextFixtureForTeam(code, { from: args.now ?? /* @__PURE__ */ new Date() });
3832
3958
  const matches = fixture ? [fixture] : [];
3833
3959
  const teamName = fixture ? fixture.home.code === code ? fixture.home.name : fixture.away.name : code;
3834
3960
  return shareResult(
@@ -3871,7 +3997,7 @@ async function toolGetShareSnippet(args) {
3871
3997
 
3872
3998
  // src/server.ts
3873
3999
  var SERVER_NAME = "claudinho";
3874
- var SERVER_VERSION = "0.4.3";
4000
+ var SERVER_VERSION = "0.5.0";
3875
4001
  var VOICE = asFlavorLevel(process.env.CLAUDINHO_FLAVOR) === "off" ? "" : `
3876
4002
  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.`;
3877
4003
  var INSTRUCTIONS = `Claudinho serves live scores, fixtures, and group standings for the 2026 men's football tournament.
@@ -3905,7 +4031,7 @@ function buildServer() {
3905
4031
  "get_today",
3906
4032
  {
3907
4033
  title: "Today's matches",
3908
- description: "Fixtures for a given date (default: today), with live scores overlaid.",
4034
+ 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.",
3909
4035
  inputSchema: {
3910
4036
  date: dateArg.optional().describe("Date as YYYY-MM-DD (default: today)"),
3911
4037
  ...commonArgs
@@ -3919,7 +4045,7 @@ function buildServer() {
3919
4045
  "get_live",
3920
4046
  {
3921
4047
  title: "Live matches",
3922
- description: "Matches currently in play, with score and minute.",
4048
+ description: "Only matches in play right now \u2014 each with current score and minute (empty when nothing is live). Use during matches for in-play state; for a full day's schedule including upcoming and finished, use get_today. tz/lang/flavor affect formatting only.",
3923
4049
  inputSchema: { ...commonArgs },
3924
4050
  annotations: { readOnlyHint: true, openWorldHint: true }
3925
4051
  },
@@ -3929,7 +4055,7 @@ function buildServer() {
3929
4055
  "get_match",
3930
4056
  {
3931
4057
  title: "Match detail",
3932
- description: "A single match by its id, with live state if available.",
4058
+ description: "One match by its id, with live score/minute overlaid when it's in play. Get the id from get_today or get_live; to find a team's match without an id, use get_next_fixture. tz/lang/flavor affect formatting.",
3933
4059
  inputSchema: { id: z.string().describe("Match id"), ...commonArgs },
3934
4060
  annotations: { readOnlyHint: true, openWorldHint: true }
3935
4061
  },
@@ -3939,7 +4065,7 @@ function buildServer() {
3939
4065
  "get_standings",
3940
4066
  {
3941
4067
  title: "Group standings",
3942
- description: "Group table(s). Pass a group letter A\u2013L, or omit for all groups.",
4068
+ description: "Live cumulative group standings \u2014 pass a group letter A\u2013L, or omit for all 12. Returns ranked rows (team, played, W/D/L, goal difference, points). Use get_today for fixtures/scores and get_next_fixture for one team. Falls back to a roster at zero (flagged degraded) if live standings are unavailable.",
3943
4069
  inputSchema: {
3944
4070
  group: groupArg.optional().describe("Group letter A\u2013L (omit for all)"),
3945
4071
  ...commonArgs
@@ -3981,10 +4107,11 @@ function buildServer() {
3981
4107
  "get_share_snippet",
3982
4108
  {
3983
4109
  title: "Shareable match snippet",
3984
- description: "A polished, copy-pasteable match card (plain text) for a match (matchId), a team's next fixture (team), a date (default: today), or live matches (live: true). Returns the ready-to-paste snippet plus structured data \u2014 hand the snippet text to the user verbatim. No links; it carries a non-affiliation disclaimer, and any market line stays informational only.",
4110
+ description: `A polished, copy-pasteable card (plain text) for a match (matchId), a team's next fixture (team), a group's standings table (group, e.g. "A"), a date (default: today), or live matches (live: true). Returns the ready-to-paste snippet plus structured data \u2014 hand the snippet text to the user verbatim. No links; it carries a non-affiliation disclaimer, and any market line stays informational only.`,
3985
4111
  inputSchema: {
3986
4112
  matchId: z.string().optional().describe("Match id (most specific)"),
3987
4113
  team: teamArg.optional().describe("3-letter team code for that team's next fixture, e.g. MEX"),
4114
+ group: groupArg.optional().describe("Group letter A\u2013L for a standings card, e.g. A"),
3988
4115
  date: dateArg.optional().describe("Date as YYYY-MM-DD (default: today)"),
3989
4116
  live: z.boolean().optional().describe("Snapshot of matches in play right now"),
3990
4117
  style: z.enum(["social", "compact"]).optional().describe("social (default, full card) or compact (one line per match)"),
@@ -4003,13 +4130,12 @@ function buildServer() {
4003
4130
  new ResourceTemplate("standings://{group}", { list: void 0 }),
4004
4131
  {
4005
4132
  title: "Group standings",
4006
- description: "Static group table for a group letter A\u2013L.",
4133
+ description: "Live group table for a group letter A\u2013L.",
4007
4134
  mimeType: "text/plain"
4008
4135
  },
4009
4136
  async (uri, variables) => {
4010
- const group = String(variables.group ?? "").toUpperCase();
4011
- const rows = computeStandings(fixturesByGroup(group));
4012
- const text = rows.length ? standingsTable(group, rows) : `No group ${group}.`;
4137
+ const group = String(variables.group ?? "");
4138
+ const text = await standingsResourceText(group, makeAdapter());
4013
4139
  return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
4014
4140
  }
4015
4141
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudinho/mcp",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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",
@@ -54,7 +54,7 @@
54
54
  "tsup": "^8.0.0",
55
55
  "typescript": "^5.7.0",
56
56
  "vitest": "^4.1.0",
57
- "@claudinho/core": "0.4.3"
57
+ "@claudinho/core": "0.5.0"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "tsup",