@semanticintent/semantic-chirp-intelligence-mcp 3.0.0 → 4.0.0

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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * šŸ“Š NHL Stats Service — player identity and statistics, no account required
3
+ *
4
+ * The counterpart to NhlScheduleService. Together they replace every external
5
+ * data source this server used to need:
6
+ *
7
+ * • who exists, and which club they play for → club rosters
8
+ * • what they have actually produced → club season stats
9
+ *
10
+ * Both come from the NHL's public API: no key, no OAuth, no approval, no
11
+ * per-user account binding. That is the whole point of v4 — the intelligence
12
+ * layer works for anyone, on any fantasy platform, or none at all.
13
+ *
14
+ * šŸ›ļø Rule 1 (Semantic Over Structural): callers ask for a player by the name a
15
+ * human would type, not by a provider's internal id.
16
+ * šŸ›ļø Rule 3 (Observable Anchoring): a name that cannot be resolved is reported
17
+ * as unresolved. It is never silently matched to the nearest guess.
18
+ */
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import { NHL_TRICODES } from '../domain/nhl-teams.js';
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+ const NHL_API_BASE = 'https://api-web.nhle.com/v1';
26
+ const GAME_TYPE_REGULAR_SEASON = 2;
27
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
28
+ export class NhlStatsService {
29
+ byId = new Map();
30
+ byNameKey = new Map();
31
+ byLastName = new Map();
32
+ rosterSeason = null;
33
+ statsSeason = null;
34
+ loaded = false;
35
+ loadError = null;
36
+ inFlight = null;
37
+ cacheDir;
38
+ constructor(cacheDir) {
39
+ this.cacheDir = cacheDir ?? path.join(__dirname, '..', '..', '.nhl-schedule-cache');
40
+ }
41
+ // ==========================================
42
+ // šŸŽÆ Loading
43
+ // ==========================================
44
+ /**
45
+ * Load every club's roster and season statistics.
46
+ *
47
+ * @param rosterSeason which season's rosters define current club membership
48
+ * @param statsSeason which season's numbers to attach. Defaults to the
49
+ * previous season, because before opening night the
50
+ * current season has no statistics at all — and a draft
51
+ * is exactly when you need last season's line.
52
+ */
53
+ async load(rosterSeason, statsSeason) {
54
+ const roster = rosterSeason ?? NhlStatsService.currentSeason();
55
+ const stats = statsSeason ?? NhlStatsService.previousSeason(roster);
56
+ if (this.loaded && this.rosterSeason === roster && this.statsSeason === stats)
57
+ return;
58
+ if (this.inFlight)
59
+ return this.inFlight;
60
+ this.inFlight = this.performLoad(roster, stats).finally(() => {
61
+ this.inFlight = null;
62
+ });
63
+ return this.inFlight;
64
+ }
65
+ async performLoad(rosterSeason, statsSeason) {
66
+ this.rosterSeason = rosterSeason;
67
+ this.statsSeason = statsSeason;
68
+ this.loadError = null;
69
+ const cached = this.readCache(rosterSeason, statsSeason);
70
+ if (cached) {
71
+ this.index(cached.players);
72
+ this.loaded = true;
73
+ return;
74
+ }
75
+ const results = await Promise.all(NHL_TRICODES.map(team => this.fetchClub(team, rosterSeason, statsSeason)));
76
+ const failed = NHL_TRICODES.filter((_, i) => results[i] === null);
77
+ // A partial league makes "best available player" quietly wrong, so a
78
+ // partial load is treated as no load at all.
79
+ if (failed.length > 0) {
80
+ this.loaded = false;
81
+ this.loadError = `NHL player data unavailable for ${failed.length} club(s): ${failed.join(', ')}`;
82
+ return;
83
+ }
84
+ const players = results.flat();
85
+ this.index(players);
86
+ this.loaded = true;
87
+ this.writeCache(rosterSeason, statsSeason, players);
88
+ }
89
+ async fetchClub(team, rosterSeason, statsSeason) {
90
+ try {
91
+ const [rosterRes, statsRes] = await Promise.all([
92
+ fetch(`${NHL_API_BASE}/roster/${team}/${rosterSeason}`),
93
+ fetch(`${NHL_API_BASE}/club-stats/${team}/${statsSeason}/${GAME_TYPE_REGULAR_SEASON}`)
94
+ ]);
95
+ if (!rosterRes.ok)
96
+ return null;
97
+ const rosterData = await rosterRes.json();
98
+ // Stats are best-effort: a club with no published line still has players.
99
+ const statsData = statsRes.ok ? await statsRes.json() : { skaters: [], goalies: [] };
100
+ const statsById = new Map();
101
+ for (const s of statsData.skaters ?? []) {
102
+ statsById.set(String(s.playerId), this.skaterStats(s));
103
+ }
104
+ for (const g of statsData.goalies ?? []) {
105
+ statsById.set(String(g.playerId), this.goalieStats(g));
106
+ }
107
+ const players = [];
108
+ for (const group of ['forwards', 'defensemen', 'goalies']) {
109
+ for (const p of rosterData[group] ?? []) {
110
+ const id = String(p.id);
111
+ players.push({
112
+ player_id: id,
113
+ name: `${p.firstName?.default ?? ''} ${p.lastName?.default ?? ''}`.trim(),
114
+ team,
115
+ position: p.positionCode ?? (group === 'goalies' ? 'G' : '?'),
116
+ stats: statsById.get(id)
117
+ });
118
+ }
119
+ }
120
+ return players;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ skaterStats(s) {
127
+ return {
128
+ games_played: Number(s.gamesPlayed ?? 0),
129
+ goals: Number(s.goals ?? 0),
130
+ assists: Number(s.assists ?? 0),
131
+ points: Number(s.points ?? 0),
132
+ plus_minus: Number(s.plusMinus ?? 0),
133
+ penalty_minutes: Number(s.penaltyMinutes ?? 0),
134
+ shots: Number(s.shots ?? 0),
135
+ power_play_goals: Number(s.powerPlayGoals ?? 0),
136
+ short_handed_goals: Number(s.shorthandedGoals ?? 0),
137
+ game_winning_goals: Number(s.gameWinningGoals ?? 0),
138
+ time_on_ice_per_game: Number(s.avgTimeOnIcePerGame ?? 0)
139
+ };
140
+ }
141
+ goalieStats(g) {
142
+ return {
143
+ games_played: Number(g.gamesPlayed ?? 0),
144
+ wins: Number(g.wins ?? 0),
145
+ losses: Number(g.losses ?? 0),
146
+ goals_against_average: Number(g.goalsAgainstAverage ?? 0),
147
+ // The NHL feed spells this savePercentage; it can be null for low samples.
148
+ save_percentage: g.savePercentage === null || g.savePercentage === undefined
149
+ ? undefined
150
+ : Number(g.savePercentage),
151
+ shutouts: Number(g.shutouts ?? 0),
152
+ saves: Number(g.saves ?? 0),
153
+ goals_against: Number(g.goalsAgainst ?? 0)
154
+ };
155
+ }
156
+ // ==========================================
157
+ // šŸŽÆ Indexing and resolution
158
+ // ==========================================
159
+ index(players) {
160
+ this.byId = new Map();
161
+ this.byNameKey = new Map();
162
+ this.byLastName = new Map();
163
+ for (const p of players) {
164
+ this.byId.set(p.player_id, p);
165
+ const key = NhlStatsService.nameKey(p.name);
166
+ const bucket = this.byNameKey.get(key) ?? [];
167
+ bucket.push(p);
168
+ this.byNameKey.set(key, bucket);
169
+ const last = NhlStatsService.nameKey(p.name.split(/\s+/).slice(-1)[0]);
170
+ const lastBucket = this.byLastName.get(last) ?? [];
171
+ lastBucket.push(p);
172
+ this.byLastName.set(last, lastBucket);
173
+ }
174
+ }
175
+ /**
176
+ * Normalize a name for matching: lower-cased, accents folded, punctuation and
177
+ * spacing removed. Handles "Stützle" vs "Stutzle" and "J.T. Miller" vs
178
+ * "JT Miller", both of which people type either way.
179
+ */
180
+ static nameKey(name) {
181
+ return String(name)
182
+ .normalize('NFD')
183
+ .replace(/\p{Diacritic}/gu, '')
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9]/g, '');
186
+ }
187
+ /**
188
+ * Resolve a human-typed name to a player.
189
+ *
190
+ * Accepts "Auston Matthews", "auston matthews", "MATTHEWS, Auston" and
191
+ * "Matthews". A bare surname resolves only when exactly one player carries
192
+ * it; otherwise the candidates are returned so the caller can ask rather
193
+ * than guess.
194
+ */
195
+ resolve(input) {
196
+ const raw = String(input ?? '').trim();
197
+ if (!raw)
198
+ return { input: raw, player: null, reason: 'empty' };
199
+ // "Lastname, Firstname" -> "Firstname Lastname"
200
+ const normalized = raw.includes(',')
201
+ ? raw.split(',').map(s => s.trim()).reverse().join(' ')
202
+ : raw;
203
+ const exact = this.byNameKey.get(NhlStatsService.nameKey(normalized));
204
+ if (exact?.length === 1)
205
+ return { input: raw, player: exact[0] };
206
+ if (exact && exact.length > 1) {
207
+ return { input: raw, player: null, ambiguous: exact, reason: 'multiple players share this name' };
208
+ }
209
+ // Surname only
210
+ const surnameBucket = this.byLastName.get(NhlStatsService.nameKey(normalized));
211
+ if (surnameBucket?.length === 1)
212
+ return { input: raw, player: surnameBucket[0] };
213
+ if (surnameBucket && surnameBucket.length > 1) {
214
+ return { input: raw, player: null, ambiguous: surnameBucket, reason: 'surname matches several players' };
215
+ }
216
+ return { input: raw, player: null, reason: 'no NHL player found with that name' };
217
+ }
218
+ // ==========================================
219
+ // šŸŽÆ Queries
220
+ // ==========================================
221
+ isAvailable() {
222
+ return this.loaded && this.byId.size > 0;
223
+ }
224
+ getUnavailableReason() {
225
+ if (this.isAvailable())
226
+ return null;
227
+ return this.loadError ?? 'NHL player data has not been loaded yet';
228
+ }
229
+ getById(playerId) {
230
+ return this.byId.get(String(playerId)) ?? null;
231
+ }
232
+ getAll() {
233
+ return [...this.byId.values()];
234
+ }
235
+ getByTeam(team) {
236
+ return this.getAll().filter(p => p.team === team);
237
+ }
238
+ getPlayerCount() {
239
+ return this.byId.size;
240
+ }
241
+ getSeasons() {
242
+ return { roster: this.rosterSeason, stats: this.statsSeason };
243
+ }
244
+ // ==========================================
245
+ // šŸŽÆ Seasons
246
+ // ==========================================
247
+ /** Season id for today, rolling over in August. */
248
+ static currentSeason(date = new Date()) {
249
+ const year = date.getUTCFullYear();
250
+ const start = date.getUTCMonth() >= 7 ? year : year - 1;
251
+ return `${start}${start + 1}`;
252
+ }
253
+ /** The season before the given one, e.g. 20262027 -> 20252026. */
254
+ static previousSeason(season) {
255
+ const start = Number(season.slice(0, 4)) - 1;
256
+ return `${start}${start + 1}`;
257
+ }
258
+ // ==========================================
259
+ // šŸŽÆ Cache
260
+ // ==========================================
261
+ cachePath(rosterSeason, statsSeason) {
262
+ return path.join(this.cacheDir, `players-${rosterSeason}-${statsSeason}.json`);
263
+ }
264
+ readCache(rosterSeason, statsSeason) {
265
+ try {
266
+ const file = this.cachePath(rosterSeason, statsSeason);
267
+ if (!fs.existsSync(file))
268
+ return null;
269
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
270
+ if (Date.now() - parsed.fetched_at > CACHE_TTL_MS)
271
+ return null;
272
+ if (!Array.isArray(parsed.players) || parsed.players.length === 0)
273
+ return null;
274
+ return parsed;
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ writeCache(rosterSeason, statsSeason, players) {
281
+ try {
282
+ fs.mkdirSync(this.cacheDir, { recursive: true });
283
+ const payload = {
284
+ roster_season: rosterSeason,
285
+ stats_season: statsSeason,
286
+ fetched_at: Date.now(),
287
+ players
288
+ };
289
+ fs.writeFileSync(this.cachePath(rosterSeason, statsSeason), JSON.stringify(payload));
290
+ }
291
+ catch (error) {
292
+ console.error('[DEBUG] Could not write NHL player cache:', error);
293
+ }
294
+ }
295
+ }
296
+ /** Shared instance — one player index serves every analysis in the process. */
297
+ export const NHL_STATS = new NhlStatsService();
@@ -0,0 +1,233 @@
1
+ /**
2
+ * šŸ“‹ Roster Store — league state without a league account
3
+ *
4
+ * v4 gets roster, opponent and standings from what the user pastes, rather than
5
+ * from a platform API. That removes the account binding entirely: the same
6
+ * server works for a Yahoo league, an ESPN league, Sleeper, or a spreadsheet.
7
+ *
8
+ * Pasted text is messy by nature, so parsing is deliberately forgiving about
9
+ * format and deliberately strict about identity:
10
+ *
11
+ * šŸ›ļø Rule 3 (Observable Anchoring): a name that does not resolve to exactly
12
+ * one NHL player is reported back as unresolved or ambiguous. It is never
13
+ * guessed. A roster silently containing the wrong player is worse than a
14
+ * roster that tells you it could not read line 7.
15
+ */
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { NHL_STATS } from './NhlStatsService.js';
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = path.dirname(__filename);
22
+ /** Tokens that appear beside names in pasted rosters and are never names. */
23
+ const NOISE = new Set([
24
+ 'c', 'lw', 'rw', 'd', 'g', 'w', 'f', 'util', 'bn', 'ir', 'ir+', 'na',
25
+ 'bench', 'forwards', 'defense', 'defensemen', 'goalies', 'goaltenders',
26
+ 'starters', 'reserve', 'injured', 'total', 'player', 'pos', 'team',
27
+ 'opp', 'status', 'add', 'drop', 'pre-game', 'final', 'q', 'o', 'dtd'
28
+ ]);
29
+ /** Slot labels worth preserving when a paste includes them. */
30
+ const SLOT_TOKENS = new Set(['bn', 'ir', 'ir+', 'na', 'util', 'c', 'lw', 'rw', 'd', 'g']);
31
+ export class RosterStore {
32
+ dataDir;
33
+ constructor(dataDir) {
34
+ this.dataDir = dataDir ?? path.join(__dirname, '..', '..', '.chirp-data');
35
+ }
36
+ // ==========================================
37
+ // šŸŽÆ Parsing pasted text
38
+ // ==========================================
39
+ /**
40
+ * Turn pasted text into resolved players.
41
+ *
42
+ * Handles one player per line in the shapes people actually paste:
43
+ * Auston Matthews
44
+ * MATTHEWS, Auston
45
+ * C Auston Matthews TOR - C Q
46
+ * 1. Auston Matthews (TOR - C)
47
+ * Auston Matthews, TOR, C, 60 GP, 27 G
48
+ */
49
+ parseRoster(text) {
50
+ const resolved = [];
51
+ const unresolved = [];
52
+ const ambiguous = [];
53
+ const seen = new Set();
54
+ const lines = String(text ?? '')
55
+ .split(/\r?\n/)
56
+ .map(l => l.trim())
57
+ .filter(Boolean);
58
+ for (const line of lines) {
59
+ const slot = this.detectSlot(line);
60
+ const candidates = this.candidateNames(line);
61
+ if (candidates.length === 0)
62
+ continue; // header or decoration
63
+ let resolution = null;
64
+ for (const candidate of candidates) {
65
+ const attempt = NHL_STATS.resolve(candidate);
66
+ if (attempt.player) {
67
+ resolution = attempt;
68
+ break;
69
+ }
70
+ // Keep the most informative failure to report.
71
+ if (!resolution || attempt.ambiguous)
72
+ resolution = attempt;
73
+ }
74
+ if (resolution?.player) {
75
+ if (seen.has(resolution.player.player_id))
76
+ continue;
77
+ seen.add(resolution.player.player_id);
78
+ resolved.push(this.toStored(resolution.player, slot));
79
+ }
80
+ else if (resolution?.ambiguous) {
81
+ ambiguous.push({
82
+ line,
83
+ candidates: resolution.ambiguous.map(p => `${p.name} (${p.team} ${p.position})`)
84
+ });
85
+ }
86
+ else if (resolution) {
87
+ unresolved.push({ line, reason: resolution.reason ?? 'no match' });
88
+ }
89
+ }
90
+ return { resolved, unresolved, ambiguous, lines_read: lines.length };
91
+ }
92
+ /**
93
+ * Extract plausible name strings from one pasted line, most specific first.
94
+ *
95
+ * Pasted rows carry positions, team codes, injury flags and statistics
96
+ * around the name. Rather than trying to write one regex for every fantasy
97
+ * site's layout, this produces a few candidates and lets the NHL index
98
+ * decide which is a real player.
99
+ */
100
+ candidateNames(line) {
101
+ // Drop leading rank numbers, bracketed groups and anything after a stat run.
102
+ let cleaned = line
103
+ .replace(/^\s*\d+[.)]\s*/, '')
104
+ .replace(/\([^)]*\)/g, ' ')
105
+ .replace(/\[[^\]]*\]/g, ' ');
106
+ // "Lastname, Firstname" is common enough to try verbatim first.
107
+ const candidates = [];
108
+ const commaForm = cleaned.match(/^\s*([A-Za-zƀ-Ćæ'''.\-]+)\s*,\s*([A-Za-zƀ-Ćæ'''.\-]+)/);
109
+ if (commaForm)
110
+ candidates.push(`${commaForm[2]} ${commaForm[1]}`);
111
+ // Split on separators, then keep runs of word-like tokens that are not noise.
112
+ const tokens = cleaned
113
+ .split(/[\t|,;]+|\s{2,}|\s+-\s+/)
114
+ .flatMap(seg => seg.trim())
115
+ .filter(Boolean);
116
+ for (const segment of tokens) {
117
+ const words = segment
118
+ .split(/\s+/)
119
+ .filter(w => /[A-Za-zƀ-Ćæ]/.test(w) && !NOISE.has(w.toLowerCase().replace(/[^a-z+]/g, '')));
120
+ if (words.length >= 2)
121
+ candidates.push(words.slice(0, 3).join(' '));
122
+ if (words.length >= 2)
123
+ candidates.push(words.slice(0, 2).join(' '));
124
+ if (words.length === 1 && words[0].length > 2)
125
+ candidates.push(words[0]);
126
+ }
127
+ return [...new Set(candidates)];
128
+ }
129
+ /** Pull a lineup slot out of a line when one is present. */
130
+ detectSlot(line) {
131
+ const first = line.trim().split(/[\s\t|,]+/)[0]?.toLowerCase().replace(/[^a-z+]/g, '');
132
+ if (first && SLOT_TOKENS.has(first))
133
+ return first.toUpperCase();
134
+ if (/\bIR\+?\b/.test(line))
135
+ return 'IR';
136
+ if (/\bBN\b|\bbench\b/i.test(line))
137
+ return 'BN';
138
+ return undefined;
139
+ }
140
+ toStored(player, slot) {
141
+ return {
142
+ player_id: player.player_id,
143
+ name: player.name,
144
+ team: player.team,
145
+ position: player.position,
146
+ ...(slot ? { slot } : {})
147
+ };
148
+ }
149
+ // ==========================================
150
+ // šŸŽÆ Standings
151
+ // ==========================================
152
+ /**
153
+ * Parse pasted league standings. Team names are free text — there is no
154
+ * authority to resolve them against — so this only structures what it reads.
155
+ */
156
+ parseStandings(text) {
157
+ const rows = [];
158
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
159
+ const line = raw.trim();
160
+ if (!line)
161
+ continue;
162
+ const rankMatch = line.match(/^\s*(\d{1,2})[.)]?\s+(.+)$/);
163
+ const rest = rankMatch ? rankMatch[2] : line;
164
+ const record = rest.match(/\b(\d{1,3}\s*-\s*\d{1,3}(?:\s*-\s*\d{1,3})?)\b/);
165
+ const points = rest.match(/\b(\d{1,4}(?:\.\d+)?)\s*(?:pts?|points)\b/i);
166
+ const teamName = rest
167
+ .replace(record?.[0] ?? '', '')
168
+ .replace(points?.[0] ?? '', '')
169
+ .replace(/\s{2,}/g, ' ')
170
+ .trim();
171
+ if (!teamName || /^(rank|team|record|pts|points)$/i.test(teamName))
172
+ continue;
173
+ rows.push({
174
+ ...(rankMatch ? { rank: Number(rankMatch[1]) } : {}),
175
+ team_name: teamName,
176
+ ...(record ? { record: record[1].replace(/\s/g, '') } : {}),
177
+ ...(points ? { points: Number(points[1]) } : {})
178
+ });
179
+ }
180
+ return rows;
181
+ }
182
+ // ==========================================
183
+ // šŸŽÆ Persistence
184
+ // ==========================================
185
+ filePath(name) {
186
+ return path.join(this.dataDir, `${name}.json`);
187
+ }
188
+ write(name, value) {
189
+ fs.mkdirSync(this.dataDir, { recursive: true });
190
+ fs.writeFileSync(this.filePath(name), JSON.stringify(value, null, 2));
191
+ }
192
+ read(name) {
193
+ try {
194
+ const file = this.filePath(name);
195
+ if (!fs.existsSync(file))
196
+ return null;
197
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ }
203
+ saveRoster(key, players, label) {
204
+ const stored = { label, players, updated_at: new Date().toISOString() };
205
+ this.write(key, stored);
206
+ return stored;
207
+ }
208
+ getRoster(key) {
209
+ return this.read(key);
210
+ }
211
+ saveStandings(rows) {
212
+ this.write('standings', { rows, updated_at: new Date().toISOString() });
213
+ }
214
+ getStandings() {
215
+ return this.read('standings');
216
+ }
217
+ clear(key) {
218
+ try {
219
+ const file = this.filePath(key);
220
+ if (!fs.existsSync(file))
221
+ return false;
222
+ fs.unlinkSync(file);
223
+ return true;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ }
229
+ getDataDir() {
230
+ return this.dataDir;
231
+ }
232
+ }
233
+ export const ROSTER_STORE = new RosterStore();
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@semanticintent/semantic-chirp-intelligence-mcp",
3
- "version": "3.0.0",
4
- "description": "Semantic Intent pattern implementation for fantasy hockey intelligence - AI analysis that chirps you into championships",
3
+ "version": "4.0.0",
4
+ "description": "Universal fantasy hockey intelligence \u2014 a Model Context Protocol server that reads NHL schedule and player data and analyses any roster you paste. No account, no API key, no platform lock-in.",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
7
7
  "scripts": {
8
- "build": "tsc",
8
+ "build": "npm run clean && tsc",
9
9
  "watch": "tsc --watch",
10
10
  "dev": "node --watch build/index.js",
11
11
  "start": "node build/index.js",
@@ -13,38 +13,37 @@
13
13
  "test": "vitest run",
14
14
  "test:watch": "vitest",
15
15
  "test:ui": "vitest --ui",
16
- "test:coverage": "vitest run --coverage"
16
+ "test:coverage": "vitest run --coverage",
17
+ "preflight": "npm run build && node scripts/preflight.mjs",
18
+ "smoke": "npm run build && node scripts/smoke.mjs",
19
+ "clean": "rm -rf build",
20
+ "prepublishOnly": "npm run build && npm test"
17
21
  },
18
22
  "keywords": [
19
23
  "mcp",
20
24
  "semantic-intent",
21
25
  "fantasy-hockey",
26
+ "nhl",
22
27
  "chirp",
23
28
  "intelligence",
24
- "yahoo-fantasy",
25
- "ice"
29
+ "ice",
30
+ "model-context-protocol"
26
31
  ],
27
32
  "author": "semanticintent",
28
33
  "license": "MIT",
29
- "publishConfig": {
30
- "access": "public"
31
- },
32
34
  "repository": {
33
35
  "type": "git",
34
- "url": "git+https://github.com/semanticintent/semantic-chirp-intelligence-mcp.git"
36
+ "url": "https://github.com/semanticintent/semantic-chirp-intelligence-mcp.git"
35
37
  },
36
38
  "devDependencies": {
37
39
  "@types/node": "^24.7.0",
38
- "@types/xml2js": "^0.4.14",
39
- "@vitest/coverage-v8": "^3.2.4",
40
- "@vitest/ui": "^3.2.4",
40
+ "@vitest/coverage-v8": "^4.1.10",
41
+ "@vitest/ui": "^4.1.10",
41
42
  "typescript": "^5.9.3",
42
- "vitest": "^3.2.4"
43
+ "vitest": "^4.1.10"
43
44
  },
44
45
  "dependencies": {
45
46
  "@modelcontextprotocol/sdk": "^1.19.1",
46
- "dotenv": "^17.2.3",
47
- "selfsigned": "^3.0.1",
48
- "xml2js": "^0.6.2"
47
+ "dotenv": "^17.4.2"
49
48
  }
50
49
  }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 🚦 Preflight — is this server ready to run?
4
+ *
5
+ * v4 needs no credentials, no OAuth and no platform account, so this checks
6
+ * only what actually matters: the build, the NHL public API, and whether a
7
+ * roster has been provided yet.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+
13
+ let failures = 0;
14
+ const ok = (m) => console.log(` āœ… ${m}`);
15
+ const bad = (m) => { failures++; console.log(` āŒ ${m}`); };
16
+ const info = (m) => console.log(` ā„¹ļø ${m}`);
17
+
18
+ console.log('\n🚦 CHIRP preflight\n');
19
+
20
+ console.log('Build');
21
+ fs.existsSync('build/index.js')
22
+ ? ok('build/index.js present')
23
+ : bad('build/index.js missing — run `npm run build`');
24
+
25
+ console.log('\nNHL public API (no key required)');
26
+ for (const [label, url, check] of [
27
+ ['schedule', 'https://api-web.nhle.com/v1/club-schedule-season/TOR/20262027',
28
+ d => `${(d.games ?? []).filter(g => g.gameType === 2).length} regular-season games for TOR`],
29
+ ['rosters', 'https://api-web.nhle.com/v1/roster/TOR/20262027',
30
+ d => `${(d.forwards ?? []).length + (d.defensemen ?? []).length + (d.goalies ?? []).length} players on TOR`],
31
+ ['standings', 'https://api-web.nhle.com/v1/standings/now',
32
+ d => `${(d.standings ?? []).length} teams`]
33
+ ]) {
34
+ try {
35
+ const res = await fetch(url);
36
+ if (!res.ok) { bad(`${label}: HTTP ${res.status}`); continue; }
37
+ ok(`${label} reachable — ${check(await res.json())}`);
38
+ } catch (error) {
39
+ bad(`${label} unreachable: ${error.message}`);
40
+ }
41
+ }
42
+
43
+ for (const [dir, label] of [['.nhl-schedule-cache', 'NHL data cache'], ['.chirp-data', 'stored league data']]) {
44
+ fs.existsSync(dir)
45
+ ? info(`${label}: ${fs.readdirSync(dir).join(', ') || '(empty)'}`)
46
+ : info(`${label}: none yet`);
47
+ }
48
+
49
+ console.log('\nYour league');
50
+ const rosterFile = path.join('.chirp-data', 'roster.json');
51
+ if (fs.existsSync(rosterFile)) {
52
+ try {
53
+ const r = JSON.parse(fs.readFileSync(rosterFile, 'utf8'));
54
+ ok(`roster stored: "${r.label}" — ${r.players.length} players (updated ${r.updated_at})`);
55
+ } catch {
56
+ bad('roster file present but unreadable');
57
+ }
58
+ } else {
59
+ info('no roster yet — paste one with the `set_roster` tool, then everything else works');
60
+ }
61
+
62
+ console.log(
63
+ failures === 0
64
+ ? '\nāœ… Preflight clean — no credentials needed.\n'
65
+ : `\nāŒ ${failures} check(s) failed.\n`
66
+ );
67
+ process.exit(failures === 0 ? 0 : 1);