cito-mcp 0.4.4 → 0.4.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.
@@ -1,6 +1,3 @@
1
- /**
2
- * team_profile + head_to_head
3
- */
4
1
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
2
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
3
  import { normalizeMatch } from './normalize.js';
@@ -9,9 +6,6 @@ function teamIdentity(game, raw, idHint, slugHint) {
9
6
  const r = asRecord(raw) ?? {};
10
7
  const nested = asRecord(r.data) ?? r;
11
8
  const slug = pickString(nested.slug, nested.orgSlug, slugHint);
12
- // When the caller asks for an alias the API resolves it, and the resolved
13
- // slug is the truth. Falling back to the requested value for `id` produced
14
- // id:"t1a" beside slug:"t1-challengers", a row that disagreed with itself.
15
9
  const resolved = pickString(nested.slug, nested.orgSlug);
16
10
  const id = pickString(nested.id, nested.teamId) ??
17
11
  resolved ??
@@ -24,16 +18,11 @@ function teamIdentity(game, raw, idHint, slugHint) {
24
18
  name,
25
19
  game,
26
20
  region: pickString(nested.region, nested.country) ?? null,
27
- // CS2 publishes countryName/countryCode and worldRanking on the team
28
- // resource and none of it reached this card: a team page rendered with no
29
- // flag and no ladder position while the API had both. Spirit came back as
30
- // {id, slug, name, game, region} with world #1 nowhere in sight.
31
21
  country: pickString(nested.countryName, nested.country, nested.countryCode) ?? null,
32
22
  countryCode: pickString(nested.countryCode) ?? null,
33
23
  worldRanking: numberOrNull(nested.worldRanking ?? nested.ranking ?? nested.rank),
34
24
  };
35
25
  }
36
- /** A finite number, or null. Ranks arrive as number or numeric string. */
37
26
  function numberOrNull(value) {
38
27
  if (value === null || value === undefined || value === '')
39
28
  return null;
@@ -60,10 +49,6 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
60
49
  additionalProperties: false,
61
50
  required: ['game'],
62
51
  properties: {
63
- // Tennis is deliberately absent: singles has players, not team rosters,
64
- // and advertising it here only to throw NOT_IMPLEMENTED on every call is
65
- // a trap — the schema told the caller the request was valid. player_profile
66
- // and standings{game:"tennis"} cover the same ground honestly.
67
52
  game: {
68
53
  type: 'string',
69
54
  enum: PRIMARY_GAMES.filter((g) => g !== 'tennis'),
@@ -166,9 +151,7 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
166
151
  teamRaw = res.data;
167
152
  }
168
153
  else if (game === 'cs2') {
169
- // A cs2-team-<n> / hltv-team-<n> id resolves 1:1 — fetch it directly instead of
170
- // name-searching, which could silently fall back to rows[0] of an unrelated search.
171
- const directId = /^(cs2|hltv)-team-\d+$/i.test(idOrSlug);
154
+ const directId = /^[a-z0-9]{3,4}-team-\d+$/i.test(idOrSlug);
172
155
  const res = directId
173
156
  ? await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(idOrSlug)}`)
174
157
  : await fetchJson(ctx, '/cs2/teams', { query: { search: idOrSlug, limit: 5 } });
@@ -314,11 +297,6 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
314
297
  }
315
298
  })());
316
299
  tasks.push((async () => {
317
- // The matches index, not /lol/schedule. Schedule returned the team's
318
- // oldest fixtures: a T1 profile showed MSI 2023 as "recent" while the
319
- // index had the 2026 LCK split, newest first. Sort here anyway rather
320
- // than trusting upstream order, so the contract holds if either
321
- // endpoint changes.
322
300
  const slug = team.slug || idOrSlug;
323
301
  const res = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(slug)}/matches`, {
324
302
  query: { limit: String(recentLimit) },
@@ -330,10 +308,6 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
330
308
  const t = Date.parse(String(row?.startTime ?? ''));
331
309
  return Number.isFinite(t) ? t : -Infinity;
332
310
  };
333
- // RECENT means finished AND in the past. The index also carries
334
- // scheduled fixtures, and this path forced every row to
335
- // "completed", so Gen.G and HLE both listed their 2026-09-05
336
- // playoff match as a completed result days before it was played.
337
311
  const nowMs = Date.now();
338
312
  const isFinished = (row) => {
339
313
  const r = asRecord(row) ?? {};
@@ -343,7 +317,6 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
343
317
  const state = String(r.state ?? r.status ?? '').toLowerCase();
344
318
  if (/unstarted|scheduled|upcoming|not_?started|in_?progress|live/.test(state))
345
319
  return false;
346
- // No usable state: fall back to the clock plus a decided result.
347
320
  if (!state)
348
321
  return Number.isFinite(t) && t <= nowMs;
349
322
  return /complete|finish|final|ended/.test(state);
@@ -388,9 +361,6 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
388
361
  const res = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(tid)}/trends`);
389
362
  upstreamCalls += 1;
390
363
  rateLimit = { ...rateLimit, ...res.headers };
391
- // res.data is the whole upstream body, so this nested
392
- // {success, data:{...}} inside our own envelope. Every other field
393
- // here is normalized; this one leaked the REST wrapper to the agent.
394
364
  if (res.ok)
395
365
  form = unwrapPayload(res.data) ?? res.data;
396
366
  else {
@@ -631,13 +601,9 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
631
601
  let upstreamCalls = 0;
632
602
  let rateLimit = {};
633
603
  let rows = [];
634
- // Names used for client-side side matching; replaced with resolved team names
635
- // when the id-based REST H2H succeeds (short inputs like "navi" never substring-match "Natus Vincere").
636
604
  let matchA = sideA;
637
605
  let matchB = sideB;
638
606
  if (game === 'cs2') {
639
- // Prefer the purpose-built id-based H2H endpoint; /cs2/matches?team= name-substring
640
- // filtering misses short names ("navi" vs stored "Natus Vincere").
641
607
  const resolveCs2Side = async (side) => {
642
608
  const res = await fetchJson(ctx, '/cs2/teams', { query: { search: side, limit: 5 } });
643
609
  upstreamCalls += 1;
@@ -662,8 +628,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
662
628
  upstreamCalls += 1;
663
629
  rateLimit = { ...rateLimit, ...res.headers };
664
630
  if (res.ok) {
665
- // Rows are side-A-perspective summaries (summarizeMatchForTeam); reshape into
666
- // flat match rows so the shared normalize/score pipeline applies unchanged.
667
631
  rows = extractRows(res.data).map((row) => {
668
632
  const r = asRecord(row) ?? {};
669
633
  return {
@@ -726,11 +690,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
726
690
  rows = extractRows(res.data);
727
691
  }
728
692
  else if (game === 'lol') {
729
- // First-class H2H. The old path pulled sideA's /lol/schedule and filtered
730
- // client-side, which missed meetings and returned the oldest first, so a
731
- // T1 vs HLE rivalry showed 2023 games and called the earliest one the
732
- // "last meeting". This endpoint states the record outright (22 meetings,
733
- // 8-13) and lists them newest first.
734
693
  const h2hRes = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(sideA)}/h2h/${encodeURIComponent(sideB)}`, { query: { limit: String(limit) } });
735
694
  upstreamCalls += 1;
736
695
  rateLimit = { ...rateLimit, ...h2hRes.headers };
@@ -753,9 +712,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
753
712
  matchId: pickString(r.matchId) ?? null,
754
713
  startTime: pickString(r.date) ?? null,
755
714
  tournamentName: pickString(r.tournamentName) ?? null,
756
- // The feed gives scores in the match's own team order, which it
757
- // does not label, so they are reported as-is rather than being
758
- // assigned to a side we would be guessing at.
759
715
  team1Score: typeof r.team1Score === 'number' ? r.team1Score : null,
760
716
  team2Score: typeof r.team2Score === 'number' ? r.team2Score : null,
761
717
  winnerSlug: pickString(r.winner) ?? null,
@@ -829,7 +785,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
829
785
  rows = extractRows(res.data);
830
786
  }
831
787
  else if (game === 'ufc') {
832
- // Use fighter fight history (not global /bouts page 1, which rarely contains both fighters).
833
788
  const [aRes, bRes, histA, histB] = await Promise.all([
834
789
  fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}`),
835
790
  fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}`),
@@ -854,15 +809,11 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
854
809
  });
855
810
  }
856
811
  const toBouts = (payload) => extractRows(payload).map((row) => {
857
- // History rows carry the two corners as fighterSlug/opponent, not on
858
- // the nested bout. Dropping them here is why Jones vs Cormier reported
859
- // zero meetings while the profile listed both fights.
860
812
  const r = asRecord(row) ?? {};
861
813
  return { ...(asRecord(r.bout) ?? {}), ...r };
862
814
  });
863
815
  const fromA = histA.ok ? toBouts(histA.data) : [];
864
816
  const fromB = histB.ok ? toBouts(histB.data) : [];
865
- // Union by bout id
866
817
  const byId = new Map();
867
818
  for (const bout of [...fromA, ...fromB]) {
868
819
  const m = normalizeMatch('ufc', bout);
@@ -876,7 +827,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
876
827
  }
877
828
  }
878
829
  if (game === 'tennis') {
879
- // Tennis has a first-class H2H endpoint; resolve names to player ids first when needed.
880
830
  const resolveTennisSide = async (side) => {
881
831
  if (/^(atp|wta)_\d+$/i.test(side))
882
832
  return { id: side, name: side };
@@ -928,14 +878,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
928
878
  }
929
879
  const envelope = asRecord(res.data) ?? {};
930
880
  const h2h = asRecord(envelope.data) ?? envelope;
931
- // The id-shaped shortcut above never resolves a name — sideA:"atp_207989"
932
- // came in already looking like an id, so resolveTennisSide handed back
933
- // {id: side, name: side} without ever asking who that id belongs to. The
934
- // /tennis/h2h payload already states both names one level down (its own
935
- // player1/player2, keyed by the same player1_id/player2_id this request
936
- // just sent), so read from there rather than trusting the resolver's
937
- // name at all: it is authoritative and always available once the H2H
938
- // call itself succeeded.
939
881
  const h2hP1 = asRecord(h2h.player1);
940
882
  const h2hP2 = asRecord(h2h.player2);
941
883
  const sideAName = pickString(h2hP1?.name) ?? tA.name;
@@ -957,15 +899,13 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
957
899
  }
958
900
  const meetings = rows
959
901
  .map((row) => normalizeMatch(game, row))
960
- // Past fights only. The booked bout between the two sides is in the
961
- // same list and is not a meeting yet.
962
902
  .filter((m) => m.status === 'completed')
963
903
  .filter((m) => sidesMatch(m, matchA, matchB))
964
904
  .filter((m) => {
965
905
  if (!fromIso && !toIso)
966
906
  return true;
967
907
  if (!m.startTime)
968
- return true; // keep undated rows; date filter is best-effort
908
+ return true;
969
909
  const t = Date.parse(m.startTime);
970
910
  if (Number.isNaN(t))
971
911
  return true;
@@ -975,10 +915,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
975
915
  return false;
976
916
  return true;
977
917
  })
978
- // Newest first. Nothing sorted this before, so `lastMeeting` below was
979
- // whichever row the upstream happened to return first, which on an
980
- // ascending feed is the OLDEST meeting. It also means a limit cut off the
981
- // recent games rather than the ancient ones.
982
918
  .sort((a, b) => {
983
919
  const ta = a.startTime ? Date.parse(a.startTime) : NaN;
984
920
  const tb = b.startTime ? Date.parse(b.startTime) : NaN;
@@ -1022,21 +958,6 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
1022
958
  });
1023
959
  },
1024
960
  };
1025
- /**
1026
- * h2h_matrix (TENNIS-24, cycle 60).
1027
- *
1028
- * Tennis-only: multi-player head-to-head comparison grid from the first-class
1029
- * GET /tennis/h2h/matrix route (2-16 player ids, comma-separated). Upstream
1030
- * shape (verified 2026-09-09): { players: [{ id, name, ioc, wins }], matrix:
1031
- * { [rowId]: { [colId]: "W-L" | "-" } } }. Cell "7-6" at [A][B] means A won 7
1032
- * and B won 6 of their meetings; "-" marks the self diagonal. Unknown ids get
1033
- * honest 0-0 cells and no players entry (no 404); fewer than 2 or more than 16
1034
- * ids 422, which surfaces as VALIDATION here.
1035
- *
1036
- * Names are resolved to atp_/wta_ ids via /tennis/players/search first (same
1037
- * id-shaped shortcut as the head_to_head tennis branch); duplicates collapse
1038
- * to one grid row.
1039
- */
1040
961
  export const h2hMatrix = {
1041
962
  name: 'h2h_matrix',
1042
963
  description: `Multi-player tennis head-to-head grid: every pair's series record in one comparison matrix.
@@ -1145,8 +1066,6 @@ Parallel-safe: yes. Upstream cost: 1 + one search per unresolved name.`,
1145
1066
  ],
1146
1067
  });
1147
1068
  }
1148
- // Dedupe resolved ids preserving order: REST dict keys would collapse
1149
- // anyway, but players_info would list the duplicate twice.
1150
1069
  const ids = [...new Set(resolved.map((r) => r.id))];
1151
1070
  if (ids.length < 2) {
1152
1071
  return errorEnvelope({
@@ -1,21 +1,7 @@
1
- /**
2
- * tournaments — tennis tournament discovery (TENNIS-25).
3
- *
4
- * WHY THIS EXISTS
5
- * event_card already renders a tournament Profile/Draw, but only once you know a
6
- * tournament id. There was no way to ASK for tournaments — so "list the 2026
7
- * WTA 1000 events" or "what ATP 250s are on clay" was impossible without
8
- * call_api, even though /tennis/tournaments supports exactly those filters
9
- * (year, tour, level, surface, country) and the level filter was recently made
10
- * to accept names like "WTA 1000" rather than only raw tier codes.
11
- *
12
- * This tool is the discovery half; event_card stays the detail half.
13
- */
14
1
  import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
15
2
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
16
3
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
17
4
  const SURFACES = ['Hard', 'Clay', 'Grass', 'Carpet'];
18
- /** Human level names the API accepts on ?level=, plus the raw tier codes. */
19
5
  const LEVEL_NAMES = [
20
6
  'Grand Slam',
21
7
  'Masters 1000',
@@ -42,9 +28,6 @@ function normalizeTournament(row) {
42
28
  name: pickString(r.name) ?? null,
43
29
  tour: pickString(r.tour) ?? null,
44
30
  year: num(r.year),
45
- // `tier` is the human level the API derives from the tier code; `level` is
46
- // the raw code. Both are surfaced because callers filter on the name but
47
- // cross-reference by the code.
48
31
  level: pickString(r.tier) ?? pickString(r.level) ?? null,
49
32
  levelCode: pickString(r.tier_code, r.level) ?? null,
50
33
  category: pickString(r.category) ?? null,
@@ -174,25 +157,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
174
157
  });
175
158
  }
176
159
  }
177
- /**
178
- * A level name that starts with a tour token constrains the tour, and the
179
- * upstream filter does not know that.
180
- *
181
- * The API's reverse level map is not tour-aware: `_TIER_WTA` carries BOTH
182
- * "PM" and "M" as "WTA 1000" (M is the fallback label for a WTA row stored
183
- * with the ATP code), so `?level=WTA 1000` resolves to `tier_code IN
184
- * ('PM','M')` and returns every ATP Masters 1000 event alongside the WTA
185
- * ones. Verified live: asking for "WTA 1000" returned Rome Masters, Madrid
186
- * Masters, Monte Carlo, Miami Masters and Indian Wells Masters (all ATP).
187
- * The same widening hits "WTA 125" (code C, shared with ATP Challengers) and
188
- * "ATP 500"/"ATP 250" (code A, shared with WTA tour-level rows).
189
- *
190
- * The tool therefore asserts the invariant the label states: it sends the
191
- * tour, and it verifies the rows that come back. Rows that contradict the
192
- * requested level are dropped and counted rather than passed through as
193
- * "close enough" — a filter that returns other-tour events is worse than a
194
- * filter that returns nothing, because the caller has no way to notice.
195
- */
196
160
  const levelTourPrefix = (() => {
197
161
  const m = /^(ATP|WTA)\b/i.exec(levelRaw);
198
162
  return m ? m[1].toUpperCase() : null;
@@ -246,7 +210,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
246
210
  const root = asRecord(res.data) ?? {};
247
211
  const body = asRecord(root.data) ?? root;
248
212
  const rawItems = (Array.isArray(body.items) ? body.items : []).map(normalizeTournament);
249
- // Verify the invariant the level label states. See levelTourPrefix above.
250
213
  const contradictions = levelTourPrefix
251
214
  ? rawItems.filter((t) => t.tour && t.tour.toUpperCase() !== levelTourPrefix)
252
215
  : [];
@@ -1,5 +1,4 @@
1
1
  import { ENVELOPE_OUTPUT_SCHEMA, toMcpResult } from '../envelope.js';
2
- /** All curated tools fetch Cito data. None delete, post, or mutate user state. */
3
2
  export const READ_TOOL_ANNOTATIONS = {
4
3
  readOnlyHint: true,
5
4
  destructiveHint: false,
@@ -79,7 +78,6 @@ export function stringSchema(description, example) {
79
78
  schema.examples = [example];
80
79
  return schema;
81
80
  }
82
- /** Levenshtein distance, capped for short arg names. */
83
81
  function editDistance(a, b) {
84
82
  const m = a.length;
85
83
  const n = b.length;
@@ -93,7 +91,6 @@ function editDistance(a, b) {
93
91
  }
94
92
  return prev[n];
95
93
  }
96
- /** Closest known parameter to a mistyped one, if it is plausibly a typo. */
97
94
  function suggestParam(unknown, known) {
98
95
  const u = unknown.toLowerCase();
99
96
  let best = null;
@@ -104,24 +101,12 @@ function suggestParam(unknown, known) {
104
101
  }
105
102
  if (!best)
106
103
  return null;
107
- // Accept near-misses, plus prefix relationships like query -> q.
108
104
  const threshold = Math.max(2, Math.floor(Math.max(u.length, best.name.length) / 3));
109
105
  if (best.d <= threshold)
110
106
  return best.name;
111
107
  const prefixHit = known.find((k) => u.startsWith(k.toLowerCase()) || k.toLowerCase().startsWith(u));
112
108
  return prefixHit ?? null;
113
109
  }
114
- /**
115
- * Args the tool does not declare, when its schema says additionalProperties:false.
116
- *
117
- * Every tool already declared that, but nothing enforced it: the MCP SDK does
118
- * not validate arguments against inputSchema, so a plausible-but-wrong name was
119
- * dropped on the floor and the tool ran with a default. search_entities
120
- * { game:"ufc", query:"Jon Jones" } — `q` is the real parameter — returned
121
- * ok:true with an unrelated roster. Silently wrong beats loudly wrong for a
122
- * human skimming output, but for an agent it is far worse: it has no way to
123
- * tell a real answer from a discarded question.
124
- */
125
110
  export function unknownArgKeys(def, args) {
126
111
  const schema = def.inputSchema ?? {};
127
112
  if (schema.additionalProperties !== false)
@@ -132,7 +117,6 @@ export function unknownArgKeys(def, args) {
132
117
  const known = Object.keys(props);
133
118
  return Object.keys(args ?? {}).filter((k) => !known.includes(k));
134
119
  }
135
- /** Normalize MCP handler return to MCP content result. */
136
120
  export async function runTool(def, args, ctx) {
137
121
  try {
138
122
  const unknown = unknownArgKeys(def, args ?? {});
@@ -144,7 +128,6 @@ export async function runTool(def, args, ctx) {
144
128
  return toResult(errorEnvelope({
145
129
  code: 'VALIDATION',
146
130
  message: `${def.name}: unknown argument${unknown.length > 1 ? 's' : ''} ` +
147
- // Avoid "?." when the last hint is a did-you-mean question.
148
131
  `${hints.join('; ')}${hints[hints.length - 1].endsWith('?') ? '' : '.'} ` +
149
132
  `Accepted: ${known.join(', ')}.`,
150
133
  game: typeof args?.game === 'string' ? args.game : null,
package/dist/version.js CHANGED
@@ -1,19 +1,4 @@
1
1
  import { createRequire } from 'node:module';
2
- /**
3
- * Single source of truth for the server version.
4
- *
5
- * This used to be hardcoded in three places — package.json, PACKAGE_VERSION in
6
- * index.ts, and CATALOG_VERSION in tools/meta.ts — which had already drifted:
7
- * a test asserted 0.2.4 while the package shipped 0.2.5. Agents read the version
8
- * out of list_capabilities, so a stale value there is a support problem, not a
9
- * cosmetic one. Read it from the manifest instead, so `npm version` is the only
10
- * edit a release needs.
11
- *
12
- * createRequire rather than a JSON import: import assertions are still awkward
13
- * across the Node versions this package supports (>=20), and this resolves the
14
- * same from src/ under tsx and from dist/ once built, because package.json is
15
- * always published alongside dist.
16
- */
17
2
  const require = createRequire(import.meta.url);
18
3
  function readVersion() {
19
4
  try {
@@ -21,8 +6,6 @@ function readVersion() {
21
6
  return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
22
7
  }
23
8
  catch {
24
- // Never let a packaging quirk crash the server on boot; a wrong-but-present
25
- // version is far better than a failed start.
26
9
  return '0.0.0';
27
10
  }
28
11
  }
package/package.json CHANGED
@@ -1,8 +1,28 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.4",
4
- "description": "Standalone MCP server for the Cito esports and sports API — 42 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
3
+ "version": "0.4.6",
4
+ "description": "Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
5
5
  "type": "module",
6
+ "homepage": "https://cito.gg",
7
+ "//REPOSITORY": "Intentionally NOT set. cito-api-scraper and cito.gg are PRIVATE repos and must stay that way, so a repository URL pointing at them would render as a broken 404 link on the public npm page and would not satisfy MCP Registry npm ownership validation. Set this only if a PUBLIC repo for this package is ever created (e.g. achillesscriptsvip/cito-mcp with this directory as root) — then use `git+https://github.com/OWNER/REPO.git` and mirror it in the `mcpName`/server.json `name` pair. Do not point it at the private monorepo.",
8
+ "keywords": [
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "esports",
12
+ "cs2",
13
+ "counter-strike",
14
+ "counter-strike-2",
15
+ "lol",
16
+ "league-of-legends",
17
+ "dota2",
18
+ "cod",
19
+ "ufc",
20
+ "mma",
21
+ "tennis",
22
+ "live-scores",
23
+ "sports-data",
24
+ "esports-api"
25
+ ],
6
26
  "bin": {
7
27
  "cito-mcp": "dist/index.js"
8
28
  },