#!/usr/bin/env node
/**
* NHL MCP Server
*
* Provides tools for querying NHL live data and statistics
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { NHLAPIClient, GameScore, TeamStandings, PlayerStats, GoalieStats } from './nhl-api.js';
const client = new NHLAPIClient();
// Define available tools
const TOOLS: Tool[] = [
{
name: 'get_live_games',
description: 'Get live NHL game scores and status for today or a specific date. Shows current scores, period, game state, and venue information.',
inputSchema: {
type: 'object',
properties: {
date: {
type: 'string',
description: 'Date in YYYY-MM-DD format (optional, defaults to today)',
},
},
},
},
{
name: 'get_game_details',
description: 'Get detailed information about a specific game including play-by-play data, scoring plays, and period summaries.',
inputSchema: {
type: 'object',
properties: {
gameId: {
type: 'number',
description: 'The NHL game ID',
},
},
required: ['gameId'],
},
},
{
name: 'get_standings',
description: 'Get current NHL standings including wins, losses, points, goals for/against, and goal differential. Can filter by division or conference.',
inputSchema: {
type: 'object',
properties: {
date: {
type: 'string',
description: 'Date in YYYY-MM-DD format (optional, defaults to current standings)',
},
division: {
type: 'string',
description: 'Filter by division (Atlantic, Metropolitan, Central, Pacific)',
},
conference: {
type: 'string',
description: 'Filter by conference (Eastern, Western)',
},
},
},
},
{
name: 'get_team_stats',
description: 'Get detailed statistics for a specific NHL team including roster, season performance, and player stats.',
inputSchema: {
type: 'object',
properties: {
teamAbbrev: {
type: 'string',
description: 'Team abbreviation (e.g., TOR, NYR, BOS, MTL)',
},
season: {
type: 'string',
description: 'Season in format YYYYYYYY (e.g., 20242025), defaults to current season',
},
},
required: ['teamAbbrev'],
},
},
{
name: 'get_player_stats',
description: 'Get statistics for top NHL players including goals, assists, points, plus/minus, and other performance metrics.',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
description: 'Category to sort by: points, goals, assists, plusMinus, shots, shootingPctg (defaults to points)',
},
limit: {
type: 'number',
description: 'Number of players to return (defaults to 20)',
},
season: {
type: 'string',
description: 'Season in format YYYYYYYY (e.g., 20242025), defaults to current season',
},
},
},
},
{
name: 'get_goalie_stats',
description: 'Get statistics for NHL goalies including save percentage, GAA, wins, shutouts, and other goalie-specific metrics.',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Number of goalies to return (defaults to 20)',
},
season: {
type: 'string',
description: 'Season in format YYYYYYYY (e.g., 20242025), defaults to current season',
},
},
},
},
{
name: 'get_schedule',
description: 'Get NHL schedule for upcoming games. Can get schedule for a specific date or team.',
inputSchema: {
type: 'object',
properties: {
date: {
type: 'string',
description: 'Date in YYYY-MM-DD format (optional, defaults to current week)',
},
teamAbbrev: {
type: 'string',
description: 'Team abbreviation to get specific team schedule (e.g., TOR, NYR)',
},
},
},
},
{
name: 'get_playoff_bracket',
description: 'Get current playoff bracket with series information, matchups, and results.',
inputSchema: {
type: 'object',
properties: {
season: {
type: 'string',
description: 'Season year (e.g., 2024), defaults to current season',
},
},
},
},
{
name: 'compare_teams',
description: 'Compare head-to-head statistics between two NHL teams including recent matchups and historical records.',
inputSchema: {
type: 'object',
properties: {
team1: {
type: 'string',
description: 'First team abbreviation (e.g., TOR)',
},
team2: {
type: 'string',
description: 'Second team abbreviation (e.g., MTL)',
},
season: {
type: 'string',
description: 'Season in format YYYYYYYY (optional, defaults to current)',
},
},
required: ['team1', 'team2'],
},
},
{
name: 'get_team_streak',
description: 'Get current winning or losing streak for an NHL team based on recent game results.',
inputSchema: {
type: 'object',
properties: {
teamAbbrev: {
type: 'string',
description: 'Team abbreviation (e.g., TOR, NYR)',
},
},
required: ['teamAbbrev'],
},
},
{
name: 'compare_seasons',
description: 'Compare team or player statistics across multiple NHL seasons.',
inputSchema: {
type: 'object',
properties: {
teamAbbrev: {
type: 'string',
description: 'Team abbreviation to compare (optional)',
},
seasons: {
type: 'array',
items: {
type: 'string',
},
description: 'Array of seasons to compare in format YYYYYYYY (e.g., ["20232024", "20242025"])',
},
},
required: ['seasons'],
},
},
];
// Helper functions
function formatGameScore(game: GameScore): string {
const status =
game.gameState === 'LIVE' || game.gameState === 'CRIT'
? `LIVE - Period ${game.period}`
: game.gameState === 'FUT'
? 'Scheduled'
: game.gameState === 'FINAL' || game.gameState === 'OFF'
? 'Final'
: game.gameState;
return `${game.awayTeam.abbrev} ${game.awayTeam.score} @ ${game.homeTeam.abbrev} ${game.homeTeam.score} - ${status}\nVenue: ${game.venue}\nDate: ${game.gameDate}\nGame ID: ${game.id}`;
}
function formatStandings(standings: TeamStandings[]): string {
let result = 'Team | GP | W | L | OT | PTS | GF | GA | DIFF | Div\n';
result += '-'.repeat(70) + '\n';
standings.forEach((team) => {
const teamName = team.teamAbbrev.default.padEnd(4);
const gp = team.gamesPlayed.toString().padStart(3);
const w = team.wins.toString().padStart(2);
const l = team.losses.toString().padStart(2);
const ot = team.otLosses.toString().padStart(2);
const pts = team.points.toString().padStart(3);
const gf = team.goalFor.toString().padStart(3);
const ga = team.goalAgainst.toString().padStart(3);
const diff = team.goalDifferential.toString().padStart(4);
const div = team.divisionAbbrev;
result += `${teamName} | ${gp} | ${w} | ${l} | ${ot} | ${pts} | ${gf} | ${ga} | ${diff} | ${div}\n`;
});
return result;
}
function formatPlayerStats(players: PlayerStats[], category: string): string {
let result = `Rank | Player | Team | Pos | ${category.toUpperCase()}\n`;
result += '-'.repeat(60) + '\n';
players.forEach((player, index) => {
const name = `${player.firstName.default} ${player.lastName.default}`;
const displayName = name.substring(0, 25).padEnd(25);
const team = player.teamAbbrev.padEnd(4);
const pos = player.position.padEnd(2);
const value = player.value.toString().padStart(4);
const rank = (index + 1).toString().padStart(3);
result += `${rank} | ${displayName} | ${team} | ${pos} | ${value}\n`;
});
return result;
}
function formatGoalieStats(goalies: GoalieStats[], category: string): string {
let result = `Rank | Goalie | Team | ${category.toUpperCase()}\n`;
result += '-'.repeat(60) + '\n';
goalies.forEach((goalie, index) => {
const name = `${goalie.firstName.default} ${goalie.lastName.default}`;
const displayName = name.substring(0, 25).padEnd(25);
const team = goalie.teamAbbrev.padEnd(4);
const value = category === 'savePctg'
? goalie.value.toFixed(3)
: goalie.value.toString();
const rank = (index + 1).toString().padStart(3);
result += `${rank} | ${displayName} | ${team} | ${value}\n`;
});
return result;
}
async function analyzeStreak(teamAbbrev: string): Promise<string> {
try {
const schedule = await client.getTeamSchedule(teamAbbrev);
if (!schedule.games || schedule.games.length === 0) {
return `No games found for ${teamAbbrev}`;
}
// Get completed games sorted by date
const completedGames = schedule.games
.filter((g: any) => g.gameState === 'OFF' || g.gameState === 'FINAL')
.sort((a: any, b: any) => new Date(b.gameDate).getTime() - new Date(a.gameDate).getTime());
if (completedGames.length === 0) {
return `No completed games found for ${teamAbbrev} this season`;
}
let streakCount = 0;
let streakType = '';
const recentResults: string[] = [];
for (const game of completedGames) {
const isHome = game.homeTeam.abbrev === teamAbbrev;
const teamScore = isHome ? game.homeTeam.score : game.awayTeam.score;
const oppScore = isHome ? game.awayTeam.score : game.homeTeam.score;
const oppTeam = isHome ? game.awayTeam.abbrev : game.homeTeam.abbrev;
const won = teamScore > oppScore;
const result = won ? 'W' : 'L';
recentResults.push(`${result} ${teamScore}-${oppScore} vs ${oppTeam}`);
if (streakCount === 0) {
streakType = result;
streakCount = 1;
} else if (result === streakType) {
streakCount++;
} else {
break;
}
if (recentResults.length >= 10) break;
}
const streakText =
streakType === 'W'
? `${streakCount} game winning streak`
: `${streakCount} game losing streak`;
return `${teamAbbrev} Current Streak: ${streakText}\n\nLast 10 games:\n${recentResults.join('\n')}`;
} catch (error: any) {
return `Error analyzing streak: ${error.message}`;
}
}
async function compareTeamsHeadToHead(team1: string, team2: string, season?: string): Promise<string> {
try {
const schedule1 = await client.getTeamSchedule(team1, season);
if (!schedule1.games) {
return `No schedule data found for ${team1}`;
}
// Find games between these two teams
const matchups = schedule1.games.filter((game: any) => {
return (
(game.homeTeam.abbrev === team1 && game.awayTeam.abbrev === team2) ||
(game.homeTeam.abbrev === team2 && game.awayTeam.abbrev === team1)
);
});
if (matchups.length === 0) {
return `No matchups found between ${team1} and ${team2} this season`;
}
let team1Wins = 0;
let team2Wins = 0;
const results: string[] = [];
matchups.forEach((game: any) => {
const isTeam1Home = game.homeTeam.abbrev === team1;
const team1Score = isTeam1Home ? game.homeTeam.score : game.awayTeam.score;
const team2Score = isTeam1Home ? game.awayTeam.score : game.homeTeam.score;
if (game.gameState === 'OFF' || game.gameState === 'FINAL') {
if (team1Score > team2Score) {
team1Wins++;
results.push(`${game.gameDate}: ${team1} ${team1Score}, ${team2} ${team2Score} - ${team1} WIN`);
} else {
team2Wins++;
results.push(`${game.gameDate}: ${team1} ${team1Score}, ${team2} ${team2Score} - ${team2} WIN`);
}
} else if (game.gameState === 'FUT') {
results.push(`${game.gameDate}: Upcoming game`);
} else {
results.push(`${game.gameDate}: ${team1} ${team1Score}, ${team2} ${team2Score} - IN PROGRESS`);
}
});
return `Head-to-Head: ${team1} vs ${team2}\n\nSeason Series: ${team1} ${team1Wins}-${team2Wins} ${team2}\n\nGames:\n${results.join('\n')}`;
} catch (error: any) {
return `Error comparing teams: ${error.message}`;
}
}
async function compareSeasons(seasons: string[], teamAbbrev?: string): Promise<string> {
try {
if (!teamAbbrev) {
// Compare league-wide stats
const results: string[] = [];
for (const season of seasons) {
const standings = await client.getStandingsBySeason(season);
const teams = standings.standings || [];
const totalGoals = teams.reduce((sum, t) => sum + t.goalFor, 0);
const totalGames = teams.reduce((sum, t) => sum + t.gamesPlayed, 0);
results.push(
`Season ${client.formatSeason(season)}:\n` +
` Total teams: ${teams.length}\n` +
` Total goals: ${totalGoals}\n` +
` Avg goals/game: ${(totalGoals / totalGames).toFixed(2)}`
);
}
return `Season Comparison:\n\n${results.join('\n\n')}`;
} else {
// Compare specific team across seasons
const results: string[] = [];
for (const season of seasons) {
const standings = await client.getStandingsBySeason(season);
const team = (standings.standings || []).find(
(t) => t.teamAbbrev.default === teamAbbrev
);
if (team) {
results.push(
`${client.formatSeason(season)} - ${teamAbbrev}:\n` +
` Record: ${team.wins}-${team.losses}-${team.otLosses}\n` +
` Points: ${team.points}\n` +
` Goals For: ${team.goalFor}\n` +
` Goals Against: ${team.goalAgainst}\n` +
` Goal Diff: ${team.goalDifferential}`
);
} else {
results.push(`${client.formatSeason(season)} - ${teamAbbrev}: No data found`);
}
}
return `Season Comparison for ${teamAbbrev}:\n\n${results.join('\n\n')}`;
}
} catch (error: any) {
return `Error comparing seasons: ${error.message}`;
}
}
// Create MCP server
const server = new Server(
{
name: 'nhl-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Register tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// Ensure args exists, default to empty object
const parameters = args || {};
switch (name) {
case 'get_live_games': {
const games = await client.getTodaysScores(parameters.date as string | undefined);
if (games.length === 0) {
return {
content: [
{ type: 'text', text: 'No games scheduled for this date' },
],
};
}
const formatted = games.map(formatGameScore).join('\n\n');
return {
content: [{ type: 'text', text: formatted }],
};
}
case 'get_game_details': {
const details = await client.getGameDetails(parameters.gameId as number);
return {
content: [{ type: 'text', text: JSON.stringify(details, null, 2) }],
};
}
case 'get_standings': {
const standings = await client.getStandings(parameters.date as string | undefined);
let filtered = standings.standings || [];
if (parameters.division) {
filtered = filtered.filter(
(t) => t.divisionAbbrev.toLowerCase() === (parameters.division as string).toLowerCase()
);
}
if (parameters.conference) {
filtered = filtered.filter(
(t) =>
t.conferenceAbbrev.toLowerCase() === (parameters.conference as string).toLowerCase()
);
}
const formatted = formatStandings(filtered);
return {
content: [{ type: 'text', text: formatted }],
};
}
case 'get_team_stats': {
const stats = await client.getTeamStats(
parameters.teamAbbrev as string,
parameters.season as string | undefined
);
return {
content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }],
};
}
case 'get_player_stats': {
const category = (parameters.category as string) || 'points';
const players = await client.getTopSkaters(
category,
parameters.limit as number | undefined,
parameters.season as string | undefined
);
const formatted = formatPlayerStats(players, category);
return {
content: [{ type: 'text', text: formatted }],
};
}
case 'get_goalie_stats': {
const goalies = await client.getTopGoalies(
parameters.limit as number | undefined,
parameters.season as string | undefined
);
const formatted = formatGoalieStats(goalies, 'savePctg');
return {
content: [{ type: 'text', text: formatted }],
};
}
case 'get_schedule': {
if (parameters.teamAbbrev) {
const schedule = await client.getTeamSchedule(
parameters.teamAbbrev as string,
parameters.season as string | undefined
);
return {
content: [{ type: 'text', text: JSON.stringify(schedule, null, 2) }],
};
} else {
const schedule = await client.getSchedule(parameters.date as string | undefined);
return {
content: [{ type: 'text', text: JSON.stringify(schedule, null, 2) }],
};
}
}
case 'get_playoff_bracket': {
const bracket = await client.getPlayoffBracket(parameters.season as string | undefined);
return {
content: [{ type: 'text', text: JSON.stringify(bracket, null, 2) }],
};
}
case 'compare_teams': {
const comparison = await compareTeamsHeadToHead(
parameters.team1 as string,
parameters.team2 as string,
parameters.season as string | undefined
);
return {
content: [{ type: 'text', text: comparison }],
};
}
case 'get_team_streak': {
const streak = await analyzeStreak(parameters.teamAbbrev as string);
return {
content: [{ type: 'text', text: streak }],
};
}
case 'compare_seasons': {
const comparison = await compareSeasons(
parameters.seasons as string[],
parameters.teamAbbrev as string | undefined
);
return {
content: [{ type: 'text', text: comparison }],
};
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
};
}
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing ${name}: ${error.message}`,
},
],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('NHL MCP Server running on stdio');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});