cito-mcp 0.2.5 → 0.2.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/README.md CHANGED
@@ -12,7 +12,7 @@ Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail RES
12
12
 
13
13
  v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
14
14
 
15
- **v0.2 ships 15 hand-authored tools** that answer *jobs* instead of mirroring REST:
15
+ **v0.2 ships 16 hand-authored tools** that answer *jobs* instead of mirroring REST:
16
16
 
17
17
  | Job | Tool |
18
18
  | --- | --- |
@@ -26,6 +26,8 @@ v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among t
26
26
  | Pre-match briefing | `match_preview` |
27
27
  | Event / fight-night card | `event_card` |
28
28
  | Rivalry record | `head_to_head` |
29
+ | Fighter / team photos | `event_card` · `player_profile` |
30
+ | Which raw REST route exists? | `list_routes` |
29
31
  | Name → ID | `resolve_entity` / `search_entities` |
30
32
 
31
33
  Composites multi-fetch server-side, return a **stable JSON envelope**, and isolate partial failures in `partial[]` so one bad secondary section does not fail the whole page.
@@ -124,7 +126,7 @@ The server **exits immediately** if `CITO_API_KEY` is missing. The key is sent o
124
126
  4. Match card → `match_summary` (then `match_details` if needed)
125
127
  5. Team / player pages → `team_profile` / `player_profile`
126
128
  6. Tables / rivalry / preview / event card → `standings` / `head_to_head` / `match_preview` / `event_card`
127
- 7. Escape hatch → `call_api` (allowlisted paths only)
129
+ 7. Escape hatch → `list_routes` to find a path, then `call_api` (allowlisted paths only)
128
130
 
129
131
  **Never invent IDs** — resolve them or take them from live / schedule / search results.
130
132
 
@@ -132,7 +134,7 @@ Mnemonic: **resolve → live/schedule → summary → deep**.
132
134
 
133
135
  ---
134
136
 
135
- ## Tool catalog (15)
137
+ ## Tool catalog (16)
136
138
 
137
139
  All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (the server name already brands the surface).
138
140
 
@@ -151,9 +153,38 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
151
153
  | `head_to_head` | Composed H2H (no first-class REST H2H) | Rivalry / series record; preview context | Single-side form only; live scores; standings | `game`, `sideA`, `sideB`, `entityType?`, `limit?`, `from?`, `to?` |
152
154
  | `standings` | League/event tables or world/division rankings | Playoff picture; UFC rankings; CDL / CS2 tables | Team form; live scores; match recaps | `game`, `scope?`, `leagueId?`, `tournamentId?`, `eventId?`, `season?`, `stage?`, `division?`, `limit?` |
153
155
  | `match_preview` | Pre-match briefing: sides, rosters/form, H2H stub | Upcoming deep link; pick’ems; preview cards | Completed recaps; deep live state | `game`, `matchId` **or** (`teamA` + `teamB`), `eventId?`, `includeH2H?`, `includeRosters?`, `recentLimit?` |
154
- | `event_card` | Event / fight-night card: identity, bout/match list, optional standings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
156
+ | `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
157
+ | `list_routes` | Index of raw REST routes from the live OpenAPI spec (method, path, summary, tag) | Finding a long-tail path before `call_api`; checking an endpoint exists | A curated tool covers the outcome | `game?`, `q?`, `limit?` |
155
158
  | `call_api` | Allowlisted raw REST (`data.raw`) | Fortnite / long-tail paths; payload debugging | Any job covered by a curated tool | `path`, `method?`, `queryJson?`, `bodyJson?` |
156
159
 
160
+ ### Images
161
+
162
+ Fighter and team sides carry an `images` object wherever upstream supplies one —
163
+ on `event_card` bout corners and on `player_profile`. No extra call, no N+1.
164
+
165
+ ```jsonc
166
+ "team1": {
167
+ "name": "Islam Makhachev",
168
+ "slug": "islam-makhachev",
169
+ "record": { "wins": 28, "losses": 1, "draws": 0, "text": "28-1-0 (W-L-D)" },
170
+ "championStatus": "champion",
171
+ "images": {
172
+ "headshotUrl": "https://ufc.com/…/MAKHACHEV_ISLAM_BELT_01-18.png",
173
+ "bodyImageUrl": "https://ufc.com/…/athlete_bio_full_body/…",
174
+ "imageUrl": "https://ufc.com/…/event_fight_card_upper_body/…",
175
+ "proxiedImageUrl": "https://api.citoapi.com/api/v1/public/images/ufc/aHR0cHM6…"
176
+ }
177
+ }
178
+ ```
179
+
180
+ **Use `proxiedImageUrl` in a browser.** `ufc.com` sends no CORS header and can
181
+ hotlink-block, so raw URLs render as broken images in a web UI. The proxied URL
182
+ is served by the API and is safe to put in an `<img src>`.
183
+
184
+ When a key is `null` the image does not exist for that entity; when the whole
185
+ `images` object is absent, upstream sent nothing for that side. The object is
186
+ never partially shaped — if any image exists, all four keys are present.
187
+
157
188
  ### Games
158
189
 
159
190
  | Game | Depth | Notes |
@@ -6,7 +6,7 @@ import { playerTools } from './player.js';
6
6
  import { teamTools } from './team.js';
7
7
  import { standingsTools } from './standings.js';
8
8
  import { insightTools } from './insight.js';
9
- /** Curated outcome-tool catalog (15 tools). Order matches preferred cold-start ladder. */
9
+ /** Curated outcome-tool catalog (16 tools). Order matches preferred cold-start ladder. */
10
10
  export const allTools = [
11
11
  ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
12
12
  ...resolveTools,
@@ -16,7 +16,8 @@ export const allTools = [
16
16
  ...teamTools,
17
17
  ...standingsTools,
18
18
  ...insightTools,
19
- ...metaTools.filter((t) => t.name === 'call_api'),
19
+ // Escape-hatch pair last: discover routes, then call one.
20
+ ...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
20
21
  ];
21
22
  export function getTool(name) {
22
23
  return allTools.find((t) => t.name === name);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch } from './normalize.js';
6
+ import { normalizeMatch, sortByCardOrder } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
8
8
  async function loadSide(ctx, game, side, recentLimit, includeRosters) {
9
9
  const partial = [];
@@ -573,7 +573,14 @@ When to use:
573
573
  - CS2 event hub with match list
574
574
  - Tournament/event overview before match_preview drill-down
575
575
 
576
- Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only.
576
+ Bouts come back in card order main event first, then prelims, then early prelims.
577
+ Each bout carries weightClass, titleBout, card placement, and (once fought) result
578
+ { method, round, time, referee, winnerSlug }. Each corner carries images
579
+ { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
580
+ championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
581
+ browsers — ufc.com sends no CORS header. You do not need call_api per fighter for faces.
582
+
583
+ Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only; per-fighter call_api just to fetch headshots.
577
584
 
578
585
  Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
579
586
 
@@ -585,12 +592,17 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
585
592
  type: 'object',
586
593
  additionalProperties: false,
587
594
  required: ['game'],
595
+ // The handler needs an event to look up, so `game` alone always fails.
596
+ // Say so in the schema rather than letting a client believe game suffices.
597
+ anyOf: [{ required: ['eventIdOrSlug'] }, { required: ['q'] }],
588
598
  properties: {
589
599
  game: gameSchema({ allowAll: false, required: true }),
590
- eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Example: "ufc-300".', 'ufc-300'),
600
+ eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Required unless q is given. Example: "ufc-300".', 'ufc-300'),
591
601
  q: stringSchema('Free-text event name when id/slug unknown. Example: "UFC 300". Resolves within this tool — still prefer resolve_entity when disambiguating many hits.', 'UFC 300'),
592
602
  includeBouts: boolSchema('Include bout/match list (default true).', true),
593
- includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false).', false),
603
+ includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false). ' +
604
+ 'For UFC this also joins divisional rank and movement onto each bout corner as team.rank / team.rankMovement — ' +
605
+ 'bout rows themselves carry no rank. Costs one extra upstream call.', false),
594
606
  limit: limitSchema({
595
607
  default: 20,
596
608
  max: 50,
@@ -684,12 +696,14 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
684
696
  if (includeBouts) {
685
697
  const embedded = extractRows(inner.bouts ?? inner.fights ?? data.bouts);
686
698
  if (embedded.length) {
687
- bouts = embedded.slice(0, limit).map((row) => normalizeMatch('ufc', {
699
+ // Order the full card before slicing — truncating upstream order
700
+ // first can drop the main event and keep an early prelim.
701
+ bouts = sortByCardOrder(embedded.map((row) => normalizeMatch('ufc', {
688
702
  ...(asRecord(row) ?? {}),
689
703
  eventName: event.name,
690
704
  eventId: event.id,
691
705
  eventSlug: event.slug,
692
- }, undefined));
706
+ }, undefined))).slice(0, limit);
693
707
  }
694
708
  }
695
709
  }
@@ -728,14 +742,12 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
728
742
  upstreamCalls += 1;
729
743
  rateLimit = { ...rateLimit, ...boutsRes.headers };
730
744
  if (boutsRes.ok) {
731
- bouts = extractRows(boutsRes.data)
732
- .slice(0, limit)
733
- .map((row) => normalizeMatch('ufc', {
745
+ bouts = sortByCardOrder(extractRows(boutsRes.data).map((row) => normalizeMatch('ufc', {
734
746
  ...(asRecord(row) ?? {}),
735
747
  eventName: event.name,
736
748
  eventId: event.id,
737
749
  eventSlug: event.slug,
738
- }, undefined));
750
+ }, undefined))).slice(0, limit);
739
751
  }
740
752
  else {
741
753
  partial.push(partialFromRejection('bouts', {
@@ -750,10 +762,69 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
750
762
  upstreamCalls += 1;
751
763
  rateLimit = { ...rateLimit, ...ranks.headers };
752
764
  if (ranks.ok) {
765
+ // Bout rows carry rankText: null, so a card built from them alone is
766
+ // rank-blind. The rankings payload is one row per fighter keyed by
767
+ // slug, so join it onto the corners we already have instead of
768
+ // handing back a raw blob the caller has to re-index itself.
769
+ const rankRows = extractRows(ranks.data)
770
+ .map((row) => asRecord(row))
771
+ .filter((r) => Boolean(r));
772
+ const bySlug = new Map();
773
+ for (const r of rankRows) {
774
+ const slug = pickString(r.fighterSlug, asRecord(r.fighter)?.slug);
775
+ if (slug)
776
+ bySlug.set(slug.toLowerCase(), r);
777
+ }
778
+ const withRank = (side) => {
779
+ if (!side?.slug)
780
+ return side;
781
+ const hit = bySlug.get(side.slug.toLowerCase());
782
+ if (!hit)
783
+ return side;
784
+ const rankText = pickString(hit.rankText);
785
+ const movement = pickString(asRecord(hit.movement)?.label, hit.rankChangeText);
786
+ return {
787
+ ...side,
788
+ ...(side.rank == null && rankText ? { rank: rankText } : {}),
789
+ ...(movement ? { rankMovement: movement } : {}),
790
+ };
791
+ };
792
+ bouts = bouts.map((b) => ({ ...b, team1: withRank(b.team1), team2: withRank(b.team2) }));
793
+ // Scope the table to divisions actually on this card. The full ladder
794
+ // is 176 rows (~42KB) and doubles the response; a card with no
795
+ // bantamweight bout has no use for the bantamweight ladder, and that
796
+ // context is not free for the agent reading it.
797
+ const cardDivisions = new Set(bouts
798
+ .flatMap((b) => [b.weightClass, b.team1?.division, b.team2?.division])
799
+ .filter((d) => Boolean(d))
800
+ // "Welterweight Title" must still match the Welterweight ladder.
801
+ .map((d) => d.toLowerCase().replace(/\s+title$/, '').trim()));
802
+ const inScope = (r) => {
803
+ if (cardDivisions.size === 0)
804
+ return true;
805
+ const d = pickString(r.division, r.normalizedDivision)?.toLowerCase().trim();
806
+ return d ? cardDivisions.has(d) : false;
807
+ };
808
+ const scoped = rankRows.filter(inScope);
809
+ const shown = scoped.length > 0 ? scoped : rankRows;
810
+ const shownDivisions = [...new Set(shown.map((r) => pickString(r.division)).filter(Boolean))];
753
811
  standingsSnippet = {
754
812
  scope: 'division',
755
- note: 'UFC global rankings (not event-specific bracket)',
756
- raw: ranks.data,
813
+ note: 'UFC global rankings (not an event bracket), joined onto bout corners as team.rank / team.rankMovement. ' +
814
+ (scoped.length > 0 && scoped.length < rankRows.length
815
+ ? // Count the ladders actually returned, not the card's weight
816
+ // classes — catchweight bouts have no ranking ladder.
817
+ `Rows filtered to the ${shownDivisions.length} ranked division(s) on this card; ${rankRows.length} rows exist across all divisions.`
818
+ : 'All divisions shown.'),
819
+ divisions: shownDivisions,
820
+ rows: shown.map((r) => ({
821
+ division: pickString(r.division) ?? null,
822
+ rank: pickString(r.rankText) ?? null,
823
+ fighterSlug: pickString(r.fighterSlug) ?? null,
824
+ fighterName: pickString(r.fighterName) ?? null,
825
+ isChampion: r.isChampion === true,
826
+ movement: pickString(asRecord(r.movement)?.label) ?? null,
827
+ })),
757
828
  };
758
829
  }
759
830
  else {
@@ -88,10 +88,10 @@ const TOOL_CATALOG = [
88
88
  },
89
89
  {
90
90
  name: 'player_profile',
91
- outcome: 'Player/fighter identity + recent form',
91
+ outcome: 'Player/fighter identity + recent form, including images (headshot, body, CORS-safe proxied)',
92
92
  parallelSafe: true,
93
93
  games: [...PRIMARY_GAMES],
94
- jobs: ['player_form'],
94
+ jobs: ['player_form', 'media'],
95
95
  exampleArgs: { game: 'cs2', playerId: 'cs2-player-1', recentLimit: 10 },
96
96
  preferOver: ['manual multi-call career/trends via call_api'],
97
97
  doNotUse: 'Full team roster → team_profile; unresolved name → resolve_entity',
@@ -138,14 +138,29 @@ const TOOL_CATALOG = [
138
138
  },
139
139
  {
140
140
  name: 'event_card',
141
- outcome: 'Event / fight-night card: identity + bout/match list (+ optional standings)',
141
+ outcome: 'Event / fight-night card: identity + bout list, card-ordered (main event first), ' +
142
+ 'each corner carrying photos, record, nickname, country and weight class (+ optional standings)',
142
143
  parallelSafe: true,
143
144
  games: [...PRIMARY_GAMES],
144
- jobs: ['event_card', 'schedule', 'app_scaffold'],
145
+ jobs: ['event_card', 'schedule', 'media', 'app_scaffold'],
145
146
  exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeBouts: true },
146
- preferOver: ['call_api /ufc/events + bout expansion', 'N+1 match_summary for card list'],
147
+ preferOver: [
148
+ 'call_api /ufc/events + bout expansion',
149
+ 'N+1 match_summary for card list',
150
+ 'N+1 call_api per fighter for headshots — corners already include images',
151
+ ],
147
152
  doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
148
153
  },
154
+ {
155
+ name: 'list_routes',
156
+ outcome: 'Index of raw REST routes from the live OpenAPI spec (method, path, summary)',
157
+ parallelSafe: true,
158
+ games: [...PRIMARY_GAMES, 'fortnite'],
159
+ jobs: ['app_scaffold'],
160
+ exampleArgs: { game: 'ufc', q: 'rankings' },
161
+ preferOver: ['guessing a REST path for call_api', 'fetching the full 90KB spec by hand'],
162
+ doNotUse: 'A curated tool covers the outcome → list_capabilities',
163
+ },
149
164
  {
150
165
  name: 'call_api',
151
166
  outcome: 'Allowlisted raw REST escape hatch (unshaped data.raw)',
@@ -167,6 +182,13 @@ const JOBS = [
167
182
  { id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
168
183
  { id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
169
184
  { id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
185
+ // Agents asked "does this API have photos?" and, finding no job for it,
186
+ // assumed no. Photos ship on every UFC corner and profile; make that findable.
187
+ {
188
+ id: 'media',
189
+ description: 'Headshots, full-body shots and team logos for visual UIs. UFC fighters carry headshotUrl / bodyImageUrl / imageUrl plus a CORS-safe proxiedImageUrl; use the proxied URL in a browser. Available on event_card corners and player_profile without any extra call.',
190
+ recommendedTools: ['event_card', 'player_profile', 'resolve_entity'],
191
+ },
170
192
  { id: 'app_scaffold', description: 'Design-time multi-screen prototype', recommendedTools: ['list_capabilities', 'api_health', 'live_matches'] },
171
193
  ];
172
194
  const RECIPES = [
@@ -450,7 +472,18 @@ Example: { "includeGameProbes": true }`,
450
472
  });
451
473
  },
452
474
  };
453
- const ALLOWLIST_PREFIXES = ['/health', '/lol', '/cs2', '/dota2', '/cod', '/ufc', '/fortnite'];
475
+ const ALLOWLIST_PREFIXES = [
476
+ '/health',
477
+ '/lol',
478
+ '/cs2',
479
+ '/dota2',
480
+ '/cod',
481
+ '/ufc',
482
+ '/fortnite',
483
+ // The spec describes the surface call_api is allowed to reach; refusing to
484
+ // serve it left route discovery impossible except by guessing.
485
+ '/openapi.json',
486
+ ];
454
487
  function pathAllowed(path) {
455
488
  if (!path.startsWith('/') || path.startsWith('//'))
456
489
  return false;
@@ -604,6 +637,147 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
604
637
  });
605
638
  },
606
639
  };
607
- export const metaTools = [listCapabilities, apiHealth, callApi];
640
+ /**
641
+ * Games whose routes the published spec does not describe.
642
+ *
643
+ * The spec has 123 paths and zero under /lol, yet every LoL route works —
644
+ * api_health itself answers partly from /lol/leagues. An agent that treats the
645
+ * spec as the whole surface concludes LoL is unsupported and stops, so say so
646
+ * explicitly rather than returning an empty list that reads as a verdict.
647
+ */
648
+ const SPEC_OMITS = {
649
+ lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
650
+ };
651
+ export const listRoutes = {
652
+ name: 'list_routes',
653
+ description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
654
+
655
+ When to use:
656
+ - You need a long-tail path for call_api and do not want to guess
657
+ - Checking whether an endpoint exists before building around it
658
+ - Mapping what raw data backs a curated tool
659
+
660
+ Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
661
+
662
+ Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
663
+
664
+ Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
665
+
666
+ Parallel-safe: yes. Upstream cost: 1.
667
+ Example: { "game": "ufc", "q": "rankings" }`,
668
+ inputSchema: {
669
+ type: 'object',
670
+ additionalProperties: false,
671
+ properties: {
672
+ game: gameSchema({
673
+ allowAll: false,
674
+ description: 'Filter to one game prefix (also accepts fortnite). Omit for all routes.',
675
+ }),
676
+ q: stringSchema('Free-text filter over path and summary.', 'rankings'),
677
+ limit: {
678
+ type: 'integer',
679
+ minimum: 1,
680
+ maximum: 200,
681
+ default: 60,
682
+ description: 'Max routes to return (default 60, max 200).',
683
+ },
684
+ },
685
+ },
686
+ handler: async (args, ctx) => {
687
+ const started = Date.now();
688
+ const requestId = newRequestId();
689
+ // Accept fortnite here even though it is not a PRIMARY_GAME: it has 21
690
+ // documented paths and no curated tools, so it is exactly what call_api
691
+ // callers come looking for.
692
+ const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
693
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite)$/)) {
694
+ return errorEnvelope({
695
+ code: 'UNSUPPORTED_GAME',
696
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite`,
697
+ game: null,
698
+ source: 'list_routes',
699
+ requestId,
700
+ tookMs: Date.now() - started,
701
+ });
702
+ }
703
+ const q = typeof args.q === 'string' ? args.q.toLowerCase().trim() : '';
704
+ const limit = Math.min(Math.max(Number(args.limit) || 60, 1), 200);
705
+ const res = await fetchJson(ctx, '/openapi.json');
706
+ if (!res.ok) {
707
+ return errorEnvelope({
708
+ code: mapHttpToCode(res.status),
709
+ message: `openapi.json HTTP ${res.status}`,
710
+ game: gameRaw || null,
711
+ source: 'list_routes',
712
+ requestId,
713
+ tookMs: Date.now() - started,
714
+ upstreamCalls: 1,
715
+ rateLimit: res.headers,
716
+ httpStatus: res.status,
717
+ recover: [
718
+ 'Call list_capabilities for curated tools that need no route knowledge',
719
+ 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite',
720
+ ],
721
+ });
722
+ }
723
+ const spec = (res.data ?? {});
724
+ const paths = (spec.paths ?? {});
725
+ const rows = [];
726
+ for (const [path, ops] of Object.entries(paths)) {
727
+ if (!ops || typeof ops !== 'object')
728
+ continue;
729
+ for (const [method, opRaw] of Object.entries(ops)) {
730
+ if (!/^(get|post|put|patch|delete)$/i.test(method))
731
+ continue;
732
+ const op = (opRaw ?? {});
733
+ const tags = Array.isArray(op.tags) ? op.tags : [];
734
+ rows.push({
735
+ method: method.toUpperCase(),
736
+ path,
737
+ summary: typeof op.summary === 'string' ? op.summary : null,
738
+ tag: typeof tags[0] === 'string' ? tags[0] : null,
739
+ });
740
+ }
741
+ }
742
+ let filtered = rows;
743
+ if (gameRaw)
744
+ filtered = filtered.filter((r) => r.path.startsWith(`/${gameRaw}`));
745
+ if (q) {
746
+ filtered = filtered.filter((r) => r.path.toLowerCase().includes(q) || (r.summary ?? '').toLowerCase().includes(q));
747
+ }
748
+ filtered.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
749
+ const notes = [];
750
+ if (gameRaw && filtered.length === 0 && SPEC_OMITS[gameRaw])
751
+ notes.push(SPEC_OMITS[gameRaw]);
752
+ if (!gameRaw)
753
+ notes.push(SPEC_OMITS.lol);
754
+ if (filtered.length > limit) {
755
+ notes.push(`${filtered.length} routes matched; showing ${limit}. Narrow with game or q.`);
756
+ }
757
+ return successEnvelope({
758
+ data: {
759
+ specVersion: typeof spec.info?.version === 'string'
760
+ ? spec.info.version
761
+ : null,
762
+ totalDocumented: rows.length,
763
+ matched: filtered.length,
764
+ routes: filtered.slice(0, limit),
765
+ allowlistedPrefixes: ALLOWLIST_PREFIXES,
766
+ notes,
767
+ nextSteps: [
768
+ 'Prefer a curated tool when one covers the outcome (list_capabilities)',
769
+ 'call_api { path } for a route with no curated equivalent',
770
+ ],
771
+ },
772
+ game: gameRaw || null,
773
+ source: 'list_routes',
774
+ requestId,
775
+ tookMs: Date.now() - started,
776
+ upstreamCalls: 1,
777
+ rateLimit: res.headers,
778
+ });
779
+ },
780
+ };
781
+ export const metaTools = [listCapabilities, apiHealth, listRoutes, callApi];
608
782
  // silence unused import in case extractRows needed later
609
783
  void extractRows;
@@ -1,8 +1,34 @@
1
1
  /**
2
2
  * Cross-game row normalization for live/schedule boards and entity cards.
3
3
  */
4
- import { asRecord, pickString, unwrapPayload } from '../client.js';
5
- function sideFrom(name, id, slug, score) {
4
+ import { asRecord, pickString, unwrapPayload, DEFAULT_API_BASE } from '../client.js';
5
+ const IMAGE_PROXY_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
6
+ /**
7
+ * Bout rows carry raw ufc.com URLs but no proxied variant (only the fighter
8
+ * detail endpoint includes one). ufc.com can hotlink-block and sends no CORS
9
+ * header, so a browser-side card built straight off those URLs shows broken
10
+ * images. The proxy token is base64url of the source URL — verified to
11
+ * round-trip and serve HTTP 200 — so derive it rather than making the caller
12
+ * N+1 the fighter endpoint just to get a loadable image.
13
+ *
14
+ * UFC only: /public/images/lol/<token> returns HTTP 400, so other games get
15
+ * their raw URL and a null proxy rather than a fabricated link.
16
+ */
17
+ function deriveProxiedImageUrl(rawUrl, game) {
18
+ if (!rawUrl || game !== 'ufc')
19
+ return null;
20
+ let host;
21
+ try {
22
+ host = new URL(rawUrl).hostname.toLowerCase();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ if (host !== 'ufc.com' && !host.endsWith('.ufc.com'))
28
+ return null;
29
+ return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
30
+ }
31
+ function sideFrom(name, id, slug, score, extra) {
6
32
  if (!name && !id && !slug)
7
33
  return null;
8
34
  return {
@@ -10,9 +36,96 @@ function sideFrom(name, id, slug, score) {
10
36
  ...(id ? { id } : {}),
11
37
  ...(slug ? { slug } : {}),
12
38
  ...(score !== undefined ? { score } : {}),
39
+ ...(extra ?? {}),
40
+ };
41
+ }
42
+ function numOrNull(v) {
43
+ if (typeof v === 'number' && Number.isFinite(v))
44
+ return v;
45
+ if (typeof v === 'string' && v !== '' && Number.isFinite(Number(v)))
46
+ return Number(v);
47
+ return null;
48
+ }
49
+ /**
50
+ * Collect image URLs from a corner/team row and its nested profile. Returns
51
+ * undefined when the upstream carried none, so lean rows stay lean; when any
52
+ * image exists every key is present (null where absent) — a missing key would
53
+ * read as "this API has no images" rather than "no image for this one".
54
+ */
55
+ function imagesFrom(game, ...sources) {
56
+ const pick = (...keys) => {
57
+ for (const src of sources) {
58
+ if (!src)
59
+ continue;
60
+ for (const k of keys) {
61
+ const v = pickString(src[k]);
62
+ if (v)
63
+ return v;
64
+ }
65
+ }
66
+ return null;
67
+ };
68
+ const images = {
69
+ headshotUrl: pick('headshotUrl', 'headshot'),
70
+ bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
71
+ imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
72
+ proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
73
+ };
74
+ if (!images.proxiedImageUrl) {
75
+ // Prefer the headshot for the proxied variant — it is the crop a card UI wants.
76
+ images.proxiedImageUrl =
77
+ deriveProxiedImageUrl(images.headshotUrl, game) ??
78
+ deriveProxiedImageUrl(images.imageUrl, game);
79
+ }
80
+ return Object.values(images).some(Boolean) ? images : undefined;
81
+ }
82
+ function recordFrom(...sources) {
83
+ for (const src of sources) {
84
+ if (!src)
85
+ continue;
86
+ const rec = asRecord(src.record);
87
+ const text = pickString(src.recordText, rec?.text);
88
+ if (!rec && !text)
89
+ continue;
90
+ const out = {
91
+ wins: numOrNull(rec?.wins),
92
+ losses: numOrNull(rec?.losses),
93
+ draws: numOrNull(rec?.draws),
94
+ noContest: numOrNull(rec?.noContest),
95
+ text: text ?? null,
96
+ };
97
+ if (Object.values(out).some((v) => v !== null))
98
+ return out;
99
+ }
100
+ return undefined;
101
+ }
102
+ /** Enrichment shared by UFC corners and (where upstream supplies it) team rows. */
103
+ function sideExtras(o, game) {
104
+ const profile = asRecord(o.profile) ?? undefined;
105
+ const fighter = asRecord(o.fighter) ?? undefined;
106
+ const images = imagesFrom(game, o, profile, fighter);
107
+ const record = recordFrom(o, profile, fighter);
108
+ const nickname = pickString(profile?.nickname, fighter?.nickname, o.nickname);
109
+ const rank = pickString(o.rankText, o.rank, profile?.rankText);
110
+ const championStatus = pickString(o.championStatus, profile?.championStatus);
111
+ const division = pickString(profile?.division, o.division, o.weightClass);
112
+ const country = pickString(o.country, profile?.country);
113
+ const flag = pickString(o.flag, profile?.flag);
114
+ const outcome = pickString(o.outcome);
115
+ return {
116
+ ...(nickname ? { nickname } : {}),
117
+ ...(images ? { images } : {}),
118
+ ...(record ? { record } : {}),
119
+ ...(rank ? { rank } : {}),
120
+ // "none" carries no signal for a page; only surface an actual belt state.
121
+ ...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
122
+ ...(division ? { division } : {}),
123
+ ...(country ? { country } : {}),
124
+ ...(flag ? { flag } : {}),
125
+ ...(outcome ? { outcome } : {}),
13
126
  };
14
127
  }
15
- function nestedSide(raw) {
128
+ function nestedSide(raw, game) {
16
129
  const o = asRecord(raw);
17
130
  if (!o) {
18
131
  if (typeof raw === 'string')
@@ -29,7 +142,7 @@ function nestedSide(raw) {
29
142
  : typeof scoreRaw === 'string' && scoreRaw !== ''
30
143
  ? Number(scoreRaw)
31
144
  : null;
32
- return sideFrom(name, id, slug, Number.isFinite(score) ? score : null);
145
+ return sideFrom(name, id, slug, Number.isFinite(score) ? score : null, sideExtras(o, game));
33
146
  }
34
147
  function scoreNum(v) {
35
148
  if (typeof v === 'number' && Number.isFinite(v))
@@ -42,9 +155,9 @@ export function normalizeMatch(game, row, forcedStatus) {
42
155
  // Always peel { success, data } so UFC bout fighters[] / status are visible.
43
156
  const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
44
157
  const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
45
- let team1 = nestedSide(r.team1) ??
158
+ let team1 = nestedSide(r.team1, game) ??
46
159
  sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
47
- let team2 = nestedSide(r.team2) ??
160
+ let team2 = nestedSide(r.team2, game) ??
48
161
  sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
49
162
  // CS2 rows nest team1/team2 objects that lack a score; a nested side must not
50
163
  // shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
@@ -76,7 +189,7 @@ export function normalizeMatch(game, row, forcedStatus) {
76
189
  const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
77
190
  const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
78
191
  const score = scoreNum(o.score ?? o.points);
79
- return sideFrom(name, id, slug, score);
192
+ return sideFrom(name, id, slug, score, sideExtras(o, game));
80
193
  };
81
194
  team1 =
82
195
  team1 ??
@@ -106,8 +219,8 @@ export function normalizeMatch(game, row, forcedStatus) {
106
219
  }
107
220
  // COD sometimes uses teams[]
108
221
  if ((!team1 || !team2) && Array.isArray(r.teams)) {
109
- team1 = team1 ?? nestedSide(r.teams[0]);
110
- team2 = team2 ?? nestedSide(r.teams[1]);
222
+ team1 = team1 ?? nestedSide(r.teams[0], game);
223
+ team2 = team2 ?? nestedSide(r.teams[1], game);
111
224
  }
112
225
  const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
113
226
  const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
@@ -183,6 +296,22 @@ export function normalizeMatch(game, row, forcedStatus) {
183
296
  (matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
184
297
  vsLabel;
185
298
  }
299
+ // Bout metadata already present on the upstream row. Passing it through here
300
+ // is what stops an agent from N+1'ing call_api to rebuild a fight card.
301
+ const cardSection = pickString(r.cardSection, r.cardSegment, r.segment);
302
+ const cardPosition = pickString(r.cardPosition);
303
+ const cardSectionOrder = numOrNull(r.cardSectionOrder);
304
+ const boutOrder = numOrNull(r.boutOrder);
305
+ const hasCard = cardSection != null || cardPosition != null || cardSectionOrder != null || boutOrder != null;
306
+ const method = pickString(r.method);
307
+ const methodDetails = pickString(r.methodDetails);
308
+ const resultTime = pickString(r.resultTime);
309
+ // UFC sends referee as { id, name, firstName, lastName } — not a string.
310
+ const referee = pickString(r.referee, asRecord(r.referee)?.name);
311
+ const winnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
312
+ const resultRound = numOrNull(r.resultRound);
313
+ const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
314
+ const isCancelled = r.isCancelled === true;
186
315
  return {
187
316
  game,
188
317
  matchId,
@@ -193,8 +322,55 @@ export function normalizeMatch(game, row, forcedStatus) {
193
322
  team2,
194
323
  event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
195
324
  league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
325
+ ...(weightOrClass ? { weightClass: weightOrClass } : {}),
326
+ ...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
327
+ ...(hasCard
328
+ ? {
329
+ card: {
330
+ section: cardSection ?? null,
331
+ sectionOrder: cardSectionOrder,
332
+ position: cardPosition ?? null,
333
+ order: boutOrder,
334
+ },
335
+ }
336
+ : {}),
337
+ ...(hasResultDetail
338
+ ? {
339
+ result: {
340
+ method: method ?? null,
341
+ methodDetails: methodDetails ?? null,
342
+ round: resultRound,
343
+ time: resultTime ?? null,
344
+ referee: referee ?? null,
345
+ winnerSlug: winnerSlug ?? null,
346
+ },
347
+ }
348
+ : {}),
349
+ ...(isCancelled
350
+ ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
351
+ : {}),
196
352
  };
197
353
  }
354
+ /**
355
+ * Sort a fight card the way it is presented: main card before prelims, and the
356
+ * main event at the top of its section. Rows without placement keep their
357
+ * upstream order behind those that have it.
358
+ */
359
+ export function sortByCardOrder(rows) {
360
+ const rank = (x, i) => ({
361
+ section: x.card?.sectionOrder ?? Number.MAX_SAFE_INTEGER,
362
+ order: x.card?.order ?? Number.MAX_SAFE_INTEGER,
363
+ i,
364
+ });
365
+ return rows
366
+ .map((x, i) => ({ x, k: rank(x, i) }))
367
+ .sort((a, b) => a.k.section !== b.k.section
368
+ ? a.k.section - b.k.section
369
+ : a.k.order !== b.k.order
370
+ ? a.k.order - b.k.order
371
+ : a.k.i - b.k.i)
372
+ .map((e) => e.x);
373
+ }
198
374
  export function entityRef(row, type, game) {
199
375
  const r = asRecord(row) ?? {};
200
376
  const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
@@ -29,6 +29,24 @@ function identityFrom(game, raw, idHint, slugHint) {
29
29
  : null,
30
30
  role: pickString(r.role, r.position) ?? null,
31
31
  nationality: pickString(r.nationality, r.country) ?? null,
32
+ /**
33
+ * Always present, keys always present, null when the upstream has no photo.
34
+ *
35
+ * REST has carried headshot/body/proxied images on /ufc/fighters/{slug} all
36
+ * along; this shaper silently dropped them, so every agent concluded the
37
+ * product had no images and either shipped faceless UIs or N+1'd call_api
38
+ * per fighter to dig them out. Emitting explicit nulls is the point: silence
39
+ * reads as "not supported", null reads as "not available for this one".
40
+ *
41
+ * proxiedImageUrl is the one builders should prefer — it is served from our
42
+ * own domain, so it works from a browser without hotlink/CORS trouble.
43
+ */
44
+ images: {
45
+ headshotUrl: pickString(r.headshotUrl, r.headshot) ?? null,
46
+ bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
47
+ imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
48
+ proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
49
+ },
32
50
  };
33
51
  }
34
52
  export const playerProfile = {
@@ -332,7 +350,14 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
332
350
  .map((row) => {
333
351
  const r = asRecord(row) ?? {};
334
352
  const bout = asRecord(r.bout) ?? row;
335
- const m = normalizeMatch('ufc', bout, 'completed');
353
+ // Never force 'completed'. A fighter's history endpoint also
354
+ // returns bouts that are booked but not yet fought, and forcing
355
+ // the status told agents that a future main event had already
356
+ // happened (Hernandez vs Rodrigues, ufc-12928: status
357
+ // 'confirmed', no method, no winner, event still scheduled,
358
+ // reported as completed with result null). Let normalizeMatch
359
+ // derive it from the result and the start time instead.
360
+ const m = normalizeMatch('ufc', bout);
336
361
  return {
337
362
  ...m,
338
363
  result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
@@ -354,7 +379,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
354
379
  .map((row) => {
355
380
  const r = asRecord(row) ?? {};
356
381
  const bout = asRecord(r.bout) ?? row;
357
- return normalizeMatch('ufc', bout, 'completed');
382
+ // Same as above: derive, never assert. See the note there.
383
+ return normalizeMatch('ufc', bout);
358
384
  });
359
385
  }
360
386
  else {
@@ -64,9 +64,82 @@ export function stringSchema(description, example) {
64
64
  schema.examples = [example];
65
65
  return schema;
66
66
  }
67
+ /** Levenshtein distance, capped for short arg names. */
68
+ function editDistance(a, b) {
69
+ const m = a.length;
70
+ const n = b.length;
71
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= m; i++) {
73
+ const cur = [i];
74
+ for (let j = 1; j <= n; j++) {
75
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
76
+ }
77
+ prev = cur;
78
+ }
79
+ return prev[n];
80
+ }
81
+ /** Closest known parameter to a mistyped one, if it is plausibly a typo. */
82
+ function suggestParam(unknown, known) {
83
+ const u = unknown.toLowerCase();
84
+ let best = null;
85
+ for (const k of known) {
86
+ const d = editDistance(u, k.toLowerCase());
87
+ if (!best || d < best.d)
88
+ best = { name: k, d };
89
+ }
90
+ if (!best)
91
+ return null;
92
+ // Accept near-misses, plus prefix relationships like query -> q.
93
+ const threshold = Math.max(2, Math.floor(Math.max(u.length, best.name.length) / 3));
94
+ if (best.d <= threshold)
95
+ return best.name;
96
+ const prefixHit = known.find((k) => u.startsWith(k.toLowerCase()) || k.toLowerCase().startsWith(u));
97
+ return prefixHit ?? null;
98
+ }
99
+ /**
100
+ * Args the tool does not declare, when its schema says additionalProperties:false.
101
+ *
102
+ * Every tool already declared that, but nothing enforced it: the MCP SDK does
103
+ * not validate arguments against inputSchema, so a plausible-but-wrong name was
104
+ * dropped on the floor and the tool ran with a default. search_entities
105
+ * { game:"ufc", query:"Jon Jones" } — `q` is the real parameter — returned
106
+ * ok:true with an unrelated roster. Silently wrong beats loudly wrong for a
107
+ * human skimming output, but for an agent it is far worse: it has no way to
108
+ * tell a real answer from a discarded question.
109
+ */
110
+ export function unknownArgKeys(def, args) {
111
+ const schema = def.inputSchema ?? {};
112
+ if (schema.additionalProperties !== false)
113
+ return [];
114
+ const props = schema.properties;
115
+ if (!props || typeof props !== 'object')
116
+ return [];
117
+ const known = Object.keys(props);
118
+ return Object.keys(args ?? {}).filter((k) => !known.includes(k));
119
+ }
67
120
  /** Normalize MCP handler return to MCP content result. */
68
121
  export async function runTool(def, args, ctx) {
69
122
  try {
123
+ const unknown = unknownArgKeys(def, args ?? {});
124
+ if (unknown.length > 0) {
125
+ const known = Object.keys((def.inputSchema.properties ?? {}));
126
+ const pairs = unknown.map((k) => ({ k, s: suggestParam(k, known) }));
127
+ const hints = pairs.map(({ k, s }) => s ? `"${k}" — did you mean "${s}"?` : `"${k}" is not accepted`);
128
+ const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
129
+ return toResult(errorEnvelope({
130
+ code: 'VALIDATION',
131
+ message: `${def.name}: unknown argument${unknown.length > 1 ? 's' : ''} ` +
132
+ // Avoid "?." when the last hint is a did-you-mean question.
133
+ `${hints.join('; ')}${hints[hints.length - 1].endsWith('?') ? '' : '.'} ` +
134
+ `Accepted: ${known.join(', ')}.`,
135
+ game: typeof args?.game === 'string' ? args.game : null,
136
+ source: def.name,
137
+ recover: [
138
+ ...pairs.map(({ k, s }) => s ? `Rename "${k}" to "${s}" and retry` : `Remove "${k}" and retry`),
139
+ `Accepted arguments: ${known.join(', ')}`,
140
+ ],
141
+ }));
142
+ }
70
143
  const result = await def.handler(args ?? {}, ctx);
71
144
  if (result && typeof result === 'object' && 'content' in result) {
72
145
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.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": {
@@ -17,7 +17,8 @@
17
17
  "build": "tsc -p tsconfig.json",
18
18
  "test": "tsx --test src/**/*.test.ts src/*.test.ts",
19
19
  "start": "node dist/index.js",
20
- "prepublishOnly": "npm run build"
20
+ "prepublishOnly": "npm run build && npm test && npm run smoke",
21
+ "smoke": "node scripts/smoke.mjs"
21
22
  },
22
23
  "dependencies": {
23
24
  "@modelcontextprotocol/sdk": "1.29.0"