cito-mcp 0.4.0 → 0.4.1

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.
@@ -82,6 +82,8 @@ Do not use when: current top-N snapshot → standings with game tennis; player h
82
82
 
83
83
  Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
84
84
 
85
+ The two lists are NOT always one week apart: previousDate is whatever the archive holds next, and the archive has holes. Read gapDays / comparisonWindow (and comparisonsAreWeekly) before describing a delta as weekly — a warning is raised whenever the gap exceeds ten days.
86
+
85
87
  Parallel-safe: yes. Upstream cost: 1.`,
86
88
  inputSchema: {
87
89
  type: 'object',
@@ -195,6 +197,39 @@ Parallel-safe: yes. Upstream cost: 1.`,
195
197
  const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
196
198
  const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
197
199
  const total = typeof body.total === 'number' ? body.total : items.length;
200
+ /**
201
+ * How far apart the two lists actually are.
202
+ *
203
+ * `previous_date` is simply the next list the archive holds — it is NOT
204
+ * guaranteed to be last week. On 2026-09-12 it was 2026-06-08 against a
205
+ * current 2026-08-31: an 84-day gap, because the weekly archive ends
206
+ * 2026-06-08 and the only snapshots after it are the current ESPN-sourced
207
+ * pair. Calling that "week-over-week movers" told every caller that a
208
+ * three-month climb was a one-week climb. The interval is measured and
209
+ * reported instead of assumed.
210
+ */
211
+ const rankingDate = pickString(body.ranking_date) ?? null;
212
+ const previousDate = pickString(body.previous_date) ?? null;
213
+ const gapDays = (() => {
214
+ if (!rankingDate || !previousDate)
215
+ return null;
216
+ const a = Date.parse(rankingDate);
217
+ const b = Date.parse(previousDate);
218
+ if (!Number.isFinite(a) || !Number.isFinite(b))
219
+ return null;
220
+ return Math.round((a - b) / 86_400_000);
221
+ })();
222
+ const comparisonWindow = gapDays === null
223
+ ? null
224
+ : gapDays <= 8
225
+ ? 'week-over-week'
226
+ : `${Math.round(gapDays / 7)}-week gap`;
227
+ const warnings = [];
228
+ if (gapDays !== null && gapDays > 10) {
229
+ warnings.push(`The compared lists are ${gapDays} days apart (${previousDate} -> ${rankingDate}), not one week. ` +
230
+ `Every "movement" here accumulated over ${Math.round(gapDays / 7)} weeks; there is no published ` +
231
+ `ranking snapshot strictly between those two dates. Treat the deltas as period-over-period, not weekly.`);
232
+ }
198
233
  return successEnvelope({
199
234
  pagination: {
200
235
  limit,
@@ -210,13 +245,19 @@ Parallel-safe: yes. Upstream cost: 1.`,
210
245
  tookMs: Date.now() - started,
211
246
  upstreamCalls: 1,
212
247
  rateLimit: res.headers,
248
+ warnings: warnings.length ? warnings : undefined,
213
249
  data: {
214
250
  title: `${tourParse.tour} ranking movers (${dirParse.direction})`,
215
251
  tour: tourParse.tour,
216
252
  direction: dirParse.direction,
217
253
  within,
218
- rankingDate: pickString(body.ranking_date) ?? null,
219
- previousDate: pickString(body.previous_date) ?? null,
254
+ rankingDate,
255
+ previousDate,
256
+ /** Real interval between the two lists; null when only one list exists. */
257
+ gapDays,
258
+ /** 'week-over-week' only when the gap really is about a week. */
259
+ comparisonWindow,
260
+ comparisonsAreWeekly: gapDays !== null && gapDays <= 8,
220
261
  items,
221
262
  newEntries,
222
263
  dropped,
@@ -237,8 +278,10 @@ Prefer over: standings (latest snapshot only); rankings_movers (week-over-week d
237
278
  Do not use when: the current top-N table → standings with game tennis.
238
279
 
239
280
  Tennis-only. Rows arrive oldest-first (chronological), which is the order a chart
240
- wants; the newest entries are the tail. Use 'since' or 'limit' to bound the
241
- series — the full career can be hundreds of rows.
281
+ wants; the newest entries are the tail. limit keeps the most RECENT N rows (the
282
+ route itself ignores ?limit=, so the bound is applied here) — hasMore and
283
+ oldestReturned/newestReturned say exactly which window came back, and
284
+ omittedOlder counts the rows left behind at the old end.
242
285
 
243
286
  Parallel-safe: yes. Upstream cost: 1.`,
244
287
  inputSchema: {
@@ -322,7 +365,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
322
365
  const root = asRecord(res.data) ?? {};
323
366
  const body = asRecord(root.data) ?? root;
324
367
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
325
- let history = (Array.isArray(body.history) ? body.history : []).map((row) => {
368
+ const allHistory = (Array.isArray(body.history) ? body.history : []).map((row) => {
326
369
  const r = asRecord(row) ?? {};
327
370
  return {
328
371
  date: pickString(r.date) ?? null,
@@ -330,8 +373,23 @@ Parallel-safe: yes. Upstream cost: 1.`,
330
373
  points: num(r.points),
331
374
  };
332
375
  });
333
- if (sinceRaw)
334
- history = history.filter((h) => h.date !== null && h.date >= sinceRaw);
376
+ const inWindow = sinceRaw
377
+ ? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
378
+ : allHistory;
379
+ /**
380
+ * The route IGNORES ?limit= — verified: `?limit=4` returned all 342 rows of
381
+ * Alcaraz's career with total:342 and hasMore:false. Asking for 4 and
382
+ * receiving 342 is not a bound, it is a payload the caller cannot control,
383
+ * so the bound is applied here.
384
+ *
385
+ * Rows arrive oldest-first because that is the order a chart wants. limit
386
+ * therefore keeps the most RECENT N (the tail), matching every other tool
387
+ * in this server: `player_matches` returns the newest rows and reports
388
+ * hasMore when older ones exist. The older end is what gets dropped, and
389
+ * oldestReturned/newestReturned say exactly which window came back.
390
+ */
391
+ const total = inWindow.length;
392
+ const history = total > limit ? inWindow.slice(total - limit) : inWindow;
335
393
  // Best rank actually present in the returned window, so a caller can label a
336
394
  // chart without recomputing. Null when the window is empty.
337
395
  const best = history.reduce((acc, h) => {
@@ -344,9 +402,9 @@ Parallel-safe: yes. Upstream cost: 1.`,
344
402
  return successEnvelope({
345
403
  pagination: {
346
404
  limit,
347
- offset: 0,
348
- total: history.length,
349
- hasMore: false,
405
+ offset: total - history.length,
406
+ total,
407
+ hasMore: total > history.length,
350
408
  nextCursor: null,
351
409
  prevCursor: null,
352
410
  },
@@ -367,6 +425,13 @@ Parallel-safe: yes. Upstream cost: 1.`,
367
425
  since: sinceRaw || null,
368
426
  bestRankInWindow: best,
369
427
  history,
428
+ // The bound is applied locally because the route ignores ?limit=; state
429
+ // it so a caller does not read a short series as a short career.
430
+ totalInWindow: total,
431
+ returned: history.length,
432
+ omittedOlder: total - history.length,
433
+ oldestReturned: history.length ? history[0].date : null,
434
+ newestReturned: history.length ? history[history.length - 1].date : null,
370
435
  },
371
436
  });
372
437
  },
@@ -5,6 +5,13 @@ 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 { editionYear, entityRef, rankScore } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
+ /**
9
+ * Minimum relevance a search result must reach to be returned when a query was
10
+ * given. rankScore returns 0 for "no part of the query appears anywhere in this
11
+ * row", so 1 means "must have matched something". See the floor note in
12
+ * searchEntities for the live evidence.
13
+ */
14
+ export const RELEVANCE_FLOOR = 1;
8
15
  /**
9
16
  * Ranking order whose tiebreak never depends on the order upstream happened to
10
17
  * return rows in.
@@ -840,33 +847,86 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
840
847
  seen.add(key);
841
848
  return true;
842
849
  });
850
+ /**
851
+ * Relevance floor.
852
+ *
853
+ * The upstream player/tournament search is a substring match with no floor
854
+ * of its own: `q=Sinner` returns ten rows, of which only "Jannik Sinner" and
855
+ * "Martin Sinner" have anything to do with the query. The tool scored them
856
+ * all and then SORTED rather than filtered, so "A Winner", "J Skinner",
857
+ * "E Skinner", "C Sinkler", "Dr Sinnett", "H C Skinner", "Mrs Skinner" and
858
+ * "J J Sinnott" came back as equals — an agent asking for one player got
859
+ * eight strangers to ignore, and a caller paging the results paid for them.
860
+ *
861
+ * rankScore already separates them cleanly (Jannik 100, Martin 48, every
862
+ * other row 0), so the floor is simply "must have matched something at all".
863
+ * It is applied only when a query was given: with no q this tool is a browse
864
+ * list, and a floor there would delete the catalogue.
865
+ */
866
+ const scoreOf = (it) => typeof it.meta?.score === 'number'
867
+ ? it.meta.score
868
+ : rankScore(q, it.name, it.id, it.slug, it.meta?.nickname);
869
+ const upstreamTotal = total;
870
+ let droppedBelowFloor = [];
871
+ if (q) {
872
+ // Publish the score on every row, not just the ones the scored branch
873
+ // produced, so the floor is visible rather than mysterious.
874
+ items = items.map((it) => ({ ...it, meta: { ...(it.meta ?? {}), score: scoreOf(it) } }));
875
+ droppedBelowFloor = items
876
+ .filter((it) => it.meta?.score < RELEVANCE_FLOOR)
877
+ .map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
878
+ if (droppedBelowFloor.length) {
879
+ items = items.filter((it) => it.meta?.score >= RELEVANCE_FLOOR);
880
+ }
881
+ }
843
882
  if (q && !preserveUpstreamOrder) {
844
- items.sort((a, b) => {
845
- const sa = typeof a.meta?.score === 'number' ? a.meta.score : rankScore(q, a.name, a.id, a.slug, a.meta?.nickname);
846
- const sb = typeof b.meta?.score === 'number' ? b.meta.score : rankScore(q, b.name, b.id, b.slug, b.meta?.nickname);
847
- return sb - sa || a.name.localeCompare(b.name);
848
- });
883
+ items.sort((a, b) => scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name));
849
884
  }
850
885
  // pagination over ranked result set
851
886
  const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
852
887
  const hasMore = upstreamPaged
853
888
  ? (total != null ? offset + pageItems.length < total : pageItems.length >= limit)
854
889
  : offset + pageItems.length < items.length;
890
+ const floorWarnings = [];
891
+ if (droppedBelowFloor.length) {
892
+ const sample = droppedBelowFloor
893
+ .slice(0, 5)
894
+ .map((d) => `${d.name} (${d.id})`)
895
+ .join(', ');
896
+ floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all — the upstream search is a ` +
897
+ `substring match with no relevance floor, so it pads every page: ${sample}. ` +
898
+ `If one of these is what you wanted, query it by name or id directly.`);
899
+ }
855
900
  return successEnvelope({
856
901
  source: 'search_entities',
857
902
  game,
858
903
  requestId,
859
904
  tookMs: Date.now() - started,
860
905
  upstreamCalls,
906
+ warnings: floorWarnings.length ? floorWarnings : undefined,
861
907
  pagination: {
862
908
  limit,
863
909
  offset,
864
- total: total ?? (q ? items.length : null),
910
+ total: q ? items.length : (total ?? null),
865
911
  hasMore,
866
912
  nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
867
913
  prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
868
914
  },
869
- data: { items: pageItems },
915
+ data: {
916
+ items: pageItems,
917
+ ...(q
918
+ ? {
919
+ relevance: {
920
+ query: q,
921
+ floor: RELEVANCE_FLOOR,
922
+ matched: items.length,
923
+ droppedBelowFloor: droppedBelowFloor.length,
924
+ dropped: droppedBelowFloor.slice(0, 25),
925
+ upstreamTotal: upstreamTotal ?? null,
926
+ },
927
+ }
928
+ : {}),
929
+ },
870
930
  });
871
931
  },
872
932
  };
@@ -33,12 +33,26 @@ function normalizeScheduled(row) {
33
33
  round: pickString(r.round) ?? null,
34
34
  roundName: pickString(r.round_name) ?? null,
35
35
  startsAt: pickString(r.starts_at, r.commence_time, r.match_date) ?? null,
36
+ /**
37
+ * Historical days report every match at T00:00:00 with
38
+ * `start_time_known: false` — the date is real, the clock is a placeholder.
39
+ * Emitting the midnight without this flag invites a caller to render "12:00
40
+ * AM" for a semi-final that finished at 3pm. Null when the route does not
41
+ * state it (the completed half does not).
42
+ */
43
+ startTimeKnown: typeof r.start_time_known === 'boolean' ? r.start_time_known : null,
36
44
  status: pickString(r.status, r.outcome) ?? null,
37
45
  player1: { id: pickString(p1.id, p1.player_id) ?? null, name: pickString(p1.name) ?? null },
38
46
  player2: { id: pickString(p2.id, p2.player_id) ?? null, name: pickString(p2.name) ?? null },
39
47
  score: pickString(r.score, r.score_raw) ?? null,
40
48
  };
41
49
  }
50
+ /** A row is "played" when the feed says so, in either spelling it uses. */
51
+ function isPlayed(row) {
52
+ const r = asRecord(row) ?? {};
53
+ const s = (pickString(r.status, r.outcome) ?? '').toLowerCase();
54
+ return /complete|finished|ended|final|closed|done|official|retire|walkover|default/.test(s);
55
+ }
42
56
  export const tennisSchedule = {
43
57
  name: 'tennis_schedule',
44
58
  description: `One day of tennis: the day's schedule and/or the matches completed that day, across ATP and WTA.
@@ -54,6 +68,12 @@ Tennis-only. 'date' defaults to today (UTC) and MUST be YYYY-MM-DD. Choose with
54
68
  'include': both (default), scheduled, or completed. Both halves are fetched in
55
69
  parallel, so either can fail on its own without losing the other.
56
70
 
71
+ 'limit' bounds EACH half (the completed route ignores ?limit= upstream, so the
72
+ bound is applied here); scheduledCount/completedCount report how many rows the
73
+ day held and *Returned how many came back. The two halves overlap: the day's
74
+ list includes matches already played, so the same matchId can appear in both —
75
+ see the overlap block before de-duplicating or counting.
76
+
57
77
  Parallel-safe: yes. Upstream cost: 1 or 2.`,
58
78
  inputSchema: {
59
79
  type: 'object',
@@ -63,7 +83,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
63
83
  game: gameSchema({ allowAll: false, required: true }),
64
84
  date: stringSchema('Day to fetch, YYYY-MM-DD. Defaults to today (UTC).', '2026-09-12'),
65
85
  include: stringSchema('both (default) | scheduled | completed.', 'both'),
66
- limit: limitSchema({ default: 20, max: 50, description: 'Rows per half (default 20, max 50).' }),
86
+ limit: limitSchema({ default: 20, max: 50, description: 'Rows per half, applied here (default 20, max 50). scheduledCount/completedCount still report the full day.' }),
67
87
  },
68
88
  },
69
89
  handler: async (args, ctx) => {
@@ -121,27 +141,44 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
121
141
  const limit = clampInt(args.limit, 20, 1, 50);
122
142
  const wantScheduled = includeRaw === 'both' || includeRaw === 'scheduled';
123
143
  const wantCompleted = includeRaw === 'both' || includeRaw === 'completed';
144
+ /**
145
+ * Ask for the whole day, then bound locally.
146
+ *
147
+ * /tennis/schedule honours ?limit= but /tennis/matches/completed ignores it,
148
+ * so passing `limit` upstream made `scheduledCount` mean "rows this call
149
+ * asked for" on one half and "rows the day held" on the other. Both halves
150
+ * are now fetched at the API's own page ceiling and sliced here, which makes
151
+ * scheduledCount/completedCount a fact about the day and *Returned a fact
152
+ * about the response.
153
+ */
154
+ const upstreamPage = 200;
124
155
  const settled = await Promise.allSettled([
125
156
  wantScheduled
126
- ? fetchJson(ctx, '/tennis/schedule', { query: { date, limit } })
157
+ ? fetchJson(ctx, '/tennis/schedule', { query: { date, limit: upstreamPage } })
127
158
  : Promise.resolve(null),
128
159
  wantCompleted
129
- ? fetchJson(ctx, '/tennis/matches/completed', { query: { date, limit } })
160
+ ? fetchJson(ctx, '/tennis/matches/completed', { query: { date, limit: upstreamPage } })
130
161
  : Promise.resolve(null),
131
162
  ]);
132
163
  const rejected = [];
133
164
  const rows = (value) => {
134
165
  const root = asRecord(value);
135
166
  const body = asRecord(root?.data) ?? root ?? {};
136
- return Array.isArray(body.items) ? body.items : [];
167
+ const items = Array.isArray(body.items) ? body.items : [];
168
+ return { items, total: typeof body.total === 'number' ? body.total : null };
137
169
  };
138
170
  let scheduled = [];
139
171
  let completed = [];
172
+ let scheduledTotal = null;
173
+ let completedTotal = null;
140
174
  const [schedRes, compRes] = settled;
141
175
  if (schedRes.status === 'fulfilled' && schedRes.value) {
142
176
  const v = schedRes.value;
143
- if (v.ok)
144
- scheduled = rows(v.data);
177
+ if (v.ok) {
178
+ const parsed = rows(v.data);
179
+ scheduled = parsed.items;
180
+ scheduledTotal = parsed.total;
181
+ }
145
182
  else
146
183
  rejected.push({
147
184
  section: 'schedule',
@@ -159,8 +196,11 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
159
196
  }
160
197
  if (compRes.status === 'fulfilled' && compRes.value) {
161
198
  const v = compRes.value;
162
- if (v.ok)
163
- completed = rows(v.data);
199
+ if (v.ok) {
200
+ const parsed = rows(v.data);
201
+ completed = parsed.items;
202
+ completedTotal = parsed.total;
203
+ }
164
204
  else
165
205
  rejected.push({
166
206
  section: 'completed',
@@ -190,15 +230,64 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
190
230
  recover: ['Check the date is a real calendar day', 'Retry, or widen include to both'],
191
231
  });
192
232
  }
233
+ /**
234
+ * /tennis/matches/completed IGNORES ?limit= — verified: `limit=3` returned
235
+ * all 13 rows of the day with page_size still 50. /tennis/schedule does
236
+ * honour it, but both halves are bounded here so one call site cannot drift
237
+ * from the other and `completedCount` stops being a number the caller asked
238
+ * to be smaller.
239
+ */
240
+ const scheduledWindow = scheduled.slice(0, limit);
241
+ const completedWindow = completed.slice(0, limit);
242
+ /**
243
+ * /tennis/schedule is the DAY's list, not an "upcoming" list: it carries
244
+ * matches that have already finished, with status COMPLETED. So the two
245
+ * halves of this response legitimately overlap, and the old key name
246
+ * ("scheduled") plus a status of COMPLETED inside it read as a
247
+ * contradiction. The overlap is now counted and the statuses summarised, so
248
+ * a caller can join or de-duplicate deliberately instead of discovering it.
249
+ */
250
+ const completedBySchedule = scheduled.filter(isPlayed);
251
+ const completedIds = new Set(completed
252
+ .map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
253
+ .filter((id) => Boolean(id)));
254
+ const overlapIds = [
255
+ ...new Set(completedBySchedule
256
+ .map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
257
+ .filter((id) => typeof id === 'string' && completedIds.has(id))),
258
+ ];
259
+ const statusCounts = {};
260
+ for (const row of scheduled) {
261
+ const key = (pickString(asRecord(row)?.status, asRecord(row)?.outcome) ?? 'UNKNOWN').toUpperCase();
262
+ statusCounts[key] = (statusCounts[key] ?? 0) + 1;
263
+ }
264
+ const anyUnknownClock = scheduled.some((row) => asRecord(row)?.start_time_known === false);
193
265
  const data = {
194
266
  title: `Tennis — ${date}`,
195
267
  date,
196
268
  include: includeRaw,
197
- scheduled: scheduled.map(normalizeScheduled),
198
- scheduledCount: scheduled.length,
199
- completed: completed.map(normalizeScheduled),
200
- completedCount: completed.length,
269
+ // Everything the day's list returned, then the bounded window. Both are
270
+ // present so `scheduledCount` never silently means two different things.
271
+ scheduled: scheduledWindow.map(normalizeScheduled),
272
+ scheduledCount: scheduledTotal ?? scheduled.length,
273
+ scheduledReturned: scheduledWindow.length,
274
+ completed: completedWindow.map(normalizeScheduled),
275
+ completedCount: completedTotal ?? completed.length,
276
+ completedReturned: completedWindow.length,
277
+ statusBreakdown: statusCounts,
278
+ overlap: {
279
+ count: overlapIds.length,
280
+ matchIds: overlapIds.slice(0, 50),
281
+ note: overlapIds.length
282
+ ? 'These matches appear in BOTH halves: /tennis/schedule is the day\'s full list (played matches included) and /tennis/matches/completed is the played subset. Join on matchId and de-duplicate.'
283
+ : 'The two halves returned no shared matchId on this date.',
284
+ },
201
285
  timezoneNote: 'The API groups by UTC date. For a local "today", pass the date explicitly rather than relying on the default.',
286
+ ...(anyUnknownClock
287
+ ? {
288
+ startTimeNote: 'Rows with startTimeKnown:false carry a date-only startsAt (T00:00:00) — the clock is a placeholder, not a midnight start. The completed half does not report the flag at all.',
289
+ }
290
+ : {}),
202
291
  };
203
292
  const upstreamCalls = (wantScheduled ? 1 : 0) + (wantCompleted ? 1 : 0);
204
293
  // A half that failed is reported alongside the half that worked rather than
@@ -407,8 +407,24 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
407
407
  rows,
408
408
  total: allRows.length,
409
409
  hasMore: allRows.length > rows.length,
410
- updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
411
- null,
410
+ ...(game === 'tennis'
411
+ ? {
412
+ /**
413
+ * Tennis rankings are a dated weekly SNAPSHOT, and the route names
414
+ * the date in `ranking_date`. updatedAt was read only from
415
+ * updatedAt/lastUpdated/meta.syncedAt/meta.fetchedAt, none of which
416
+ * the tennis route emits, so every tennis table claimed
417
+ * updatedAt:null while the very date a caller needs sat in the
418
+ * payload — and a consumer showing a stale-looking table had no
419
+ * way to find out it was a week old.
420
+ */
421
+ rankingDate: pickString(obj?.ranking_date) ?? null,
422
+ updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt, obj?.ranking_date) ?? null,
423
+ }
424
+ : {
425
+ updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
426
+ null,
427
+ }),
412
428
  ...(dataFreshness ? { dataFreshness } : {}),
413
429
  ...(status ? { status } : {}),
414
430
  ...(upstreamMeta.warning || dataQuality.warning
@@ -69,6 +69,9 @@ Do not use when: the draw bracket or a specific edition → event_card with the
69
69
 
70
70
  Tennis-only. Levels accept names (Grand Slam, WTA 1000, ATP 500, Challenger,
71
71
  ITF World Tennis Tour, Davis Cup, ...) or raw tier codes (G, M, A, C, D).
72
+ A level name that starts with ATP or WTA also fixes the tour (and conflicts with
73
+ a contradicting tour= argument are rejected), so "WTA 1000" cannot return ATP
74
+ Masters events even though the upstream filter is not tour-aware.
72
75
  Note the source does not separate ATP 500 from ATP 250 — both are ATP Tour.
73
76
 
74
77
  Parallel-safe: yes. Upstream cost: 1.`,
@@ -171,6 +174,44 @@ Parallel-safe: yes. Upstream cost: 1.`,
171
174
  });
172
175
  }
173
176
  }
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
+ const levelTourPrefix = (() => {
197
+ const m = /^(ATP|WTA)\b/i.exec(levelRaw);
198
+ return m ? m[1].toUpperCase() : null;
199
+ })();
200
+ if (levelTourPrefix && tourRaw && tourRaw !== levelTourPrefix) {
201
+ return errorEnvelope({
202
+ code: 'VALIDATION',
203
+ message: `tour ('${tourRaw}') contradicts level ('${levelRaw}'), which names the ${levelTourPrefix} tour`,
204
+ game,
205
+ source: 'tournaments',
206
+ requestId,
207
+ tookMs: Date.now() - started,
208
+ recover: [
209
+ `Drop tour, or set tour=${levelTourPrefix}`,
210
+ 'A level name that starts with ATP/WTA already determines the tour',
211
+ ],
212
+ });
213
+ }
214
+ const effectiveTour = tourRaw || levelTourPrefix || '';
174
215
  const limit = clampInt(args.limit, 20, 1, 50);
175
216
  const page = clampInt(args.page, 1, 1, 10000);
176
217
  const countryCode = typeof args.countryCode === 'string' && args.countryCode.trim()
@@ -182,7 +223,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
182
223
  limit,
183
224
  page,
184
225
  ...(year !== null ? { year } : {}),
185
- ...(tourRaw ? { tour: tourRaw } : {}),
226
+ ...(effectiveTour ? { tour: effectiveTour } : {}),
186
227
  ...(levelRaw ? { level: levelRaw } : {}),
187
228
  ...(surface ? { surface } : {}),
188
229
  ...(countryCode ? { country_code: countryCode } : {}),
@@ -204,17 +245,35 @@ Parallel-safe: yes. Upstream cost: 1.`,
204
245
  }
205
246
  const root = asRecord(res.data) ?? {};
206
247
  const body = asRecord(root.data) ?? root;
207
- const items = (Array.isArray(body.items) ? body.items : []).map(normalizeTournament);
248
+ const rawItems = (Array.isArray(body.items) ? body.items : []).map(normalizeTournament);
249
+ // Verify the invariant the level label states. See levelTourPrefix above.
250
+ const contradictions = levelTourPrefix
251
+ ? rawItems.filter((t) => t.tour && t.tour.toUpperCase() !== levelTourPrefix)
252
+ : [];
253
+ const items = contradictions.length
254
+ ? rawItems.filter((t) => !t.tour || t.tour.toUpperCase() === levelTourPrefix)
255
+ : rawItems;
208
256
  const total = typeof body.total === 'number' ? body.total : items.length;
209
257
  const pageSize = typeof body.page_size === 'number' ? body.page_size : limit;
210
258
  const filters = [
211
259
  year !== null ? String(year) : null,
212
- tourRaw || null,
260
+ effectiveTour || null,
213
261
  levelRaw || null,
214
262
  surface || null,
215
263
  countryCode || null,
216
264
  q ? `"${q}"` : null,
217
265
  ].filter(Boolean);
266
+ const warnings = [];
267
+ if (contradictions.length) {
268
+ const sample = contradictions
269
+ .slice(0, 5)
270
+ .map((t) => `${t.id} (${t.tour})`)
271
+ .join(', ');
272
+ warnings.push(`The upstream level filter returned ${contradictions.length} tournament(s) that contradict level="${levelRaw}": ` +
273
+ `${sample}. They were dropped. The API's level reverse-map is not tour-aware (WTA 1000 resolves to tier codes ` +
274
+ `PM and M), which is a server-side defect; this tool sends tour=${levelTourPrefix} as well, so a repeat of this ` +
275
+ `warning means the upstream filter is widening again.`);
276
+ }
218
277
  return successEnvelope({
219
278
  pagination: {
220
279
  limit: pageSize,
@@ -230,18 +289,25 @@ Parallel-safe: yes. Upstream cost: 1.`,
230
289
  tookMs: Date.now() - started,
231
290
  upstreamCalls: 1,
232
291
  rateLimit: res.headers,
292
+ warnings: warnings.length ? warnings : undefined,
233
293
  data: {
234
294
  title: filters.length > 0
235
295
  ? `Tennis tournaments — ${filters.join(' ')}`
236
296
  : 'Tennis tournaments',
237
297
  filters: {
238
298
  year,
239
- tour: tourRaw || null,
299
+ tour: effectiveTour || null,
240
300
  level: levelRaw || null,
241
301
  surface: surface ?? null,
242
302
  countryCode: countryCode || null,
243
303
  q: q || null,
244
304
  },
305
+ levelFilterIntegrity: {
306
+ levelNamesTour: levelTourPrefix,
307
+ rowsChecked: rawItems.length,
308
+ contradictionsDropped: contradictions.length,
309
+ ok: contradictions.length === 0,
310
+ },
245
311
  items,
246
312
  total,
247
313
  page,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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).",
5
5
  "type": "module",
6
6
  "bin": {