cito-mcp 0.3.7 → 0.3.9

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.
@@ -280,7 +280,7 @@ export function normalizeMatch(game, row, forcedStatus) {
280
280
  else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
281
281
  status = 'completed';
282
282
  }
283
- else if (/upcoming|scheduled|not_started|pending|soon|booked|confirmed|announced/.test(statusRaw) ||
283
+ else if (/upcoming|scheduled|not_?started|unstarted|pending|soon|booked|confirmed|announced|tbd/.test(statusRaw) ||
284
284
  (game === 'ufc' && !hasResult && (startTime || statusRaw === '' || statusRaw === 'unknown'))) {
285
285
  // UFC bouts often omit status or use sparse values; default upcoming when no result yet.
286
286
  if (hasResult)
@@ -9,9 +9,20 @@ function identityFrom(game, raw, idHint, slugHint) {
9
9
  const r = asRecord(raw) ?? {};
10
10
  const id = pickString(r.id, r.playerId, r.lolPlayerId, r.codPlayerId, idHint, slugHint) ?? 'unknown';
11
11
  const slug = pickString(r.slug, slugHint);
12
- const name = pickString(r.name, r.full_name, r.nickname, r.displayName, r.tag, slug, id) ?? id;
12
+ // currentIgn FIRST for LoL. The row carries currentIgn "Faker" and realName
13
+ // "Sanghyeok Lee" but no `name`, so this fell through every key to the id and
14
+ // served a raw UUID as the player's display name.
15
+ const name = pickString(r.name, r.currentIgn, r.ign, r.full_name, r.nickname, r.displayName, r.tag, r.realName, slug, id) ?? id;
16
+ // currentTeam is a STRING on the LoL row ("T1"), not an object, so asRecord
17
+ // returned null and the profile reported a team of "?" while the slug sat
18
+ // right there in currentTeamSlug.
19
+ const currentTeamName = pickString(r.currentTeam);
20
+ const currentTeamSlug = pickString(r.currentTeamSlug, r.teamSlug, r.orgSlug);
13
21
  const team = asRecord(r.team) ??
14
22
  asRecord(r.currentTeam) ??
23
+ (currentTeamName || currentTeamSlug
24
+ ? { name: currentTeamName ?? currentTeamSlug, slug: currentTeamSlug, id: currentTeamSlug }
25
+ : null) ??
15
26
  (r.teamName || r.orgSlug
16
27
  ? { name: r.teamName, slug: r.orgSlug, id: r.teamId }
17
28
  : null);
@@ -24,7 +35,7 @@ function identityFrom(game, raw, idHint, slugHint) {
24
35
  ? {
25
36
  id: pickString(asRecord(team)?.id, asRecord(team)?.teamId),
26
37
  slug: pickString(asRecord(team)?.slug, asRecord(team)?.orgSlug),
27
- name: pickString(asRecord(team)?.name) ?? '?',
38
+ name: pickString(asRecord(team)?.name, asRecord(team)?.slug) ?? '?',
28
39
  }
29
40
  : null,
30
41
  role: pickString(r.role, r.position) ?? null,
@@ -204,11 +215,30 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
204
215
  const rows = extractRows(res.data);
205
216
  const first = asRecord(rows[0]);
206
217
  if (first) {
207
- currentTeam = {
208
- id: pickString(first.id, first.teamId),
209
- slug: pickString(first.slug),
210
- name: pickString(first.name) ?? '?',
211
- };
218
+ // NEVER let team history overwrite the player row's own team.
219
+ // This endpoint returns showmatch and novelty orgs alongside the
220
+ // real one, all marked status "current" — for Faker the first row
221
+ // is literally "Faker", then "Captain Faker", then "KR with
222
+ // Influencers". Taking rows[0] reported the player as their own
223
+ // team. The player row's currentTeamSlug is the answer; history is
224
+ // only a fallback, and then only for a row that looks like a real
225
+ // club rather than the player's own name.
226
+ const rows2 = rows.map((row) => asRecord(row) ?? {});
227
+ const preferred = (player.team?.slug
228
+ ? rows2.find((x) => pickString(x.orgSlug, x.slug) === player.team?.slug)
229
+ : null) ??
230
+ rows2.find((x) => x.status === 'current' && pickString(x.teamName, x.name) !== player.name) ??
231
+ first;
232
+ const histName = pickString(preferred.name, preferred.teamName, preferred.orgName);
233
+ const histSlug = pickString(preferred.slug, preferred.orgSlug, preferred.teamSlug);
234
+ const alreadyResolved = Boolean(currentTeam?.slug && currentTeam.name && currentTeam.name !== '?');
235
+ if (!alreadyResolved && (histName || histSlug)) {
236
+ currentTeam = {
237
+ id: pickString(preferred.id, preferred.teamId) ?? histSlug ?? undefined,
238
+ slug: histSlug ?? undefined,
239
+ name: histName ?? histSlug ?? '?',
240
+ };
241
+ }
212
242
  }
213
243
  }
214
244
  else {
@@ -220,6 +250,67 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
220
250
  }
221
251
  })());
222
252
  }
253
+ if (game === 'lol') {
254
+ // /lol/players/{id}/matches exists and returns the player's real match
255
+ // index; only tennis was wired to its equivalent, so every LoL profile
256
+ // shipped recentMatches: [].
257
+ tasks.push((async () => {
258
+ const res = await fetchJson(ctx, `/lol/players/${encodeURIComponent(idOrSlug)}/matches`, {
259
+ query: { limit: String(recentLimit) },
260
+ });
261
+ upstreamCalls += 1;
262
+ rateLimit = { ...rateLimit, ...res.headers };
263
+ if (res.ok) {
264
+ const startMs = (row) => {
265
+ const r = asRecord(row) ?? {};
266
+ const t = Date.parse(String(r.startTime ?? r.date ?? ''));
267
+ return Number.isFinite(t) ? t : -Infinity;
268
+ };
269
+ const picked = extractRows(res.data)
270
+ .slice()
271
+ .sort((a, b) => startMs(b) - startMs(a))
272
+ .slice(0, recentLimit);
273
+ // The index carries team SLUGS and no names, so labels read
274
+ // "hle vs t1". Resolve the distinct slugs once — deduplicated and
275
+ // capped — rather than a lookup per match, and fall back to the
276
+ // slug when a lookup fails so a label is never blank.
277
+ const slugs = [
278
+ ...new Set(picked.flatMap((row) => {
279
+ const r = asRecord(row) ?? {};
280
+ return [pickString(r.team1Slug), pickString(r.team2Slug)];
281
+ }).filter((x) => Boolean(x))),
282
+ ].slice(0, 12);
283
+ const names = new Map();
284
+ await Promise.all(slugs.map(async (sl) => {
285
+ const t = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(sl)}`);
286
+ upstreamCalls += 1;
287
+ if (!t.ok)
288
+ return;
289
+ const body = asRecord(t.data) ?? {};
290
+ const nm = pickString(body.name, asRecord(body.data)?.name);
291
+ if (nm)
292
+ names.set(sl, nm);
293
+ }));
294
+ recentMatches = picked.map((row) => {
295
+ const r = asRecord(row) ?? {};
296
+ const s1 = pickString(r.team1Slug);
297
+ const s2 = pickString(r.team2Slug);
298
+ return normalizeMatch('lol', {
299
+ ...r,
300
+ ...(s1 ? { team1: { slug: s1, name: names.get(s1) ?? s1, score: r.team1Score } } : {}),
301
+ ...(s2 ? { team2: { slug: s2, name: names.get(s2) ?? s2, score: r.team2Score } } : {}),
302
+ });
303
+ });
304
+ }
305
+ else {
306
+ partial.push(partialFromRejection('recentMatches', {
307
+ code: mapHttpToCode(res.status),
308
+ message: `player matches HTTP ${res.status}`,
309
+ httpStatus: res.status,
310
+ }));
311
+ }
312
+ })());
313
+ }
223
314
  if (game === 'tennis') {
224
315
  if (includeTrends) {
225
316
  tasks.push((async () => {
@@ -5,6 +5,20 @@ import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIn
5
5
  import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
6
  import { entityRef, rankScore } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
+ /**
9
+ * Query aliases for orgs whose common name is not their slug. Kept deliberately
10
+ * small and one-directional: these map what a person types onto the canonical
11
+ * slug, and are only used to grant an exact-match boost, never to rewrite a
12
+ * result. SK Telecom T1 became T1 in 2019; sk-telecom-t1-k and -s remain
13
+ * separate historical rosters and are NOT folded in.
14
+ */
15
+ const normalizeQueryKey = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9]/g, '');
16
+ const ORG_ALIASES = {
17
+ skt: 't1',
18
+ sktt1: 't1',
19
+ sktelecomt1: 't1',
20
+ skt1: 't1',
21
+ };
8
22
  function pushCandidates(out, game, type, rows, q, limit) {
9
23
  for (const row of rows) {
10
24
  const ref = entityRef(row, type, game);
@@ -268,15 +282,33 @@ async function searchGame(ctx, game, q, type, limit) {
268
282
  const tasks = [];
269
283
  if (type === 'any' || type === 'team') {
270
284
  tasks.push((async () => {
271
- const res = await fetchJson(ctx, '/lol/teams', { query: { search: q, limit: String(limit) } });
285
+ // Over-fetch before ranking. The upstream returns search hits in
286
+ // alphabetical order, so asking for `limit` rows put the exact
287
+ // match outside the window: "T1" returned mt1-esports, mt1-vision
288
+ // and two SK Telecom rosters while the real t1 sat sixth and was
289
+ // never seen. Rank a wide window, then cut.
290
+ const wide = String(Math.max(50, limit * 5));
291
+ const res = await fetchJson(ctx, '/lol/teams', { query: { search: q, limit: wide } });
272
292
  calls += 1;
273
293
  if (res.ok)
274
294
  pushCandidates(candidates, game, 'team', extractRows(res.data), q, limit);
295
+ // An alias is a name the API does not know. "SKT" returns no t1 row
296
+ // at all, so ranking cannot help; search the canonical slug too and
297
+ // score those rows against the canonical name.
298
+ const aliasTarget = ORG_ALIASES[normalizeQueryKey(q)];
299
+ if (aliasTarget) {
300
+ const alt = await fetchJson(ctx, '/lol/teams', { query: { search: aliasTarget, limit: wide } });
301
+ calls += 1;
302
+ if (alt.ok)
303
+ pushCandidates(candidates, game, 'team', extractRows(alt.data), aliasTarget, limit);
304
+ }
275
305
  })());
276
306
  }
277
307
  if (type === 'any' || type === 'player') {
278
308
  tasks.push((async () => {
279
- const res = await fetchJson(ctx, '/lol/players', { query: { search: q, limit: String(limit) } });
309
+ const res = await fetchJson(ctx, '/lol/players', {
310
+ query: { search: q, limit: String(Math.max(50, limit * 5)) },
311
+ });
280
312
  calls += 1;
281
313
  if (res.ok)
282
314
  pushCandidates(candidates, game, 'player', extractRows(res.data), q, limit);
@@ -444,8 +476,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
444
476
  all.sort((a, b) => b.score - a.score);
445
477
  const candidates = all.slice(0, limit);
446
478
  const best = candidates[0] ?? null;
479
+ // An exact identity hit is not ambiguous, whatever else scored near it.
480
+ // "T1" ties with t1-rookies and t1-challengers on fuzzy score, which left
481
+ // the caller with needsDisambiguation and no decision to act on even though
482
+ // one candidate matched the query exactly. Aliases count: "SKT" resolves
483
+ // through ORG_ALIASES to the canonical org.
484
+ const qKey = normalizeQueryKey(q);
485
+ const aliasKey = ORG_ALIASES[qKey] ? normalizeQueryKey(ORG_ALIASES[qKey]) : null;
486
+ const bestIsExact = !!best &&
487
+ [best.slug, best.id, best.name].some((v) => {
488
+ const n = normalizeQueryKey(v);
489
+ return n && (n === qKey || (aliasKey !== null && n === aliasKey));
490
+ });
447
491
  const needsDisambiguation = !best ||
448
- (candidates.length > 1 && (candidates[1].score >= best.score - 5 || best.score < 50));
492
+ (!bestIsExact &&
493
+ candidates.length > 1 &&
494
+ (candidates[1].score >= best.score - 5 || best.score < 50));
449
495
  return successEnvelope({
450
496
  source: 'resolve_entity',
451
497
  game: best?.game ?? gameParse.game ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
5
5
  "type": "module",
6
6
  "bin": {