cito-mcp 0.3.4 → 0.3.6

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/install.js CHANGED
@@ -264,21 +264,21 @@ function parseArgs(argv) {
264
264
  }
265
265
  return out;
266
266
  }
267
- const HELP = `cito-mcp install — configure the Cito MCP server for your editors
268
-
269
- Usage:
270
- npx cito-mcp install --key cito_xxx
271
- npx cito-mcp install --key cito_xxx --client cursor,claude-code
272
- npx cito-mcp install --dry-run
273
-
274
- Options:
275
- --key <key> Cito API key. Falls back to $CITO_API_KEY.
276
- --client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
277
- Default: every client detected on this machine.
278
- --base <url> Override API base (staging / self-hosted).
279
- --dry-run, -n Show what would change; write nothing.
280
- --help, -h This message.
281
-
267
+ const HELP = `cito-mcp install — configure the Cito MCP server for your editors
268
+
269
+ Usage:
270
+ npx cito-mcp install --key cito_xxx
271
+ npx cito-mcp install --key cito_xxx --client cursor,claude-code
272
+ npx cito-mcp install --dry-run
273
+
274
+ Options:
275
+ --key <key> Cito API key. Falls back to $CITO_API_KEY.
276
+ --client <ids> Comma-separated: claude-code, claude-desktop, cursor, windsurf, codex, grok.
277
+ Default: every client detected on this machine.
278
+ --base <url> Override API base (staging / self-hosted).
279
+ --dry-run, -n Show what would change; write nothing.
280
+ --help, -h This message.
281
+
282
282
  Get a key at https://citoapi.com/dashboard`;
283
283
  export async function runInstall(argv) {
284
284
  const args = parseArgs(argv);
@@ -422,13 +422,25 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
422
422
  warnings.push(`Ignored filter "${param}": ${reason}`);
423
423
  };
424
424
  let path = '';
425
+ // Set when this tool, not the API, is responsible for the team filter. It
426
+ // also moves paging client-side, because an upstream offset would be
427
+ // counted against rows we are about to discard.
428
+ let clientTeamFilter = false;
425
429
  let query = { limit, offset };
426
430
  if (game === 'lol') {
427
431
  path = '/lol/schedule/upcoming';
432
+ // The upstream accepts teamSlug but does not apply it: asking for t1
433
+ // returned Dplus KIA, Team WE and NightBirds. That is a silently wrong
434
+ // answer, which is worse than an error, so the filter is enforced below.
435
+ // Filtering client-side means the upstream offset would be counted
436
+ // against unfiltered rows, so when a team is named we over-fetch and page
437
+ // here instead.
438
+ if (team)
439
+ clientTeamFilter = true;
428
440
  query = {
429
441
  hours: String(hours),
430
- limit: String(limit),
431
- offset: String(offset),
442
+ limit: String(clientTeamFilter ? Math.min(200, (offset + limit) * 4) : limit),
443
+ ...(clientTeamFilter ? {} : { offset: String(offset) }),
432
444
  ...(league ? { leagueSlug: league } : {}),
433
445
  ...(team ? { teamSlug: team } : {}),
434
446
  };
@@ -514,12 +526,7 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
514
526
  noteIgnored('tournamentId', 'tennis fixtures have no tournamentId filter');
515
527
  if (team)
516
528
  noteIgnored('team', 'tennis fixtures have no team filter; match player names client-side');
517
- if (args.hours !== undefined)
518
- noteIgnored('hours', 'tennis fixtures cover ~3 days; filter by startTime client-side');
519
- if (from)
520
- noteIgnored('from', 'tennis fixtures cover ~3 days; filter by startTime client-side');
521
- if (to)
522
- noteIgnored('to', 'tennis fixtures cover ~3 days; filter by startTime client-side');
529
+ // hours / from / to applied client-side after fetch (below) — not warned as ignored.
523
530
  }
524
531
  const res = await fetchJson(ctx, path, { query });
525
532
  if (!res.ok) {
@@ -600,8 +607,32 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
600
607
  }
601
608
  }
602
609
  }
610
+ // Tennis: client-side hours / from / to window (fixtures list has no time params)
611
+ if (game === 'tennis' && (args.hours !== undefined || from || to)) {
612
+ const now = Date.now();
613
+ const fromMs = from ? Date.parse(from) : now;
614
+ const toMs = to ? Date.parse(to) : now + hours * 3600_000;
615
+ if (Number.isFinite(fromMs) && Number.isFinite(toMs)) {
616
+ const before = rows.length;
617
+ rows = rows.filter((row) => {
618
+ const r = asRecord(row) ?? {};
619
+ const ts = pickString(r.startTime, r.start_time, r.startsAt);
620
+ if (!ts)
621
+ return true; // keep undated rows rather than drop them
622
+ const t = Date.parse(ts);
623
+ if (!Number.isFinite(t))
624
+ return true;
625
+ return t >= fromMs && t <= toMs;
626
+ });
627
+ if (before > 0 && rows.length === 0) {
628
+ warnings.push(`hours/from/to window matched 0 of ${before} tennis fixtures — widen hours or omit time filters`);
629
+ }
630
+ }
631
+ }
603
632
  // Client-side team filter when API ignored it
604
- if (team && (game === 'dota2' || game === 'cs2')) {
633
+ let beforeTeamFilter = rows.length;
634
+ if (team && (game === 'dota2' || game === 'cs2' || clientTeamFilter)) {
635
+ beforeTeamFilter = rows.length;
605
636
  const t = team.toLowerCase();
606
637
  rows = rows.filter((row) => {
607
638
  const m = normalizeMatch(game, row, 'upcoming');
@@ -612,7 +643,11 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
612
643
  return hay.includes(t);
613
644
  });
614
645
  }
615
- const items = rows.slice(0, limit).map((row) => normalizeMatch(game, row, 'upcoming'));
646
+ if (team && beforeTeamFilter > 0 && rows.length === 0) {
647
+ warnings.push(`team "${team}" matched 0 of ${beforeTeamFilter} upcoming fixtures in this window — widen hours or check the slug`);
648
+ }
649
+ const items = (clientTeamFilter ? rows.slice(offset, offset + limit) : rows.slice(0, limit))
650
+ .map((row) => normalizeMatch(game, row, 'upcoming'));
616
651
  const obj = asRecord(res.data);
617
652
  const total = typeof obj?.total === 'number' ? obj.total : null;
618
653
  const hasMore = (typeof obj?.hasMore === 'boolean' ? obj.hasMore : items.length >= limit) ||
@@ -8,6 +8,50 @@ import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from
8
8
  async function getSection(ctx, path, query) {
9
9
  return fetchJson(ctx, path, { query });
10
10
  }
11
+ /**
12
+ * Series length. The LoL feed states it as `strategy: "Bo5"`, which nothing was
13
+ * reading, so every match reported bestOf: null. Accept the spelled forms and
14
+ * the object shape other titles use, and fall back to the declared game count.
15
+ * Only 1/3/5/7/9 are real series lengths, so anything else is refused rather
16
+ * than guessed.
17
+ */
18
+ export function parseBestOf(r) {
19
+ const fromNumber = (v) => {
20
+ const n = Number(v);
21
+ return Number.isFinite(n) && [1, 3, 5, 7, 9].includes(n) ? n : null;
22
+ };
23
+ const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo);
24
+ if (direct)
25
+ return direct;
26
+ const strat = r.strategy;
27
+ if (typeof strat === 'string') {
28
+ const m = strat.match(/(\d+)/);
29
+ if (m) {
30
+ const n = fromNumber(m[1]);
31
+ if (n)
32
+ return n;
33
+ }
34
+ }
35
+ // { type: "bestOf", count: 5 }
36
+ if (strat && typeof strat === 'object') {
37
+ const o = strat;
38
+ const n = fromNumber(o.count) ?? fromNumber(o.value) ?? fromNumber(o.bestOf);
39
+ if (n)
40
+ return n;
41
+ }
42
+ for (const key of ['format', 'seriesType', 'matchFormat']) {
43
+ const v = r[key];
44
+ if (typeof v === 'string') {
45
+ const m = v.match(/(\d+)/);
46
+ if (m) {
47
+ const n = fromNumber(m[1]);
48
+ if (n)
49
+ return n;
50
+ }
51
+ }
52
+ }
53
+ return fromNumber(r.gameCount) ?? null;
54
+ }
11
55
  function matchCore(game, matchId, raw) {
12
56
  const entity = unwrapPayload(raw);
13
57
  const m = normalizeMatch(game, entity);
@@ -23,7 +67,7 @@ function matchCore(game, matchId, raw) {
23
67
  status: m.status,
24
68
  startTime: m.startTime,
25
69
  endTime: pickString(r.endTime, r.endedAt, r.finishedAt) ?? null,
26
- bestOf: r.bestOf ?? r.bo ?? null,
70
+ bestOf: parseBestOf(r),
27
71
  label: m.label,
28
72
  scoreline,
29
73
  score: {
@@ -260,8 +260,23 @@ export function normalizeMatch(game, row, forcedStatus) {
260
260
  r.resultRound != null ||
261
261
  r.isComplete === true ||
262
262
  r.completed === true;
263
- if (/live|running|in_?progress|ongoing|started|watching/.test(statusRaw))
263
+ // "started" as a bare substring also matches "not_started", which is how a
264
+ // fixture that has not begun was being served to customers as live. Require
265
+ // word boundaries, refuse the explicit not-started spellings, and refuse any
266
+ // match whose kickoff is still in the future: a scheduled match cannot be
267
+ // in progress no matter what the upstream status string says.
268
+ const unstarted = /not[_\s-]?started|unstarted|not[_\s-]?begun|yet[_\s-]?to[_\s-]?start/.test(statusRaw);
269
+ const looksLive = !unstarted && /\b(live|running|in[_\s-]?progress|ongoing|started|watching)\b/.test(statusRaw);
270
+ const kickoffPassed = (() => {
271
+ if (!startTime)
272
+ return true; // no clock to contradict the feed
273
+ const t = Date.parse(startTime);
274
+ return !Number.isFinite(t) || t <= Date.now();
275
+ })();
276
+ if (looksLive && kickoffPassed)
264
277
  status = 'live';
278
+ else if (looksLive && !kickoffPassed)
279
+ status = 'upcoming';
265
280
  else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
266
281
  status = 'completed';
267
282
  }
@@ -367,20 +367,20 @@ async function searchGame(ctx, game, q, type, limit) {
367
367
  }
368
368
  export const resolveEntity = {
369
369
  name: 'resolve_entity',
370
- description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
371
-
372
- When to use:
373
- - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
374
- - Need a canonical id/slug before profile or match tools
375
-
376
- Prefer over search_entities when you want one best match (or small ranked set) to chain.
377
- Prefer search_entities when browsing many results with pagination.
378
-
379
- Do not use when: you already have a stable id/slug from a prior tool.
380
-
381
- Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
382
-
383
- Parallel-safe: yes. Upstream cost: 1–5.
370
+ description: `Natural-language / fuzzy query → best typed entity ID(s) + game (player, team, event, tournament, match, fighter).
371
+
372
+ When to use:
373
+ - User named an entity without an ID ("T1", "s1mple", "IEM Cologne", "Islam Makhachev")
374
+ - Need a canonical id/slug before profile or match tools
375
+
376
+ Prefer over search_entities when you want one best match (or small ranked set) to chain.
377
+ Prefer search_entities when browsing many results with pagination.
378
+
379
+ Do not use when: you already have a stable id/slug from a prior tool.
380
+
381
+ Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
382
+
383
+ Parallel-safe: yes. Upstream cost: 1–5.
384
384
  Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
385
385
  inputSchema: {
386
386
  type: 'object',
@@ -474,22 +474,22 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
474
474
  };
475
475
  export const searchEntities = {
476
476
  name: 'search_entities',
477
- description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
478
-
479
- When to use:
480
- - Typeahead / pickers
481
- - "List teams matching…"
482
- - Exploring entities without committing to one ID
483
- - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
484
-
485
- Prefer over resolve_entity when the user wants a list.
486
- Prefer resolve_entity when chaining one name into a profile tool.
487
-
488
- Do not use when: fetching a known entity profile — use team_profile or player_profile.
489
-
490
- UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
491
-
492
- Parallel-safe: yes. Upstream cost: 1–3.
477
+ description: `Browse/search teams, players, tournaments, events, fighters with type filter and pagination.
478
+
479
+ When to use:
480
+ - Typeahead / pickers
481
+ - "List teams matching…"
482
+ - Exploring entities without committing to one ID
483
+ - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
484
+
485
+ Prefer over resolve_entity when the user wants a list.
486
+ Prefer resolve_entity when chaining one name into a profile tool.
487
+
488
+ Do not use when: fetching a known entity profile — use team_profile or player_profile.
489
+
490
+ UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
491
+
492
+ Parallel-safe: yes. Upstream cost: 1–3.
493
493
  Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
494
494
  inputSchema: {
495
495
  type: 'object',
@@ -533,6 +533,11 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
533
533
  let items = [];
534
534
  let upstreamCalls = 0;
535
535
  let total = null;
536
+ // pageQuery sends offset/page upstream, so what comes back IS the page and
537
+ // must not be sliced by offset again. Doing both returned an EMPTY page two
538
+ // while reporting total=1429 and hasMore=true. The UFC paths below fetch
539
+ // page 1 deliberately and re-rank in memory, so they page here instead.
540
+ let upstreamPaged = true;
536
541
  const pageQuery = (extra = {}) => ({
537
542
  limit,
538
543
  offset,
@@ -649,6 +654,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
649
654
  }
650
655
  else if (game === 'cod') {
651
656
  if (q) {
657
+ // No offset goes upstream on this path, so page it here.
658
+ upstreamPaged = false;
652
659
  const res = await fetchJson(ctx, '/cod/search', {
653
660
  query: { q, limit, ...(type !== 'any' ? { type: type === 'team' ? 'org' : type } : {}) },
654
661
  });
@@ -668,6 +675,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
668
675
  else if (game === 'ufc') {
669
676
  if (q) {
670
677
  // Dedicated search ranks name/slug/nickname; /fighters ignores q.
678
+ // Returns the whole match set for client re-ranking; page it here.
679
+ upstreamPaged = false;
671
680
  const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
672
681
  upstreamCalls += 1;
673
682
  if (!res.ok) {
@@ -762,8 +771,10 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
762
771
  });
763
772
  }
764
773
  // pagination over ranked result set
765
- const pageItems = items.slice(offset, offset + limit);
766
- const hasMore = offset + pageItems.length < items.length || (!q && items.length >= limit);
774
+ const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
775
+ const hasMore = upstreamPaged
776
+ ? (total != null ? offset + pageItems.length < total : pageItems.length >= limit)
777
+ : offset + pageItems.length < items.length;
767
778
  return successEnvelope({
768
779
  source: 'search_entities',
769
780
  game,
@@ -285,20 +285,32 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
285
285
  }
286
286
  })());
287
287
  tasks.push((async () => {
288
- const res = await fetchJson(ctx, '/lol/schedule', {
289
- query: { teamSlug: team.slug || idOrSlug, completed: 'true', limit: String(recentLimit) },
288
+ // The matches index, not /lol/schedule. Schedule returned the team's
289
+ // oldest fixtures: a T1 profile showed MSI 2023 as "recent" while the
290
+ // index had the 2026 LCK split, newest first. Sort here anyway rather
291
+ // than trusting upstream order, so the contract holds if either
292
+ // endpoint changes.
293
+ const slug = team.slug || idOrSlug;
294
+ const res = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(slug)}/matches`, {
295
+ query: { limit: String(recentLimit) },
290
296
  });
291
297
  upstreamCalls += 1;
292
298
  rateLimit = { ...rateLimit, ...res.headers };
293
299
  if (res.ok) {
300
+ const startMs = (row) => {
301
+ const t = Date.parse(String(row?.startTime ?? ''));
302
+ return Number.isFinite(t) ? t : -Infinity;
303
+ };
294
304
  recentMatches = extractRows(res.data)
305
+ .slice()
306
+ .sort((a, b) => startMs(b) - startMs(a))
295
307
  .slice(0, recentLimit)
296
308
  .map((row) => normalizeMatch('lol', row, 'completed'));
297
309
  }
298
310
  else {
299
311
  partial.push(partialFromRejection('recentMatches', {
300
312
  code: mapHttpToCode(res.status),
301
- message: `schedule HTTP ${res.status}`,
313
+ message: `team matches HTTP ${res.status}`,
302
314
  httpStatus: res.status,
303
315
  }));
304
316
  }
@@ -664,6 +676,68 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
664
676
  rows = extractRows(res.data);
665
677
  }
666
678
  else if (game === 'lol') {
679
+ // First-class H2H. The old path pulled sideA's /lol/schedule and filtered
680
+ // client-side, which missed meetings and returned the oldest first, so a
681
+ // T1 vs HLE rivalry showed 2023 games and called the earliest one the
682
+ // "last meeting". This endpoint states the record outright (22 meetings,
683
+ // 8-13) and lists them newest first.
684
+ const h2hRes = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(sideA)}/h2h/${encodeURIComponent(sideB)}`, { query: { limit: String(limit) } });
685
+ upstreamCalls += 1;
686
+ rateLimit = { ...rateLimit, ...h2hRes.headers };
687
+ if (h2hRes.ok) {
688
+ const env = asRecord(h2hRes.data) ?? {};
689
+ const h2h = asRecord(env.data) ?? env;
690
+ const agg = asRecord(h2h.matches) ?? {};
691
+ const opponent = asRecord(h2h.opponent) ?? {};
692
+ const startMs = (v) => {
693
+ const t = Date.parse(String(v ?? ''));
694
+ return Number.isFinite(t) ? t : -Infinity;
695
+ };
696
+ const meetingRows = extractRows(h2h.recentMatches)
697
+ .slice()
698
+ .sort((a, b) => startMs(asRecord(b)?.date) - startMs(asRecord(a)?.date))
699
+ .slice(0, limit)
700
+ .map((row) => {
701
+ const r = asRecord(row) ?? {};
702
+ return {
703
+ matchId: pickString(r.matchId) ?? null,
704
+ startTime: pickString(r.date) ?? null,
705
+ tournamentName: pickString(r.tournamentName) ?? null,
706
+ // The feed gives scores in the match's own team order, which it
707
+ // does not label, so they are reported as-is rather than being
708
+ // assigned to a side we would be guessing at.
709
+ team1Score: typeof r.team1Score === 'number' ? r.team1Score : null,
710
+ team2Score: typeof r.team2Score === 'number' ? r.team2Score : null,
711
+ winnerSlug: pickString(r.winner) ?? null,
712
+ };
713
+ });
714
+ const wins = typeof agg.wins === 'number' ? agg.wins : null;
715
+ const losses = typeof agg.losses === 'number' ? agg.losses : null;
716
+ const total = typeof agg.total === 'number' ? agg.total : meetingRows.length;
717
+ return successEnvelope({
718
+ source: 'head_to_head',
719
+ game,
720
+ requestId,
721
+ tookMs: Date.now() - started,
722
+ upstreamCalls,
723
+ rateLimit,
724
+ data: {
725
+ sideA: { idOrSlug: sideA },
726
+ sideB: { idOrSlug: sideB, name: pickString(opponent.name) ?? null },
727
+ record: {
728
+ winsA: wins,
729
+ winsB: losses,
730
+ draws: 0,
731
+ meetings: total,
732
+ },
733
+ games: asRecord(h2h.games) ?? null,
734
+ meetings: meetingRows,
735
+ lastMeeting: meetingRows[0] ?? null,
736
+ notes: ['Record and meetings come from the first-class /lol/teams/{slug}/h2h/{opponent} endpoint'],
737
+ },
738
+ });
739
+ }
740
+ warnings.push(`first-class LoL H2H unavailable (HTTP ${h2hRes.status}); falling back to schedule filtering`);
667
741
  const res = await fetchJson(ctx, '/lol/schedule', {
668
742
  query: { teamSlug: sideA, completed: 'true', limit: '50' },
669
743
  });
@@ -832,6 +906,17 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
832
906
  if (!Number.isNaN(toMs) && t > toMs)
833
907
  return false;
834
908
  return true;
909
+ })
910
+ // Newest first. Nothing sorted this before, so `lastMeeting` below was
911
+ // whichever row the upstream happened to return first, which on an
912
+ // ascending feed is the OLDEST meeting. It also means a limit cut off the
913
+ // recent games rather than the ancient ones.
914
+ .sort((a, b) => {
915
+ const ta = a.startTime ? Date.parse(a.startTime) : NaN;
916
+ const tb = b.startTime ? Date.parse(b.startTime) : NaN;
917
+ const va = Number.isFinite(ta) ? ta : -Infinity;
918
+ const vb = Number.isFinite(tb) ? tb : -Infinity;
919
+ return vb - va;
835
920
  })
836
921
  .slice(0, limit);
837
922
  let winsA = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {