@claudinho/mcp 0.8.19 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +137 -34
- package/package.json +5 -3
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
|
-
|
|
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 = "
|
|
2911
|
+
var USER_AGENT = `claudinho/${"0.9.0"} (+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
|
|
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
|
|
3088
|
-
*
|
|
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 =
|
|
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 =
|
|
3107
|
-
|
|
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 ??
|
|
3167
|
+
this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
3116
3168
|
);
|
|
3169
|
+
this.lastError = void 0;
|
|
3117
3170
|
try {
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3901
|
-
|
|
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
|
|
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
|
-
|
|
3943
|
-
|
|
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
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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.
|
|
5379
|
+
var SERVER_VERSION = "0.9.0";
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claudinho/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -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.
|
|
62
|
+
"@claudinho/core": "0.9.0"
|
|
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
|
}
|