wc26-mcp 0.2.0 → 0.3.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.
package/dist/index.js CHANGED
@@ -7,10 +7,17 @@ import { teams } from "./data/teams.js";
7
7
  import { groups } from "./data/groups.js";
8
8
  import { venues } from "./data/venues.js";
9
9
  import { teamProfiles } from "./data/team-profiles.js";
10
+ import { cityGuides } from "./data/city-guides.js";
11
+ import { historicalMatchups } from "./data/historical-matchups.js";
12
+ import { visaInfo } from "./data/visa-info.js";
13
+ import { fanZones } from "./data/fan-zones.js";
14
+ import { news } from "./data/news.js";
15
+ import { injuries } from "./data/injuries.js";
16
+ import { tournamentOdds } from "./data/odds.js";
10
17
  // ── Server setup ────────────────────────────────────────────────────
11
18
  const server = new McpServer({
12
19
  name: "wc26-mcp",
13
- version: "0.2.0",
20
+ version: "0.3.1",
14
21
  });
15
22
  // ── Helpers ─────────────────────────────────────────────────────────
16
23
  function json(data) {
@@ -39,18 +46,27 @@ function haversineDistance(lat1, lng1, lat2, lng2) {
39
46
  return { miles: Math.round(km * 0.621371), km: Math.round(km) };
40
47
  }
41
48
  function convertTime(date, timeUtc, timezone) {
42
- const dt = new Date(`${date}T${timeUtc}:00Z`);
43
- const formatter = new Intl.DateTimeFormat("en-CA", {
44
- timeZone: timezone,
45
- year: "numeric", month: "2-digit", day: "2-digit",
46
- hour: "2-digit", minute: "2-digit",
47
- hour12: false,
48
- });
49
- const parts = Object.fromEntries(formatter.formatToParts(dt).map((p) => [p.type, p.value]));
50
- return {
51
- date: `${parts.year}-${parts.month}-${parts.day}`,
52
- time: `${parts.hour}:${parts.minute}`,
53
- };
49
+ try {
50
+ const dt = new Date(`${date}T${timeUtc}:00Z`);
51
+ const formatter = new Intl.DateTimeFormat("en-CA", {
52
+ timeZone: timezone,
53
+ year: "numeric", month: "2-digit", day: "2-digit",
54
+ hour: "2-digit", minute: "2-digit",
55
+ hour12: false,
56
+ });
57
+ const parts = Object.fromEntries(formatter.formatToParts(dt).map((p) => [p.type, p.value]));
58
+ return {
59
+ date: `${parts.year}-${parts.month}-${parts.day}`,
60
+ time: `${parts.hour}:${parts.minute}`,
61
+ };
62
+ }
63
+ catch {
64
+ return { date, time: timeUtc, error: `Invalid timezone "${timezone}". Use IANA format like "America/New_York" or "Europe/London".` };
65
+ }
66
+ }
67
+ function resolveTeam(input) {
68
+ const tid = input.toLowerCase();
69
+ return teams.find(t => t.id === tid || t.code.toLowerCase() === tid || t.name.toLowerCase() === tid);
54
70
  }
55
71
  function enrichMatch(m, timezone) {
56
72
  const venue = venues.find((v) => v.id === m.venue_id);
@@ -120,19 +136,23 @@ const matchStatuses = [
120
136
  "cancelled",
121
137
  ];
122
138
  server.registerTool("get_matches", {
139
+ annotations: { readOnlyHint: true },
123
140
  title: "Get Matches",
124
141
  description: "Query FIFA World Cup 2026 matches. Filter by date, date range, team, group, venue, round, or status. Returns enriched match data with team names, venue details, and local venue time. Optionally pass a timezone to convert match times to any IANA timezone.",
125
142
  inputSchema: z.object({
126
143
  date: z
127
144
  .string()
145
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format")
128
146
  .optional()
129
147
  .describe("Exact date in YYYY-MM-DD format"),
130
148
  date_from: z
131
149
  .string()
150
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format")
132
151
  .optional()
133
152
  .describe("Start of date range (YYYY-MM-DD), inclusive"),
134
153
  date_to: z
135
154
  .string()
155
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format")
136
156
  .optional()
137
157
  .describe("End of date range (YYYY-MM-DD), inclusive"),
138
158
  team: z
@@ -172,15 +192,34 @@ server.registerTool("get_matches", {
172
192
  result = result.filter((m) => m.date <= args.date_to);
173
193
  }
174
194
  if (args.team) {
175
- const tid = args.team.toLowerCase();
176
- const team = teams.find((t) => t.id === tid || t.code.toLowerCase() === tid);
177
- const teamId = team?.id ?? tid;
178
- result = result.filter((m) => m.home_team_id === teamId || m.away_team_id === teamId);
195
+ const team = resolveTeam(args.team);
196
+ if (!team) {
197
+ return json({
198
+ error: `Team '${args.team}' not found.`,
199
+ suggestion: "Use the get_teams tool to see all available teams and their IDs.",
200
+ });
201
+ }
202
+ result = result.filter((m) => m.home_team_id === team.id || m.away_team_id === team.id);
179
203
  }
180
204
  if (args.group) {
181
- result = result.filter((m) => m.group?.toUpperCase() === args.group.toUpperCase());
205
+ const groupLetter = args.group.toUpperCase();
206
+ const validGroups = [...new Set(matches.map((m) => m.group).filter(Boolean))];
207
+ if (!validGroups.includes(groupLetter)) {
208
+ return json({
209
+ error: `Group '${args.group}' not found.`,
210
+ suggestion: `Valid groups: ${validGroups.sort().join(", ")}. Use the get_groups tool for full group details.`,
211
+ });
212
+ }
213
+ result = result.filter((m) => m.group?.toUpperCase() === groupLetter);
182
214
  }
183
215
  if (args.venue) {
216
+ const venueExists = venues.some((v) => v.id === args.venue);
217
+ if (!venueExists) {
218
+ return json({
219
+ error: `Venue '${args.venue}' not found.`,
220
+ suggestion: "Use the get_venues tool to see all available venues and their IDs.",
221
+ });
222
+ }
184
223
  result = result.filter((m) => m.venue_id === args.venue);
185
224
  }
186
225
  if (args.round) {
@@ -204,6 +243,7 @@ const confederations = [
204
243
  "OFC",
205
244
  ];
206
245
  server.registerTool("get_teams", {
246
+ annotations: { readOnlyHint: true },
207
247
  title: "Get Teams",
208
248
  description: "Query FIFA World Cup 2026 teams. Filter by group, confederation, or host status. Returns team details including FIFA ranking and flag emoji.",
209
249
  inputSchema: z.object({
@@ -238,6 +278,7 @@ server.registerTool("get_teams", {
238
278
  });
239
279
  // ── Tool: get_groups ────────────────────────────────────────────────
240
280
  server.registerTool("get_groups", {
281
+ annotations: { readOnlyHint: true },
241
282
  title: "Get Groups",
242
283
  description: "Get FIFA World Cup 2026 group details. Returns teams, venues, and match schedule for each group. Optionally filter to a specific group.",
243
284
  inputSchema: z.object({
@@ -258,6 +299,7 @@ server.registerTool("get_groups", {
258
299
  });
259
300
  // ── Tool: get_venues ────────────────────────────────────────────────
260
301
  server.registerTool("get_venues", {
302
+ annotations: { readOnlyHint: true },
261
303
  title: "Get Venues",
262
304
  description: "Query FIFA World Cup 2026 venues across the USA, Mexico, and Canada. Filter by country, city, or region. Returns full venue details including capacity, coordinates, and notable events.",
263
305
  inputSchema: z.object({
@@ -280,7 +322,7 @@ server.registerTool("get_venues", {
280
322
  result = result.filter((v) => v.country === args.country);
281
323
  }
282
324
  if (args.city) {
283
- result = result.filter((v) => v.city.toLowerCase() === args.city.toLowerCase());
325
+ result = result.filter((v) => v.city.toLowerCase().includes(args.city.toLowerCase()));
284
326
  }
285
327
  if (args.region) {
286
328
  result = result.filter((v) => v.region === args.region);
@@ -292,15 +334,18 @@ server.registerTool("get_venues", {
292
334
  });
293
335
  // ── Tool: get_schedule ──────────────────────────────────────────────
294
336
  server.registerTool("get_schedule", {
337
+ annotations: { readOnlyHint: true },
295
338
  title: "Get Schedule",
296
339
  description: "Get the full FIFA World Cup 2026 tournament schedule organized by date. Optionally filter to a specific date range. Each day includes all matches with team names, venues, kick-off times (UTC), and local venue time. Optionally pass a timezone to convert all match times.",
297
340
  inputSchema: z.object({
298
341
  date_from: z
299
342
  .string()
343
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format")
300
344
  .optional()
301
345
  .describe("Start date (YYYY-MM-DD), defaults to tournament start (2026-06-11)"),
302
346
  date_to: z
303
347
  .string()
348
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format")
304
349
  .optional()
305
350
  .describe("End date (YYYY-MM-DD), defaults to tournament end (2026-07-19)"),
306
351
  timezone: z
@@ -340,6 +385,7 @@ server.registerTool("get_schedule", {
340
385
  });
341
386
  // ── Tool: get_team_profile ───────────────────────────────────────────
342
387
  server.registerTool("get_team_profile", {
388
+ annotations: { readOnlyHint: true },
343
389
  title: "Get Team Profile",
344
390
  description: "Get a detailed profile for any FIFA World Cup 2026 team. Returns coach, playing style, key players with clubs, World Cup history, and qualifying summary. Use team ID or FIFA code (e.g. 'usa', 'BRA', 'arg').",
345
391
  inputSchema: z.object({
@@ -348,8 +394,7 @@ server.registerTool("get_team_profile", {
348
394
  .describe("Team ID or FIFA code (e.g. 'usa', 'BRA', 'arg'). Case-insensitive."),
349
395
  }),
350
396
  }, async (args) => {
351
- const tid = args.team.toLowerCase();
352
- const team = teams.find((t) => t.id === tid || t.code.toLowerCase() === tid);
397
+ const team = resolveTeam(args.team);
353
398
  if (!team) {
354
399
  return json({
355
400
  error: `Team '${args.team}' not found.`,
@@ -374,14 +419,56 @@ server.registerTool("get_team_profile", {
374
419
  world_cup_history: null,
375
420
  qualifying_summary: "No qualifying data available.",
376
421
  }),
422
+ injury_report: injuries
423
+ .filter((i) => i.team_id === team.id)
424
+ .map((i) => ({
425
+ player: i.player,
426
+ position: i.position,
427
+ injury: i.injury,
428
+ status: i.status,
429
+ expected_return: i.expected_return,
430
+ last_updated: i.last_updated,
431
+ })),
432
+ tournament_odds: (() => {
433
+ const winnerOdds = tournamentOdds.tournament_winner.find((o) => o.team_id === team.id);
434
+ const groupPred = tournamentOdds.group_predictions.find((g) => g.group === team.group);
435
+ const darkHorse = tournamentOdds.dark_horses.find((d) => d.team_id === team.id);
436
+ return {
437
+ ...(winnerOdds ? { winner_odds: winnerOdds.odds, implied_probability: winnerOdds.implied_probability } : {}),
438
+ ...(groupPred ? {
439
+ group_prediction: {
440
+ favorites: groupPred.favorites.map((f) => teamName(f)),
441
+ dark_horse: teamName(groupPred.dark_horse),
442
+ narrative: groupPred.narrative,
443
+ },
444
+ } : {}),
445
+ ...(darkHorse ? { dark_horse_pick: darkHorse.reason } : {}),
446
+ };
447
+ })(),
448
+ recent_news: news
449
+ .filter((n) => {
450
+ if (n.related_teams.includes(team.id))
451
+ return true;
452
+ // Fallback: text match on team name in title/summary
453
+ const text = `${n.title} ${n.summary}`.toLowerCase();
454
+ return text.includes(team.name.toLowerCase()) ||
455
+ (team.code !== "TBD" && text.includes(team.code.toLowerCase()));
456
+ })
457
+ .slice(0, 5)
458
+ .map((n) => ({ title: n.title, date: n.date, source: n.source, summary: n.summary, url: n.url })),
377
459
  related_tools: [
378
460
  "Use get_matches with team filter to see this team's match schedule",
379
461
  "Use get_groups to see this team's group rivals and venue assignments",
462
+ "Use get_visa_info to check entry requirements for this team's fans traveling to host countries",
463
+ "Use get_injuries for full injury details across all teams",
464
+ "Use get_odds for complete tournament predictions",
465
+ "Use get_news to see all World Cup headlines",
380
466
  ],
381
467
  });
382
468
  });
383
469
  // ── Tool: get_nearby_venues ──────────────────────────────────────────
384
470
  server.registerTool("get_nearby_venues", {
471
+ annotations: { readOnlyHint: true },
385
472
  title: "Get Nearby Venues",
386
473
  description: "Find venues near a given World Cup venue, sorted by distance. Useful for planning multi-match trips. Returns distance in miles and kilometers between all venue pairs.",
387
474
  inputSchema: z.object({
@@ -430,6 +517,199 @@ server.registerTool("get_nearby_venues", {
430
517
  ],
431
518
  });
432
519
  });
520
+ // ── Tool: get_city_guide ────────────────────────────────────────────
521
+ server.registerTool("get_city_guide", {
522
+ annotations: { readOnlyHint: true },
523
+ title: "Get City Guide",
524
+ description: "Get a travel guide for any FIFA World Cup 2026 host city. Returns highlights, getting there, food & drink recommendations, things to do, and local tips. Accepts venue ID (e.g. 'metlife'), city name (e.g. 'Miami'), or metro area name (e.g. 'New York'). Case-insensitive.",
525
+ inputSchema: z.object({
526
+ city: z
527
+ .string()
528
+ .describe("Venue ID (e.g. 'metlife'), city name (e.g. 'Miami Gardens'), or metro area (e.g. 'New York'). Case-insensitive."),
529
+ }),
530
+ }, async (args) => {
531
+ const query = args.city.toLowerCase();
532
+ // Find matching venue + guide by venue ID, city name, or metro area
533
+ let venue;
534
+ let guide;
535
+ // Try venue ID first
536
+ venue = venues.find((v) => v.id === query);
537
+ if (venue) {
538
+ guide = cityGuides.find((g) => g.venue_id === venue.id);
539
+ }
540
+ // Try city name
541
+ if (!venue) {
542
+ venue = venues.find((v) => v.city.toLowerCase().includes(query));
543
+ if (venue) {
544
+ guide = cityGuides.find((g) => g.venue_id === venue.id);
545
+ }
546
+ }
547
+ // Try metro area
548
+ if (!guide) {
549
+ guide = cityGuides.find((g) => g.metro_area.toLowerCase() === query);
550
+ if (guide) {
551
+ venue = venues.find((v) => v.id === guide.venue_id);
552
+ }
553
+ }
554
+ // Try partial match on metro area (e.g. "new york" matches "New York City")
555
+ if (!guide) {
556
+ guide = cityGuides.find((g) => g.metro_area.toLowerCase().includes(query));
557
+ if (guide) {
558
+ venue = venues.find((v) => v.id === guide.venue_id);
559
+ }
560
+ }
561
+ // Try partial match on city name
562
+ if (!guide && !venue) {
563
+ venue = venues.find((v) => v.city.toLowerCase().includes(query));
564
+ if (venue) {
565
+ guide = cityGuides.find((g) => g.venue_id === venue.id);
566
+ }
567
+ }
568
+ if (!venue || !guide) {
569
+ return json({
570
+ error: `City '${args.city}' not found.`,
571
+ suggestion: "Use the get_venues tool to see all available host cities and venue IDs.",
572
+ });
573
+ }
574
+ return json({
575
+ venue: {
576
+ id: venue.id,
577
+ name: venue.name,
578
+ city: venue.city,
579
+ state_province: venue.state_province,
580
+ country: venue.country,
581
+ capacity: venue.capacity,
582
+ timezone: venue.timezone,
583
+ weather: venue.weather,
584
+ },
585
+ metro_area: guide.metro_area,
586
+ highlights: guide.highlights,
587
+ getting_there: guide.getting_there,
588
+ food_and_drink: guide.food_and_drink,
589
+ things_to_do: guide.things_to_do,
590
+ local_tips: guide.local_tips,
591
+ related_tools: [
592
+ "Use get_matches with venue filter to see matches at this venue",
593
+ "Use get_nearby_venues to find other stadiums near this city",
594
+ "Use get_visa_info to check entry requirements for fans traveling to this country",
595
+ "Use get_fan_zones to find fan festival locations in this city",
596
+ ],
597
+ });
598
+ });
599
+ // ── Tool: get_historical_matchups ────────────────────────────────────
600
+ server.registerTool("get_historical_matchups", {
601
+ annotations: { readOnlyHint: true },
602
+ title: "Get Historical Matchups",
603
+ description: "Head-to-head World Cup history between any two teams. Returns all tournament meetings, aggregate stats, and a narrative summary. Accepts team ID or FIFA code (e.g. 'bra', 'ARG', 'england'). Also checks for a scheduled 2026 match between the pair.",
604
+ inputSchema: z.object({
605
+ team_a: z
606
+ .string()
607
+ .describe("First team — ID or FIFA code (e.g. 'bra', 'ARG', 'england'). Case-insensitive."),
608
+ team_b: z
609
+ .string()
610
+ .describe("Second team — ID or FIFA code (e.g. 'eng', 'FRA', 'germany'). Case-insensitive."),
611
+ }),
612
+ }, async (args) => {
613
+ const teamA = resolveTeam(args.team_a);
614
+ const teamB = resolveTeam(args.team_b);
615
+ if (!teamA) {
616
+ return json({
617
+ error: `Team '${args.team_a}' not found.`,
618
+ suggestion: "Use the get_teams tool to see all available teams and their IDs.",
619
+ });
620
+ }
621
+ if (!teamB) {
622
+ return json({
623
+ error: `Team '${args.team_b}' not found.`,
624
+ suggestion: "Use the get_teams tool to see all available teams and their IDs.",
625
+ });
626
+ }
627
+ if (teamA.id === teamB.id) {
628
+ return json({
629
+ error: "Both inputs resolve to the same team. Please provide two different teams.",
630
+ });
631
+ }
632
+ // TBD team check
633
+ if (teamA.code === "TBD") {
634
+ return json({
635
+ note: `${teamA.name} has not been determined yet. Historical matchup data will be available once the playoff is decided.`,
636
+ team_b: { id: teamB.id, name: teamB.name, flag_emoji: teamB.flag_emoji },
637
+ related_tools: [
638
+ "Use what_to_know_now to check the latest tournament status including pending playoffs",
639
+ "Use get_team_profile to learn more about the confirmed team",
640
+ ],
641
+ });
642
+ }
643
+ if (teamB.code === "TBD") {
644
+ return json({
645
+ note: `${teamB.name} has not been determined yet. Historical matchup data will be available once the playoff is decided.`,
646
+ team_a: { id: teamA.id, name: teamA.name, flag_emoji: teamA.flag_emoji },
647
+ related_tools: [
648
+ "Use what_to_know_now to check the latest tournament status including pending playoffs",
649
+ "Use get_team_profile to learn more about the confirmed team",
650
+ ],
651
+ });
652
+ }
653
+ // Sort alphabetically for canonical lookup
654
+ const [first, second] = [teamA, teamB].sort((a, b) => a.id.localeCompare(b.id));
655
+ const record = historicalMatchups.find((h) => h.team_a === first.id && h.team_b === second.id);
656
+ // Check for a 2026 match between these teams
657
+ const upcoming2026 = matches.find((m) => (m.home_team_id === teamA.id && m.away_team_id === teamB.id) ||
658
+ (m.home_team_id === teamB.id && m.away_team_id === teamA.id));
659
+ const matchContext = upcoming2026
660
+ ? {
661
+ upcoming_2026_match: {
662
+ date: upcoming2026.date,
663
+ time_utc: upcoming2026.time_utc,
664
+ round: upcoming2026.round,
665
+ venue: venueName(upcoming2026.venue_id),
666
+ group: upcoming2026.group ?? undefined,
667
+ },
668
+ }
669
+ : {};
670
+ // No history found
671
+ if (!record) {
672
+ const profileA = teamProfiles.find((p) => p.team_id === first.id);
673
+ const profileB = teamProfiles.find((p) => p.team_id === second.id);
674
+ const firstTimerNote = (id, profile) => {
675
+ if (profile && profile.world_cup_history.appearances <= 1) {
676
+ const t = teams.find((t) => t.id === id);
677
+ return `${t?.name ?? id} is making their World Cup debut in 2026.`;
678
+ }
679
+ return null;
680
+ };
681
+ return json({
682
+ team_a: { id: first.id, name: first.name, flag_emoji: first.flag_emoji },
683
+ team_b: { id: second.id, name: second.name, flag_emoji: second.flag_emoji },
684
+ total_matches: 0,
685
+ summary: `${first.name} and ${second.name} have never met at a FIFA World Cup.`,
686
+ first_timer_notes: [firstTimerNote(first.id, profileA), firstTimerNote(second.id, profileB)].filter(Boolean),
687
+ ...matchContext,
688
+ related_tools: [
689
+ "Use get_team_profile to learn about each team's World Cup history",
690
+ "Use get_matches with team filter to see their 2026 schedules",
691
+ ],
692
+ });
693
+ }
694
+ // Return full matchup data
695
+ return json({
696
+ team_a: { id: first.id, name: first.name, flag_emoji: first.flag_emoji },
697
+ team_b: { id: second.id, name: second.name, flag_emoji: second.flag_emoji },
698
+ total_matches: record.total_matches,
699
+ team_a_wins: record.team_a_wins,
700
+ draws: record.draws,
701
+ team_b_wins: record.team_b_wins,
702
+ total_goals_team_a: record.total_goals_team_a,
703
+ total_goals_team_b: record.total_goals_team_b,
704
+ summary: record.summary,
705
+ meetings: record.meetings,
706
+ ...matchContext,
707
+ related_tools: [
708
+ "Use get_team_profile to learn more about either team",
709
+ "Use get_matches with team filter to see their 2026 schedules",
710
+ ],
711
+ });
712
+ });
433
713
  // ── Tool: what_to_know_now ──────────────────────────────────────────
434
714
  const TOURNAMENT_DATES = {
435
715
  playoff_end: "2026-03-31",
@@ -460,11 +740,13 @@ const PHASE_LABELS = {
460
740
  tournament_over: "Tournament Complete",
461
741
  };
462
742
  server.registerTool("what_to_know_now", {
743
+ annotations: { readOnlyHint: true },
463
744
  title: "What to Know Now",
464
745
  description: "Zero-query temporal briefing for the FIFA World Cup 2026. No parameters needed — just brief me. Automatically detects the current tournament phase and returns the most relevant information for today. Optionally pass a date to simulate a different day, or a timezone for local times.",
465
746
  inputSchema: z.object({
466
747
  date: z
467
748
  .string()
749
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format")
468
750
  .optional()
469
751
  .describe("Override date in YYYY-MM-DD format (for testing different phases). Defaults to today."),
470
752
  timezone: z
@@ -509,6 +791,37 @@ server.registerTool("what_to_know_now", {
509
791
  title: "Venue Summary",
510
792
  content: `${venues.length} venues across 3 countries: ${venues.filter((v) => v.country === "USA").length} in USA, ${venues.filter((v) => v.country === "Mexico").length} in Mexico, ${venues.filter((v) => v.country === "Canada").length} in Canada.`,
511
793
  });
794
+ sections.push({
795
+ title: "Fan Zones",
796
+ content: `${fanZones.length} official FIFA Fan Festival and fan zone locations across all host cities. Free entry at most sites with live screenings, concerts, food, and interactive experiences.`,
797
+ });
798
+ // Key injuries
799
+ const activeInjuries = injuries.filter((i) => i.status !== "fit");
800
+ if (activeInjuries.length > 0) {
801
+ sections.push({
802
+ title: "Key Injuries to Watch",
803
+ content: activeInjuries.map((i) => ({
804
+ player: i.player,
805
+ team: teamName(i.team_id),
806
+ status: i.status,
807
+ injury: i.injury,
808
+ expected_return: i.expected_return,
809
+ })),
810
+ });
811
+ }
812
+ // Tournament favorites
813
+ sections.push({
814
+ title: "Tournament Favorites",
815
+ content: tournamentOdds.tournament_winner.slice(0, 8).map((o) => ({
816
+ team: teamName(o.team_id),
817
+ odds: o.odds,
818
+ implied_probability: o.implied_probability,
819
+ })),
820
+ dark_horses: tournamentOdds.dark_horses.map((d) => ({
821
+ team: teamName(d.team_id),
822
+ reason: d.reason,
823
+ })),
824
+ });
512
825
  }
513
826
  else if (phase === "post_playoff") {
514
827
  headline = `${daysUntilKickoff} days until the FIFA World Cup 2026 kicks off. All 48 teams are confirmed.`;
@@ -549,6 +862,37 @@ server.registerTool("what_to_know_now", {
549
862
  content: marquee,
550
863
  });
551
864
  }
865
+ sections.push({
866
+ title: "Fan Zones",
867
+ content: `${fanZones.length} official FIFA Fan Festival and fan zone locations across all host cities. Free entry at most sites with live screenings, concerts, food, and interactive experiences.`,
868
+ });
869
+ // Key injuries
870
+ const activeInjuriesPost = injuries.filter((i) => i.status !== "fit");
871
+ if (activeInjuriesPost.length > 0) {
872
+ sections.push({
873
+ title: "Key Injuries to Watch",
874
+ content: activeInjuriesPost.map((i) => ({
875
+ player: i.player,
876
+ team: teamName(i.team_id),
877
+ status: i.status,
878
+ injury: i.injury,
879
+ expected_return: i.expected_return,
880
+ })),
881
+ });
882
+ }
883
+ // Tournament favorites
884
+ sections.push({
885
+ title: "Tournament Favorites",
886
+ content: tournamentOdds.tournament_winner.slice(0, 8).map((o) => ({
887
+ team: teamName(o.team_id),
888
+ odds: o.odds,
889
+ implied_probability: o.implied_probability,
890
+ })),
891
+ dark_horses: tournamentOdds.dark_horses.map((d) => ({
892
+ team: teamName(d.team_id),
893
+ reason: d.reason,
894
+ })),
895
+ });
552
896
  }
553
897
  else if (phase === "group_stage") {
554
898
  const todayMatches = matches.filter((m) => m.date === today);
@@ -642,15 +986,30 @@ server.registerTool("what_to_know_now", {
642
986
  });
643
987
  }
644
988
  }
989
+ // Latest news (up to 5 most recent)
990
+ const recentNews = news
991
+ .filter((n) => n.date >= new Date(new Date(today).getTime() - 7 * 86400000).toISOString().slice(0, 10))
992
+ .slice(0, 5);
993
+ if (recentNews.length > 0) {
994
+ sections.push({
995
+ title: "Latest News",
996
+ articles: recentNews.map((n) => ({
997
+ title: n.title,
998
+ date: n.date,
999
+ source: n.source,
1000
+ summary: n.summary,
1001
+ })),
1002
+ });
1003
+ }
645
1004
  const available_tools = [];
646
1005
  if (phase === "pre_playoff" || phase === "post_playoff") {
647
- available_tools.push("Use get_team_profile to research any team in depth", "Use get_groups to explore group compositions and match schedules", "Use get_venues to explore stadiums and host cities", "Use get_schedule to see the full tournament calendar");
1006
+ available_tools.push("Use get_team_profile to research any team in depth", "Use get_groups to explore group compositions and match schedules", "Use get_venues to explore stadiums and host cities", "Use get_schedule to see the full tournament calendar", "Use get_visa_info to check entry requirements for any team's fans", "Use get_fan_zones to find fan festival locations in any host city", "Use get_news for more World Cup headlines", "Use get_injuries to check key player availability", "Use get_odds for tournament predictions and group previews");
648
1007
  }
649
1008
  else if (phase === "group_stage") {
650
- available_tools.push("Use get_team_profile to research teams playing today", "Use get_matches to filter matches by team, group, or date", "Use get_groups to see full group standings and remaining fixtures");
1009
+ available_tools.push("Use get_team_profile to research teams playing today", "Use get_matches to filter matches by team, group, or date", "Use get_groups to see full group standings and remaining fixtures", "Use get_fan_zones to find fan festival locations near today's matches", "Use get_news for more World Cup headlines", "Use get_injuries to check key player availability", "Use get_odds for tournament predictions and group previews");
651
1010
  }
652
1011
  else if (phase === "knockout") {
653
- available_tools.push("Use get_team_profile to research teams still in the tournament", "Use get_matches with round filter to see knockout bracket", "Use get_schedule to see remaining match dates");
1012
+ available_tools.push("Use get_team_profile to research teams still in the tournament", "Use get_matches with round filter to see knockout bracket", "Use get_schedule to see remaining match dates", "Use get_fan_zones to find fan festival locations near today's matches", "Use get_news for more World Cup headlines", "Use get_injuries to check key player availability");
654
1013
  }
655
1014
  else {
656
1015
  available_tools.push("Use get_matches to review any match from the tournament", "Use get_team_profile to look up team details");
@@ -665,6 +1024,629 @@ server.registerTool("what_to_know_now", {
665
1024
  available_tools,
666
1025
  });
667
1026
  });
1027
+ // ── Tool: get_visa_info ──────────────────────────────────────────────
1028
+ server.registerTool("get_visa_info", {
1029
+ annotations: { readOnlyHint: true },
1030
+ title: "Get Visa Info",
1031
+ description: "Entry requirements for FIFA World Cup 2026 travelers. Returns visa/ESTA/eTA requirements for any team's nationals entering the three host countries (USA, Mexico, Canada). Accepts team ID, FIFA code, or country name. Optionally filter to a specific host country.",
1032
+ inputSchema: z.object({
1033
+ team: z
1034
+ .string()
1035
+ .describe("Team ID, FIFA code, or country name (e.g. 'bra', 'ARG', 'england'). Case-insensitive."),
1036
+ host_country: z
1037
+ .enum(["USA", "Mexico", "Canada"])
1038
+ .optional()
1039
+ .describe("Filter to a specific host country. Omit for all three."),
1040
+ }),
1041
+ }, async (args) => {
1042
+ const team = resolveTeam(args.team);
1043
+ if (!team) {
1044
+ return json({
1045
+ error: `Team '${args.team}' not found.`,
1046
+ suggestion: "Use the get_teams tool to see all available teams and their IDs.",
1047
+ });
1048
+ }
1049
+ const info = visaInfo.find((v) => v.team_id === team.id);
1050
+ if (!info) {
1051
+ return json({
1052
+ error: `No visa data available for ${team.name}.`,
1053
+ team: { id: team.id, name: team.name, flag_emoji: team.flag_emoji },
1054
+ });
1055
+ }
1056
+ const requirements = args.host_country
1057
+ ? info.entry_requirements.filter((r) => r.country === args.host_country)
1058
+ : info.entry_requirements;
1059
+ return json({
1060
+ team: {
1061
+ id: team.id,
1062
+ name: team.name,
1063
+ flag_emoji: team.flag_emoji,
1064
+ group: team.group,
1065
+ },
1066
+ nationality: info.nationality,
1067
+ passport_country: info.passport_country,
1068
+ entry_requirements: requirements,
1069
+ related_tools: [
1070
+ "Use get_city_guide for travel tips in each host city",
1071
+ "Use get_matches with team filter to see where this team plays",
1072
+ "Use get_venues to check which countries host their matches",
1073
+ ],
1074
+ });
1075
+ });
1076
+ // ── Tool: get_fan_zones ─────────────────────────────────────────────
1077
+ server.registerTool("get_fan_zones", {
1078
+ annotations: { readOnlyHint: true },
1079
+ title: "Get Fan Zones",
1080
+ description: "Official FIFA Fan Festival and fan zone locations for World Cup 2026 host cities. Returns venue details, capacity, hours, activities, transportation tips, and amenities. Filter by city, country, or match venue.",
1081
+ inputSchema: z.object({
1082
+ city: z
1083
+ .string()
1084
+ .optional()
1085
+ .describe("Filter by city name (e.g. 'Dallas', 'Mexico City', 'Toronto'). Case-insensitive."),
1086
+ country: z
1087
+ .enum(["USA", "Mexico", "Canada"])
1088
+ .optional()
1089
+ .describe("Filter by host country."),
1090
+ venue_id: z
1091
+ .string()
1092
+ .optional()
1093
+ .describe("Filter by match venue ID (e.g. 'metlife', 'azteca'). Returns fan zones associated with that stadium's city."),
1094
+ }),
1095
+ }, async (args) => {
1096
+ let results = fanZones;
1097
+ if (args.venue_id) {
1098
+ results = results.filter((fz) => fz.venue_id === args.venue_id);
1099
+ }
1100
+ if (args.country) {
1101
+ results = results.filter((fz) => fz.country === args.country);
1102
+ }
1103
+ if (args.city) {
1104
+ const city = args.city.toLowerCase();
1105
+ const cityMatches = results.filter((fz) => fz.city.toLowerCase().includes(city));
1106
+ if (cityMatches.length > 0) {
1107
+ // Also include sibling zones sharing the same venue_id (e.g. NYNJ metro has 3 zones)
1108
+ const venueIds = new Set(cityMatches.map((fz) => fz.venue_id));
1109
+ results = results.filter((fz) => venueIds.has(fz.venue_id));
1110
+ }
1111
+ else {
1112
+ // Try matching via venue name/city for alternate names
1113
+ const matchingVenue = venues.find((v) => v.city.toLowerCase().includes(city) || v.name.toLowerCase().includes(city));
1114
+ results = matchingVenue
1115
+ ? results.filter((fz) => fz.venue_id === matchingVenue.id)
1116
+ : [];
1117
+ }
1118
+ }
1119
+ if (results.length === 0) {
1120
+ return json({
1121
+ error: "No fan zones found matching your criteria.",
1122
+ suggestion: "Try get_fan_zones without filters to see all 18 fan zones, or use get_venues to find valid venue IDs.",
1123
+ available_countries: ["USA", "Mexico", "Canada"],
1124
+ });
1125
+ }
1126
+ return json({
1127
+ count: results.length,
1128
+ fan_zones: results.map((fz) => ({
1129
+ id: fz.id,
1130
+ name: fz.name,
1131
+ city: fz.city,
1132
+ country: fz.country,
1133
+ location: fz.location,
1134
+ address: fz.address,
1135
+ coordinates: fz.coordinates,
1136
+ capacity: fz.capacity,
1137
+ free_entry: fz.free_entry,
1138
+ hours: fz.hours,
1139
+ activities: fz.activities,
1140
+ highlights: fz.highlights,
1141
+ transportation: fz.transportation,
1142
+ amenities: fz.amenities,
1143
+ family_friendly: fz.family_friendly,
1144
+ status: fz.status,
1145
+ match_venue: venueName(fz.venue_id),
1146
+ })),
1147
+ related_tools: [
1148
+ "Use get_city_guide for full travel info on any host city",
1149
+ "Use get_venues to see stadium details",
1150
+ "Use get_visa_info for entry requirements",
1151
+ ],
1152
+ });
1153
+ });
1154
+ // ── Tool: get_news ──────────────────────────────────────────────────
1155
+ const newsCategories = [
1156
+ "roster", "venue", "schedule", "injury", "analysis",
1157
+ "transfer", "qualifying", "fan-content", "logistics", "general",
1158
+ ];
1159
+ server.registerTool("get_news", {
1160
+ annotations: { readOnlyHint: true },
1161
+ title: "Get News",
1162
+ description: "Latest FIFA World Cup 2026 news from ESPN, BBC Sport, and Reddit. Auto-updated daily by the WC26 Scout Agent. Filter by team, category, or both. Returns AI-generated summaries and source links.",
1163
+ inputSchema: z.object({
1164
+ team: z
1165
+ .string()
1166
+ .optional()
1167
+ .describe("Team ID or FIFA code (e.g. 'usa', 'BRA'). Filters news mentioning this team."),
1168
+ category: z
1169
+ .enum(newsCategories)
1170
+ .optional()
1171
+ .describe("News category to filter by."),
1172
+ limit: z
1173
+ .number()
1174
+ .optional()
1175
+ .describe("Max articles to return (default 10, max 50)."),
1176
+ }),
1177
+ }, async (args) => {
1178
+ if (news.length === 0) {
1179
+ return json({
1180
+ count: 0,
1181
+ articles: [],
1182
+ note: "No news articles available yet. The Scout Agent runs daily to fetch new articles.",
1183
+ related_tools: [
1184
+ "Use what_to_know_now for a tournament briefing",
1185
+ "Use get_teams to browse participating teams",
1186
+ ],
1187
+ });
1188
+ }
1189
+ let filtered = news;
1190
+ if (args.team) {
1191
+ const team = resolveTeam(args.team);
1192
+ if (!team) {
1193
+ return json({
1194
+ error: `Team '${args.team}' not found.`,
1195
+ suggestion: "Use the get_teams tool to see all available teams and their IDs.",
1196
+ });
1197
+ }
1198
+ filtered = filtered.filter((n) => n.related_teams.includes(team.id));
1199
+ }
1200
+ if (args.category) {
1201
+ filtered = filtered.filter((n) => n.categories.includes(args.category));
1202
+ }
1203
+ const limit = Math.min(Math.max(args.limit ?? 10, 1), 50);
1204
+ const result = filtered.slice(0, limit);
1205
+ return json({
1206
+ count: result.length,
1207
+ total_available: filtered.length,
1208
+ articles: result.map((n) => ({
1209
+ id: n.id,
1210
+ title: n.title,
1211
+ date: n.date,
1212
+ source: n.source,
1213
+ url: n.url,
1214
+ summary: n.summary,
1215
+ categories: n.categories,
1216
+ related_teams: n.related_teams,
1217
+ })),
1218
+ related_tools: [
1219
+ "Use get_team_profile to learn more about a mentioned team",
1220
+ "Use get_matches to check a team's schedule",
1221
+ "Use what_to_know_now for a full tournament briefing",
1222
+ ],
1223
+ });
1224
+ });
1225
+ // ── Tool: get_injuries ────────────────────────────────────────────────
1226
+ const injuryStatuses = ["out", "doubtful", "recovering", "fit"];
1227
+ server.registerTool("get_injuries", {
1228
+ annotations: { readOnlyHint: true },
1229
+ title: "Get Injury Report",
1230
+ description: "Key player injury tracker for the FIFA World Cup 2026. Track availability of star players across all 48 teams. Filter by team or injury status (out, doubtful, recovering, fit).",
1231
+ inputSchema: z.object({
1232
+ team: z
1233
+ .string()
1234
+ .optional()
1235
+ .describe("Team ID or FIFA code (e.g. 'usa', 'BRA'). Filters to injuries for this team."),
1236
+ status: z
1237
+ .enum(injuryStatuses)
1238
+ .optional()
1239
+ .describe("Filter by injury status: out, doubtful, recovering, or fit."),
1240
+ }),
1241
+ }, async (args) => {
1242
+ let filtered = injuries;
1243
+ if (args.team) {
1244
+ const team = resolveTeam(args.team);
1245
+ if (!team) {
1246
+ return json({
1247
+ error: `Team '${args.team}' not found.`,
1248
+ suggestion: "Use the get_teams tool to see all available teams and their IDs.",
1249
+ });
1250
+ }
1251
+ filtered = filtered.filter((i) => i.team_id === team.id);
1252
+ }
1253
+ if (args.status) {
1254
+ filtered = filtered.filter((i) => i.status === args.status);
1255
+ }
1256
+ return json({
1257
+ count: filtered.length,
1258
+ injuries: filtered.map((i) => {
1259
+ const team = teams.find((t) => t.id === i.team_id);
1260
+ return {
1261
+ player: i.player,
1262
+ team: team ? `${team.flag_emoji} ${team.name}` : i.team_id,
1263
+ team_id: i.team_id,
1264
+ position: i.position,
1265
+ injury: i.injury,
1266
+ status: i.status,
1267
+ expected_return: i.expected_return,
1268
+ last_updated: i.last_updated,
1269
+ source: i.source,
1270
+ };
1271
+ }),
1272
+ related_tools: [
1273
+ "Use get_team_profile to see a team's full squad and key players",
1274
+ "Use get_news for the latest injury headlines",
1275
+ "Use get_odds to see how injuries affect tournament predictions",
1276
+ ],
1277
+ });
1278
+ });
1279
+ // ── Tool: get_odds ───────────────────────────────────────────────────
1280
+ server.registerTool("get_odds", {
1281
+ annotations: { readOnlyHint: true },
1282
+ title: "Get Tournament Odds",
1283
+ description: "FIFA World Cup 2026 betting odds and predictions. Tournament winner odds, Golden Boot favorites, group-by-group predictions, and dark horse picks. Illustrative odds aggregated from major bookmakers.",
1284
+ inputSchema: z.object({
1285
+ category: z
1286
+ .enum(["winner", "golden_boot", "groups", "dark_horses", "all"])
1287
+ .optional()
1288
+ .describe("Which odds category to return. Defaults to 'all'."),
1289
+ group: z
1290
+ .string()
1291
+ .optional()
1292
+ .describe("Specific group (A-L) for group predictions."),
1293
+ }),
1294
+ }, async (args) => {
1295
+ const category = args.category ?? "all";
1296
+ const enrichTeam = (tid) => {
1297
+ const t = teams.find((t) => t.id === tid);
1298
+ return t ? `${t.flag_emoji} ${t.name}` : tid;
1299
+ };
1300
+ const result = {
1301
+ last_updated: tournamentOdds.last_updated,
1302
+ source: tournamentOdds.source,
1303
+ };
1304
+ if (category === "winner" || category === "all") {
1305
+ result.tournament_winner = tournamentOdds.tournament_winner.map((o) => ({
1306
+ team: enrichTeam(o.team_id),
1307
+ team_id: o.team_id,
1308
+ odds: o.odds,
1309
+ implied_probability: o.implied_probability,
1310
+ }));
1311
+ }
1312
+ if (category === "golden_boot" || category === "all") {
1313
+ result.golden_boot = tournamentOdds.golden_boot.map((o) => ({
1314
+ player: o.player,
1315
+ team: enrichTeam(o.team_id),
1316
+ odds: o.odds,
1317
+ }));
1318
+ }
1319
+ if (category === "groups" || category === "all") {
1320
+ let groupPreds = tournamentOdds.group_predictions;
1321
+ if (args.group) {
1322
+ groupPreds = groupPreds.filter((g) => g.group.toUpperCase() === args.group.toUpperCase());
1323
+ }
1324
+ result.group_predictions = groupPreds.map((g) => ({
1325
+ group: `Group ${g.group}`,
1326
+ favorites: g.favorites.map(enrichTeam),
1327
+ dark_horse: enrichTeam(g.dark_horse),
1328
+ narrative: g.narrative,
1329
+ }));
1330
+ }
1331
+ if (category === "dark_horses" || category === "all") {
1332
+ result.dark_horses = tournamentOdds.dark_horses.map((d) => ({
1333
+ team: enrichTeam(d.team_id),
1334
+ team_id: d.team_id,
1335
+ reason: d.reason,
1336
+ }));
1337
+ }
1338
+ result.related_tools = [
1339
+ "Use get_team_profile to research any team in depth",
1340
+ "Use get_injuries to check key player availability",
1341
+ "Use get_historical_matchups to see head-to-head records",
1342
+ ];
1343
+ return json(result);
1344
+ });
1345
+ // ── Tool: compare_teams ─────────────────────────────────────────────
1346
+ server.registerTool("compare_teams", {
1347
+ annotations: { readOnlyHint: true },
1348
+ title: "Compare Teams",
1349
+ description: "Side-by-side comparison of any two FIFA World Cup 2026 teams. Rankings, odds, key players, group difficulty, injury concerns, and head-to-head history — all in one view. Accepts team ID, FIFA code, or country name.",
1350
+ inputSchema: z.object({
1351
+ team_a: z
1352
+ .string()
1353
+ .describe("First team — ID, FIFA code, or name (e.g. 'bra', 'ARG', 'england'). Case-insensitive."),
1354
+ team_b: z
1355
+ .string()
1356
+ .describe("Second team — ID, FIFA code, or name (e.g. 'eng', 'FRA', 'germany'). Case-insensitive."),
1357
+ }),
1358
+ }, async (args) => {
1359
+ const teamA = resolveTeam(args.team_a);
1360
+ const teamB = resolveTeam(args.team_b);
1361
+ if (!teamA)
1362
+ return json({ error: `Team '${args.team_a}' not found.`, suggestion: "Use get_teams to see all available teams." });
1363
+ if (!teamB)
1364
+ return json({ error: `Team '${args.team_b}' not found.`, suggestion: "Use get_teams to see all available teams." });
1365
+ if (teamA.id === teamB.id)
1366
+ return json({ error: "Both inputs resolve to the same team." });
1367
+ function buildProfile(team) {
1368
+ const profile = teamProfiles.find((p) => p.team_id === team.id);
1369
+ const teamInjuries = injuries.filter((i) => i.team_id === team.id);
1370
+ const winnerOdds = tournamentOdds.tournament_winner.find((o) => o.team_id === team.id);
1371
+ const darkHorse = tournamentOdds.dark_horses.find((d) => d.team_id === team.id);
1372
+ const groupPred = tournamentOdds.group_predictions.find((g) => g.group === team.group);
1373
+ const groupTeams = teams.filter((t) => t.group === team.group && t.code !== "TBD");
1374
+ const avgGroupRanking = groupTeams.length > 0
1375
+ ? Math.round(groupTeams.reduce((s, t) => s + (t.fifa_ranking ?? 100), 0) / groupTeams.length)
1376
+ : null;
1377
+ return {
1378
+ team: `${team.flag_emoji} ${team.name}`,
1379
+ team_id: team.id,
1380
+ group: team.group,
1381
+ confederation: team.confederation,
1382
+ fifa_ranking: team.fifa_ranking ?? null,
1383
+ is_host: team.is_host,
1384
+ coach: profile?.coach ?? "Unknown",
1385
+ playing_style: profile?.playing_style ?? null,
1386
+ key_players: profile?.key_players.slice(0, 5).map((p) => `${p.name} (${p.position}, ${p.club})`) ?? [],
1387
+ world_cup_history: profile?.world_cup_history ?? null,
1388
+ winner_odds: winnerOdds ? { odds: winnerOdds.odds, implied_probability: winnerOdds.implied_probability } : null,
1389
+ dark_horse_pick: darkHorse?.reason ?? null,
1390
+ group_difficulty: {
1391
+ avg_ranking: avgGroupRanking,
1392
+ group_prediction: groupPred ? {
1393
+ favorites: groupPred.favorites.map((f) => teamName(f)),
1394
+ dark_horse: teamName(groupPred.dark_horse),
1395
+ } : null,
1396
+ },
1397
+ injuries: teamInjuries.map((i) => ({
1398
+ player: i.player,
1399
+ status: i.status,
1400
+ injury: i.injury,
1401
+ expected_return: i.expected_return,
1402
+ })),
1403
+ };
1404
+ }
1405
+ // Head-to-head
1406
+ const [first, second] = [teamA, teamB].sort((a, b) => a.id.localeCompare(b.id));
1407
+ const h2h = historicalMatchups.find((h) => h.team_a === first.id && h.team_b === second.id);
1408
+ const upcoming = matches.find((m) => (m.home_team_id === teamA.id && m.away_team_id === teamB.id) ||
1409
+ (m.home_team_id === teamB.id && m.away_team_id === teamA.id));
1410
+ return json({
1411
+ comparison: {
1412
+ [teamA.id]: buildProfile(teamA),
1413
+ [teamB.id]: buildProfile(teamB),
1414
+ },
1415
+ head_to_head: h2h ? {
1416
+ total_matches: h2h.total_matches,
1417
+ [`${first.name}_wins`]: h2h.team_a_wins,
1418
+ draws: h2h.draws,
1419
+ [`${second.name}_wins`]: h2h.team_b_wins,
1420
+ summary: h2h.summary,
1421
+ } : { total_matches: 0, summary: `${first.name} and ${second.name} have never met at a World Cup.` },
1422
+ upcoming_2026_match: upcoming ? {
1423
+ date: upcoming.date,
1424
+ time_utc: upcoming.time_utc,
1425
+ venue: venueName(upcoming.venue_id),
1426
+ round: upcoming.round,
1427
+ } : null,
1428
+ related_tools: [
1429
+ "Use get_team_profile for a deeper dive on either team",
1430
+ "Use get_historical_matchups for full match-by-match history",
1431
+ "Use get_injuries for complete injury details",
1432
+ "Use get_odds for full tournament predictions",
1433
+ ],
1434
+ });
1435
+ });
1436
+ // ── Tool: get_standings ─────────────────────────────────────────────
1437
+ server.registerTool("get_standings", {
1438
+ annotations: { readOnlyHint: true },
1439
+ title: "Get Group Standings",
1440
+ description: "Group power rankings for the FIFA World Cup 2026. Before the tournament starts, returns pre-tournament strength ratings for each group based on FIFA rankings, betting odds, and predictions. Filter by group (A-L) or get all 12 groups at once.",
1441
+ inputSchema: z.object({
1442
+ group: z
1443
+ .string()
1444
+ .optional()
1445
+ .describe("Specific group letter (A-L). If omitted, returns all 12 groups."),
1446
+ }),
1447
+ }, async (args) => {
1448
+ let targetGroups = groups;
1449
+ if (args.group) {
1450
+ const gid = args.group.toUpperCase();
1451
+ targetGroups = groups.filter((g) => g.id === gid);
1452
+ if (targetGroups.length === 0) {
1453
+ return json({
1454
+ error: `Group '${args.group}' not found. Valid groups are A through L.`,
1455
+ });
1456
+ }
1457
+ }
1458
+ const result = targetGroups.map((g) => {
1459
+ const groupTeams = g.teams
1460
+ .map((tid) => teams.find((t) => t.id === tid))
1461
+ .filter(Boolean);
1462
+ const ranked = groupTeams.map((t) => {
1463
+ const winnerOdds = tournamentOdds.tournament_winner.find((o) => o.team_id === t.id);
1464
+ const darkHorse = tournamentOdds.dark_horses.find((d) => d.team_id === t.id);
1465
+ const profile = teamProfiles.find((p) => p.team_id === t.id);
1466
+ const teamInjuries = injuries.filter((i) => i.team_id === t.id && i.status !== "fit");
1467
+ // Power score: lower is better. Ranking contributes directly; odds boost reduces it.
1468
+ const ranking = t.fifa_ranking ?? 100;
1469
+ const oddsBoost = winnerOdds
1470
+ ? Math.max(0, 50 - parseFloat(winnerOdds.implied_probability))
1471
+ : 50;
1472
+ const powerScore = ranking + oddsBoost;
1473
+ return {
1474
+ team: `${t.flag_emoji} ${t.name}`,
1475
+ team_id: t.id,
1476
+ fifa_ranking: t.fifa_ranking ?? null,
1477
+ confederation: t.confederation,
1478
+ is_host: t.is_host,
1479
+ winner_odds: winnerOdds ? { odds: winnerOdds.odds, implied_probability: winnerOdds.implied_probability } : null,
1480
+ dark_horse_pick: darkHorse?.reason ?? null,
1481
+ world_cup_pedigree: profile?.world_cup_history
1482
+ ? `${profile.world_cup_history.appearances} appearances, ${profile.world_cup_history.titles} titles, best: ${profile.world_cup_history.best_result}`
1483
+ : null,
1484
+ key_injuries: teamInjuries.length > 0
1485
+ ? teamInjuries.map((i) => `${i.player} (${i.status})`)
1486
+ : null,
1487
+ _power_score: powerScore,
1488
+ };
1489
+ }).sort((a, b) => a._power_score - b._power_score);
1490
+ const groupPred = tournamentOdds.group_predictions.find((gp) => gp.group === g.id);
1491
+ const confirmedTeams = ranked.filter((t) => !t.team_id.startsWith("tbd"));
1492
+ const avgRanking = confirmedTeams.length > 0
1493
+ ? Math.round(confirmedTeams.reduce((s, t) => s + (t.fifa_ranking ?? 100), 0) / confirmedTeams.length)
1494
+ : null;
1495
+ // Collect head-to-head matchups within the group
1496
+ const groupH2H = [];
1497
+ for (let i = 0; i < groupTeams.length; i++) {
1498
+ for (let j = i + 1; j < groupTeams.length; j++) {
1499
+ const [a, b] = [groupTeams[i].id, groupTeams[j].id].sort();
1500
+ const h2h = historicalMatchups.find((h) => h.team_a === a && h.team_b === b);
1501
+ if (h2h) {
1502
+ groupH2H.push({
1503
+ matchup: `${teamName(a)} vs ${teamName(b)}`,
1504
+ summary: h2h.summary,
1505
+ });
1506
+ }
1507
+ }
1508
+ }
1509
+ return {
1510
+ group: `Group ${g.id}`,
1511
+ difficulty_rating: avgRanking,
1512
+ prediction: groupPred ? {
1513
+ favorites: groupPred.favorites.map((f) => teamName(f)),
1514
+ dark_horse: teamName(groupPred.dark_horse),
1515
+ narrative: groupPred.narrative,
1516
+ } : null,
1517
+ teams: ranked.map(({ _power_score, ...rest }) => rest),
1518
+ head_to_head_history: groupH2H.length > 0 ? groupH2H : null,
1519
+ };
1520
+ });
1521
+ return json({
1522
+ count: result.length,
1523
+ note: "Teams ranked by pre-tournament power rating (FIFA ranking + betting odds). Lower difficulty_rating = harder group.",
1524
+ groups: result,
1525
+ related_tools: [
1526
+ "Use get_team_profile for a deep dive on any team",
1527
+ "Use get_odds for full tournament predictions",
1528
+ "Use get_historical_matchups for complete head-to-head records",
1529
+ "Use get_bracket to see the knockout path from each group",
1530
+ ],
1531
+ });
1532
+ });
1533
+ // ── Tool: get_bracket ──────────────────────────────────────────────
1534
+ server.registerTool("get_bracket", {
1535
+ annotations: { readOnlyHint: true },
1536
+ title: "Get Knockout Bracket",
1537
+ description: "Visualize the FIFA World Cup 2026 knockout bracket. Shows the full path from Round of 32 through the Final, including which group positions feed into each match, venues, and dates. Optionally filter by a specific round.",
1538
+ inputSchema: z.object({
1539
+ round: z
1540
+ .enum(["Round of 32", "Round of 16", "Quarter-final", "Semi-final", "Third-place play-off", "Final", "all"])
1541
+ .optional()
1542
+ .describe("Specific knockout round to show. Defaults to 'all'."),
1543
+ }),
1544
+ }, async (args) => {
1545
+ const knockoutRounds = [
1546
+ "Round of 32", "Round of 16", "Quarter-final", "Semi-final",
1547
+ "Third-place play-off", "Final",
1548
+ ];
1549
+ const targetRound = args.round ?? "all";
1550
+ const knockoutMatches = matches.filter((m) => knockoutRounds.includes(m.round));
1551
+ function describePlaceholder(p) {
1552
+ if (!p)
1553
+ return "TBD";
1554
+ // Winner/Loser of match
1555
+ if (p.startsWith("W"))
1556
+ return `Winner of Match ${p.slice(1)}`;
1557
+ if (p.startsWith("L"))
1558
+ return `Loser of Match ${p.slice(1)}`;
1559
+ // Group position like "1A", "2B"
1560
+ if (/^[12][A-L]$/.test(p)) {
1561
+ const pos = p[0] === "1" ? "Winner" : "Runner-up";
1562
+ return `${pos} of Group ${p[1]}`;
1563
+ }
1564
+ // Best third-place like "3C/D/E"
1565
+ if (p.startsWith("3")) {
1566
+ const groups = p.slice(1);
1567
+ return `Best 3rd from Groups ${groups}`;
1568
+ }
1569
+ return p;
1570
+ }
1571
+ const enrichedKnockout = knockoutMatches.map((m) => {
1572
+ const venue = venues.find((v) => v.id === m.venue_id);
1573
+ return {
1574
+ match_number: m.match_number,
1575
+ match_id: m.id,
1576
+ round: m.round,
1577
+ date: m.date,
1578
+ time_utc: m.time_utc,
1579
+ venue: venue ? `${venue.name}, ${venue.city}` : m.venue_id,
1580
+ home: m.home_team_id ? teamName(m.home_team_id) : describePlaceholder(m.home_placeholder),
1581
+ away: m.away_team_id ? teamName(m.away_team_id) : describePlaceholder(m.away_placeholder),
1582
+ feeds_into: (() => {
1583
+ const nextAsHome = knockoutMatches.find((n) => n.home_placeholder === `W${m.match_number}`);
1584
+ const nextAsAway = knockoutMatches.find((n) => n.away_placeholder === `W${m.match_number}`);
1585
+ const next = nextAsHome || nextAsAway;
1586
+ return next ? `Winner → Match ${next.match_number} (${next.round})` : null;
1587
+ })(),
1588
+ };
1589
+ });
1590
+ // Group by round
1591
+ const byRound = {};
1592
+ for (const m of enrichedKnockout) {
1593
+ if (!byRound[m.round])
1594
+ byRound[m.round] = [];
1595
+ byRound[m.round].push(m);
1596
+ }
1597
+ // Filter if specific round requested
1598
+ let result;
1599
+ if (targetRound !== "all") {
1600
+ const roundKey = targetRound === "Final" ? "Final" : targetRound;
1601
+ const roundMatches = byRound[roundKey];
1602
+ if (!roundMatches) {
1603
+ return json({ error: `No matches found for '${targetRound}'.` });
1604
+ }
1605
+ result = {
1606
+ round: roundKey,
1607
+ matches: roundMatches,
1608
+ };
1609
+ }
1610
+ else {
1611
+ // Build bracket paths: show which group feeds where
1612
+ const bracketPaths = groups.map((g) => {
1613
+ const winnerId = `1${g.id}`;
1614
+ const runnerUpId = `2${g.id}`;
1615
+ const winnerMatch = knockoutMatches.find((m) => m.home_placeholder === winnerId || m.away_placeholder === winnerId);
1616
+ const runnerUpMatch = knockoutMatches.find((m) => m.home_placeholder === runnerUpId || m.away_placeholder === runnerUpId);
1617
+ return {
1618
+ group: `Group ${g.id}`,
1619
+ teams: g.teams.map((tid) => teamName(tid)),
1620
+ winner_enters: winnerMatch
1621
+ ? `Match ${winnerMatch.match_number} (${winnerMatch.round})`
1622
+ : "TBD",
1623
+ runner_up_enters: runnerUpMatch
1624
+ ? `Match ${runnerUpMatch.match_number} (${runnerUpMatch.round})`
1625
+ : "TBD",
1626
+ };
1627
+ });
1628
+ result = {
1629
+ bracket_overview: bracketPaths,
1630
+ rounds: Object.fromEntries(knockoutRounds.map((r) => [r, byRound[r] ?? []])),
1631
+ };
1632
+ }
1633
+ return json({
1634
+ ...result,
1635
+ key_dates: {
1636
+ round_of_32: "June 29 - July 3",
1637
+ round_of_16: "July 4 - 7",
1638
+ quarter_finals: "July 9 - 11",
1639
+ semi_finals: "July 14 - 15",
1640
+ third_place: "July 18",
1641
+ final: "July 19 (MetLife Stadium, East Rutherford, NJ)",
1642
+ },
1643
+ related_tools: [
1644
+ "Use get_standings to see group power rankings",
1645
+ "Use get_matches to see the full schedule with filters",
1646
+ "Use get_venues for stadium details",
1647
+ ],
1648
+ });
1649
+ });
668
1650
  // ── Start server ────────────────────────────────────────────────────
669
1651
  async function main() {
670
1652
  const transport = new StdioServerTransport();
@@ -674,4 +1656,3 @@ main().catch((err) => {
674
1656
  console.error("Fatal error:", err);
675
1657
  process.exit(1);
676
1658
  });
677
- //# sourceMappingURL=index.js.map