nager-mcp 1.0.0 → 1.0.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/README.md CHANGED
@@ -1,13 +1,41 @@
1
1
  # nager-mcp
2
2
 
3
- Fetch Nager.Date public holidays (keyless): countries, yearly holidays, upcoming holidays.
3
+ Worldwide public holidays and long weekends - no API key.
4
4
 
5
5
  ## Tools
6
6
 
7
- - `list_countries` — Supported country codes.
8
- - `get_holidays` — Holidays for a year and country.
9
- - `next_holidays` — Next holidays for a country.
7
+ - `list_countries` — Every country the public holiday API covers, with its two-letter code.
8
+ - `country_info` — Official name, region and land borders for a country.
9
+ - `get_holidays` — All public holidays for a country and year, with local and English names.
10
+ - `next_holidays` — The next public holidays for a country, starting from today.
11
+ - `worldwide_holidays` — The next public holidays around the world - useful for planning around other countries' closures.
12
+ - `long_weekends` — Long weekends in a country and year, and whether a bridge day off is needed to get them.
13
+ - `is_today_holiday` — Whether today is a public holiday in a country.
14
+ - `regional_holidays` — Holidays that actually apply in one subdivision (US-CA, DE-BE, CA-ON): national days plus local ones.
15
+ - `holiday_on_date` — Is one specific date a public holiday? Answers yes or no, with the previous and next holiday.
16
+ - `compare_countries` — Shared, and country-specific, public holidays for two countries in one year.
10
17
 
11
- ## Source
18
+ ## Setup
12
19
 
13
- [nager-mcp](https://github.com/mrfentmen/premium-mcps/tree/main/servers/nager-mcp)
20
+ No setup. Holiday data comes from the public Nager.Date API.
21
+
22
+ Part of [awesome-mcps](https://github.com/mrfentmen/awesome-mcps).
23
+
24
+ <!-- paywall -->
25
+ ## Premium tools
26
+
27
+ These tools need a license key:
28
+
29
+ * `working_day_calendar`
30
+ * `holiday_observance_audit`
31
+
32
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-nager-mcp and set it in your MCP client config:
33
+
34
+ ```json
35
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
36
+ ```
37
+
38
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
39
+ working offline once one check has succeeded. Every other tool on this server
40
+ stays free.
41
+ <!-- /paywall -->
package/dist/api.d.ts CHANGED
@@ -1,7 +1,70 @@
1
+ /**
2
+ * Nager.Date public holiday API client, keyless.
3
+ * Covers 100+ countries: holidays, next holidays, long weekends.
4
+ */
1
5
  export declare class NagerError extends Error {
2
6
  constructor(message: string);
3
7
  }
4
8
  export declare function errorMessage(e: unknown): string;
5
- export declare function listCountries(): Promise<string>;
6
- export declare function getHolidays(year: string, country: string): Promise<string>;
7
- export declare function nextHolidays(country: string): Promise<string>;
9
+ export interface Country {
10
+ countryCode: string;
11
+ name: string;
12
+ }
13
+ export interface Holiday {
14
+ date: string;
15
+ localName: string;
16
+ name: string;
17
+ countryCode: string;
18
+ global?: boolean;
19
+ counties?: string[] | null;
20
+ types?: string[];
21
+ }
22
+ export interface CountryInfo {
23
+ commonName: string;
24
+ officialName: string;
25
+ countryCode: string;
26
+ region: string;
27
+ borders?: Array<{
28
+ commonName: string;
29
+ officialName: string;
30
+ countryCode: string;
31
+ }> | null;
32
+ }
33
+ export interface LongWeekend {
34
+ startDate: string;
35
+ endDate: string;
36
+ dayCount: number;
37
+ needBridgeDay: boolean;
38
+ bridgeDays?: string[];
39
+ }
40
+ export declare function countries(): Promise<Country[]>;
41
+ export declare function countryInfo(country: string): Promise<CountryInfo>;
42
+ export declare function holidays(year: string, country: string): Promise<Holiday[]>;
43
+ export declare function nextHolidays(country: string): Promise<Holiday[]>;
44
+ export declare function worldwideHolidays(): Promise<Holiday[]>;
45
+ export declare function longWeekends(year: string, country: string): Promise<LongWeekend[]>;
46
+ export type TodayResult = {
47
+ isHoliday: true;
48
+ holiday: Holiday;
49
+ } | {
50
+ isHoliday: false;
51
+ };
52
+ export declare function isTodayHoliday(country: string): Promise<TodayResult>;
53
+ export declare function normaliseCountry(country: string): string;
54
+ export declare function formatHoliday(h: Holiday): string;
55
+ export declare function formatHolidays(list: Holiday[], title: string): string;
56
+ export declare function formatCountryInfo(info: CountryInfo): string;
57
+ export declare function formatLongWeekends(list: LongWeekend[], title: string): string;
58
+ export declare function regionalHolidays(year: string, country: string, county: string): Promise<string>;
59
+ export declare function holidayOnDate(date: string, country: string): Promise<string>;
60
+ export declare function compareCountries(year: string, countryA: string, countryB: string): Promise<string>;
61
+ /** Month by month working-day arithmetic for one country and year. */
62
+ export declare function workingDayCalendar(args: {
63
+ country: string;
64
+ year: string;
65
+ }): Promise<string>;
66
+ /** Where each holiday lands, and what it does to the working week around it. */
67
+ export declare function holidayObservanceAudit(args: {
68
+ country: string;
69
+ year: string;
70
+ }): Promise<string>;
package/dist/api.js CHANGED
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Nager.Date public holiday API client, keyless.
3
+ * Covers 100+ countries: holidays, next holidays, long weekends.
4
+ */
1
5
  export class NagerError extends Error {
2
6
  constructor(message) {
3
7
  super(message);
@@ -7,30 +11,364 @@ export class NagerError extends Error {
7
11
  export function errorMessage(e) {
8
12
  return e instanceof Error ? e.message : String(e);
9
13
  }
10
- const UA = { "User-Agent": "awesome-mcps/1.0" };
11
- async function get(path) {
14
+ const UA = "mrfentmen-nager-mcp/1.0";
15
+ const BASE = "https://date.nager.at/api/v3";
16
+ let countriesCache = null;
17
+ async function call(path) {
12
18
  let res;
13
19
  try {
14
- res = await fetch("https://date.nager.at/api/v3" + path, {
15
- headers: { ...UA, Accept: "application/json" },
16
- signal: AbortSignal.timeout(25000),
20
+ res = await fetch(BASE + path, {
21
+ headers: { "User-Agent": UA, Accept: "application/json" },
22
+ signal: AbortSignal.timeout(20000),
17
23
  });
18
24
  }
19
25
  catch (e) {
20
- throw new NagerError(`Network error: ${e.message}`);
26
+ throw new NagerError(`Could not reach date.nager.at: ${errorMessage(e)}`);
21
27
  }
28
+ const body = (await res.text()).slice(0, 200000);
29
+ if (res.status === 204 || res.status === 404)
30
+ return { status: res.status, body };
22
31
  if (!res.ok) {
23
- const body = (await res.text()).slice(0, 300);
24
- throw new NagerError(`HTTP ${res.status}: ${body}`);
32
+ throw new NagerError(`date.nager.at returned HTTP ${res.status}: ${body.slice(0, 200)}`);
25
33
  }
26
- return (await res.text()).slice(0, 12000);
34
+ return { status: res.status, body };
27
35
  }
28
- export function listCountries() {
29
- return get("/AvailableCountries");
36
+ async function getJson(path) {
37
+ const { body } = await call(path);
38
+ if (!body)
39
+ throw new NagerError("date.nager.at returned an empty response.");
40
+ try {
41
+ return JSON.parse(body);
42
+ }
43
+ catch {
44
+ throw new NagerError("date.nager.at returned a non-JSON response.");
45
+ }
46
+ }
47
+ export async function countries() {
48
+ if (countriesCache)
49
+ return countriesCache;
50
+ countriesCache = await getJson("/AvailableCountries");
51
+ return countriesCache;
52
+ }
53
+ export async function countryInfo(country) {
54
+ const code = normaliseCountry(country);
55
+ return getJson(`/CountryInfo/${encodeURIComponent(code)}`);
56
+ }
57
+ export async function holidays(year, country) {
58
+ const y = normaliseYear(year);
59
+ const code = normaliseCountry(country);
60
+ return getJson(`/PublicHolidays/${y}/${encodeURIComponent(code)}`);
61
+ }
62
+ export async function nextHolidays(country) {
63
+ const code = normaliseCountry(country);
64
+ return getJson(`/NextPublicHolidays/${encodeURIComponent(code)}`);
65
+ }
66
+ export async function worldwideHolidays() {
67
+ return getJson("/NextPublicHolidaysWorldwide");
68
+ }
69
+ export async function longWeekends(year, country) {
70
+ const y = normaliseYear(year);
71
+ const code = normaliseCountry(country);
72
+ return getJson(`/LongWeekend/${y}/${encodeURIComponent(code)}`);
73
+ }
74
+ export async function isTodayHoliday(country) {
75
+ const code = normaliseCountry(country);
76
+ const { status, body } = await call(`/IsTodayPublicHoliday/${encodeURIComponent(code)}`);
77
+ if (status === 204)
78
+ return { isHoliday: false };
79
+ if (!body)
80
+ return { isHoliday: false };
81
+ try {
82
+ const parsed = JSON.parse(body);
83
+ const holiday = Array.isArray(parsed) ? parsed[0] : parsed;
84
+ return holiday && holiday.name ? { isHoliday: true, holiday } : { isHoliday: false };
85
+ }
86
+ catch {
87
+ return { isHoliday: false };
88
+ }
89
+ }
90
+ function normaliseYear(year) {
91
+ const y = year.trim();
92
+ if (!/^\d{4}$/.test(y))
93
+ throw new NagerError(`"${year}" is not a four-digit year.`);
94
+ const n = Number(y);
95
+ if (n < 1975 || n > 2100)
96
+ throw new NagerError(`Year ${n} is outside the supported range 1975-2100.`);
97
+ return y;
98
+ }
99
+ export function normaliseCountry(country) {
100
+ const c = country.trim().toUpperCase();
101
+ if (!/^[A-Z]{2}$/.test(c)) {
102
+ throw new NagerError(`"${country}" is not a two-letter country code - call list_countries for the list.`);
103
+ }
104
+ return c;
105
+ }
106
+ export function formatHoliday(h) {
107
+ const types = (h.types ?? []).join("/");
108
+ const scope = h.global === false ? "regional" : "national";
109
+ return ` ${h.date} ${h.localName}${h.name !== h.localName ? ` (${h.name})` : ""} - ${types || scope}`;
30
110
  }
31
- export function getHolidays(year, country) {
32
- return get(`/PublicHolidays/${encodeURIComponent(year)}/${encodeURIComponent(country)}`);
111
+ export function formatHolidays(list, title) {
112
+ if (list.length === 0)
113
+ return `No holidays found for ${title}.`;
114
+ const sorted = [...list].sort((a, b) => a.date.localeCompare(b.date));
115
+ return `${title} (${sorted.length}):\n\n${sorted.map(formatHoliday).join("\n")}`;
33
116
  }
34
- export function nextHolidays(country) {
35
- return get(`/NextPublicHolidays/${encodeURIComponent(country)}`);
117
+ export function formatCountryInfo(info) {
118
+ const borders = (info.borders ?? []).map((b) => `${b.commonName} (${b.countryCode})`);
119
+ return [
120
+ `${info.commonName} (${info.countryCode})`,
121
+ ` official name ${info.officialName}`,
122
+ ` region ${info.region}`,
123
+ ` borders ${borders.length ? borders.join(", ") : "none - island or no land border"}`,
124
+ ].join("\n");
125
+ }
126
+ export function formatLongWeekends(list, title) {
127
+ if (list.length === 0)
128
+ return `No long weekends found for ${title}.`;
129
+ const lines = list.map((w) => ` ${w.startDate} -> ${w.endDate} ${w.dayCount} day(s)${w.needBridgeDay ? ` needs bridge day: ${(w.bridgeDays ?? []).join(", ")}` : " no bridge day needed"}`);
130
+ return `${title} (${list.length}):\n\n${lines.join("\n")}`;
131
+ }
132
+ // ---- batch-4 tools: regional holidays, date lookup and country diff (2026-09-27) ----
133
+ export async function regionalHolidays(year, country, county) {
134
+ const y = normaliseYear(year);
135
+ const code = normaliseCountry(country);
136
+ const region = String(county ?? "").trim().toUpperCase();
137
+ if (!/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(region)) {
138
+ throw new NagerError(`"${county}" is not a subdivision code - use the country code and the region, for example DE-BE or US-CA.`);
139
+ }
140
+ const list = await getJson(`/PublicHolidays/${y}/${encodeURIComponent(code)}?county=${encodeURIComponent(region)}`);
141
+ if (!list.length)
142
+ return `No holidays for ${code} in ${y}.`;
143
+ const own = list.filter((h) => Array.isArray(h.counties) && h.counties.length > 0);
144
+ const national = list.length - own.length;
145
+ return [
146
+ `Holidays that apply in ${region} during ${y}: ${list.length} in total`,
147
+ ` national (country-wide): ${national} | ${region} specific: ${own.length}`,
148
+ "",
149
+ formatHolidays(list, `${region} ${y}`),
150
+ "",
151
+ "Subdivision codes follow ISO 3166-2: DE-BE (Berlin), US-CA (California), CA-ON (Ontario).",
152
+ ].join("\n");
153
+ }
154
+ export async function holidayOnDate(date, country) {
155
+ const clean = String(date ?? "").trim();
156
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(clean)) {
157
+ throw new NagerError(`"${date}" is not a date - use YYYY-MM-DD, for example 2026-12-25.`);
158
+ }
159
+ const year = clean.slice(0, 4);
160
+ const code = normaliseCountry(country);
161
+ const list = (await holidays(year, code)).sort((a, b) => a.date.localeCompare(b.date));
162
+ const weekday = new Date(`${clean}T00:00:00Z`).toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC" });
163
+ const hits = list.filter((h) => h.date === clean);
164
+ const before = [...list].reverse().find((h) => h.date < clean);
165
+ const after = list.find((h) => h.date > clean);
166
+ const neighbours = [
167
+ before ? ` previous ${before.date} ${before.localName}` : "",
168
+ after ? ` next ${after.date} ${after.localName}` : "",
169
+ ].filter(Boolean);
170
+ if (!hits.length) {
171
+ return [
172
+ `${clean} (${weekday}) is not a public holiday in ${code}.`,
173
+ "",
174
+ ...neighbours,
175
+ "",
176
+ `The country has ${list.length} holiday(s) listed for ${year}.`,
177
+ ].join("\n");
178
+ }
179
+ return [
180
+ `Yes - ${clean} (${weekday}) is a public holiday in ${code}:`,
181
+ "",
182
+ ...hits.map((h) => ` ${h.localName}${h.name !== h.localName ? ` (${h.name})` : ""}${h.global === false ? " - subdivision only" : ""}`),
183
+ hits.some((h) => !h.global) ? " Some entries apply only in part of the country." : "",
184
+ "",
185
+ ...neighbours,
186
+ ].filter((line) => line !== "").join("\n");
187
+ }
188
+ export async function compareCountries(year, countryA, countryB) {
189
+ const y = normaliseYear(year);
190
+ const a = normaliseCountry(countryA);
191
+ const b = normaliseCountry(countryB);
192
+ if (a === b)
193
+ throw new NagerError("Give two different country codes to compare.");
194
+ const [listA, listB] = await Promise.all([holidays(y, a), holidays(y, b)]);
195
+ if (!listA.length || !listB.length) {
196
+ throw new NagerError(`date.nager.at has no holidays for ${!listA.length ? a : b} in ${y}.`);
197
+ }
198
+ const namesA = new Set(listA.map((h) => `${h.date}|${h.name}`));
199
+ const namesB = new Set(listB.map((h) => `${h.date}|${h.name}`));
200
+ const shared = [...namesA].filter((key) => namesB.has(key)).sort();
201
+ const onlyA = [...namesA].filter((key) => !namesB.has(key)).sort();
202
+ const onlyB = [...namesB].filter((key) => !namesA.has(key)).sort();
203
+ const datesA = new Map(listA.map((h) => [h.date, h.name]));
204
+ const sameDateDifferentName = listB
205
+ .filter((h) => datesA.has(h.date) && datesA.get(h.date) !== h.name)
206
+ .map((h) => ` ${h.date} ${h.name} vs ${datesA.get(h.date)}`);
207
+ const show = (keys) => keys.slice(0, 12).map((key) => ` ${key.replace("|", " ")}`);
208
+ return [
209
+ `${a} vs ${b} public holidays in ${y}:`,
210
+ "",
211
+ ` ${a}: ${listA.length} holiday(s) | ${b}: ${listB.length}`,
212
+ ` same date and name: ${shared.length} | ${a} only: ${onlyA.length} | ${b} only: ${onlyB.length}`,
213
+ ` same date, different name: ${sameDateDifferentName.length}`,
214
+ "",
215
+ "Shared days:",
216
+ ...(shared.length ? show(shared) : [" none"]),
217
+ "",
218
+ `Only in ${a}:`,
219
+ ...(onlyA.length ? show(onlyA) : [" none"]),
220
+ "",
221
+ `Only in ${b}:`,
222
+ ...(onlyB.length ? show(onlyB) : [" none"]),
223
+ "",
224
+ sameDateDifferentName.length ? "Same day, different name:" : "",
225
+ ...sameDateDifferentName.slice(0, 8),
226
+ "",
227
+ `Shows up to 12 entries per list; both countries keep their own names for the same festival.`,
228
+ ].filter((line) => line !== "").join("\n");
229
+ }
230
+ // ---- muxF tools:
231
+ /**
232
+ * The holiday API answers "when are the holidays"; these two answer the
233
+ * questions a working calendar actually needs - how many working days are
234
+ * left, and where a holiday lands on a weekend.
235
+ */
236
+ const NAG_WEEKEND = [0, 6]; // getUTCDay: 0 Sunday, 6 Saturday
237
+ const nagDayName = (date) => new Date(`${date}T00:00:00Z`).toLocaleDateString("en-US", { weekday: "short", timeZone: "UTC" });
238
+ const nagIsWeekend = (date) => NAG_WEEKEND.includes(new Date(`${date}T00:00:00Z`).getUTCDay());
239
+ const nagAddDays = (date, days) => {
240
+ const stamp = Date.parse(`${date}T00:00:00Z`);
241
+ return new Date(stamp + days * 86_400_000).toISOString().slice(0, 10);
242
+ };
243
+ const nagMonthName = (month) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString("en-US", { month: "long", timeZone: "UTC" });
244
+ async function nagYear(code, year) {
245
+ const list = await getJson(`/PublicHolidays/${year}/${code}`);
246
+ return (Array.isArray(list) ? list : []).filter((h) => h && typeof h.date === "string");
247
+ }
248
+ /** Month by month working-day arithmetic for one country and year. */
249
+ export async function workingDayCalendar(args) {
250
+ const code = normaliseCountry(args?.country);
251
+ const year = normaliseYear(args?.year);
252
+ const holidays = await nagYear(code, year);
253
+ const holidayDays = new Set(holidays.map((h) => h.date));
254
+ const yearStart = `${year}-01-01`;
255
+ const yearEnd = `${year}-12-31`;
256
+ const months = [];
257
+ let totalWeekdays = 0;
258
+ let totalWeekends = 0;
259
+ let totalHolidays = 0;
260
+ let totalWorking = 0;
261
+ for (let month = 1; month <= 12; month += 1) {
262
+ const key = `${year}-${String(month).padStart(2, "0")}`;
263
+ const daysInMonth = new Date(Date.UTC(Number(year), month, 0)).getUTCDate();
264
+ let weekends = 0;
265
+ let holidays = 0;
266
+ let weekdayHolidays = 0;
267
+ let workingInMonth = 0;
268
+ for (let day = 1; day <= daysInMonth; day += 1) {
269
+ const date = `${key}-${String(day).padStart(2, "0")}`;
270
+ const weekend = nagIsWeekend(date);
271
+ const isHoliday = holidayDays.has(date);
272
+ if (weekend)
273
+ weekends += 1;
274
+ if (isHoliday) {
275
+ holidays += 1;
276
+ if (!weekend)
277
+ weekdayHolidays += 1;
278
+ }
279
+ if (!weekend && !isHoliday)
280
+ workingInMonth += 1;
281
+ }
282
+ const weekdays = daysInMonth - weekends;
283
+ const working = weekdays - weekdayHolidays;
284
+ totalWorking += working;
285
+ months.push({ month: key, days: daysInMonth, weekends, weekdays, holidays, working });
286
+ totalWeekends += weekends;
287
+ totalHolidays += holidays;
288
+ totalWeekdays += weekdays;
289
+ }
290
+ const longestStreak = () => {
291
+ let best = { from: "", to: "", days: 0 };
292
+ let run = 0;
293
+ let start = "";
294
+ for (let day = 1; day <= 366; day += 1) {
295
+ const date = nagAddDays(yearStart, day - 1);
296
+ if (date > yearEnd)
297
+ break;
298
+ const free = !nagIsWeekend(date) && !holidayDays.has(date);
299
+ if (free) {
300
+ if (run === 0)
301
+ start = date;
302
+ run += 1;
303
+ if (run > best.days)
304
+ best = { from: start, to: date, days: run };
305
+ }
306
+ else {
307
+ run = 0;
308
+ }
309
+ }
310
+ return best;
311
+ };
312
+ const streak = longestStreak();
313
+ const lines = [
314
+ `Working-day calendar for ${code} in ${year}, from the holiday API's own list (${holidays.length} holiday date(s))`,
315
+ ` ${totalWeekdays} weekday(s) in the year, ${totalWeekends} weekend day(s), ${totalHolidays} of those weekdays are holidays`,
316
+ ` working days: ${totalWorking}`,
317
+ ` longest unbroken run of working days: ${streak.days} day(s), ${streak.from} to ${streak.to}`,
318
+ "",
319
+ " month days weekends weekdays holidays working",
320
+ ];
321
+ for (const m of months) {
322
+ lines.push(` ${nagMonthName(m.month).padEnd(14)}${String(m.days).padStart(4)}${String(m.weekends).padStart(10)}${String(m.weekdays).padStart(10)}${String(m.holidays).padStart(10)}${String(m.working).padStart(9)}`);
323
+ }
324
+ lines.push("");
325
+ lines.push(" A holiday that falls on a weekend costs no working day, which is why a country's working-day count is lower than its weekday count by less than its holiday count.");
326
+ return lines.join("\n");
327
+ }
328
+ /** Where each holiday lands, and what it does to the working week around it. */
329
+ export async function holidayObservanceAudit(args) {
330
+ const code = normaliseCountry(args?.country);
331
+ const year = normaliseYear(args?.year);
332
+ const holidays = await nagYear(code, year);
333
+ if (!holidays.length)
334
+ throw new NagerError(`The holiday API has no ${year} holidays for ${code}.`);
335
+ const rows = [...holidays].sort((a, b) => a.date.localeCompare(b.date));
336
+ const weekendRows = rows.filter((h) => nagIsWeekend(h.date));
337
+ const mondayObserved = rows.filter((h) => nagIsWeekend(h.date) && nagDayName(h.date) === "Sun");
338
+ const saturdayRows = rows.filter((h) => nagDayName(h.date) === "Sat");
339
+ const national = rows.filter((h) => h.global !== false);
340
+ const regional = rows.filter((h) => h.global === false);
341
+ const runAfter = (date) => {
342
+ let count = 0;
343
+ let cursor = date;
344
+ for (let i = 0; i < 14; i += 1) {
345
+ cursor = nagAddDays(cursor, 1);
346
+ const weekend = nagIsWeekend(cursor);
347
+ if (!weekend && !rows.some((h) => h.date === cursor))
348
+ count += 1;
349
+ }
350
+ return count;
351
+ };
352
+ const lines = [
353
+ `Holiday observance for ${code} in ${year}: ${rows.length} holiday date(s)`,
354
+ ` ${national.length} national, ${regional.length} regional (${regional.length ? "regional holidays may not apply where you are" : "none reported"})`,
355
+ ` ${weekendRows.length} fall on a weekend: ${saturdayRows.length} Saturday, ${mondayObserved.length} Sunday`,
356
+ ];
357
+ lines.push("");
358
+ lines.push(" date day falls on gap to next free working day name");
359
+ for (const h of rows) {
360
+ const day = nagDayName(h.date);
361
+ const weekend = nagIsWeekend(h.date);
362
+ const gap = weekend ? "weekend, no working day lost" : `${runAfter(h.date)} working day(s) after`;
363
+ lines.push(` ${h.date} ${day.padEnd(5)} ${(weekend ? "weekend" : "weekday").padEnd(10)} ${gap.padEnd(32)} ${h.localName || h.name}`);
364
+ }
365
+ lines.push("");
366
+ if (weekendRows.length) {
367
+ lines.push(` ${weekendRows.length} holiday(s) land on a weekend, so a substitute day is the employer's policy, not something the API states.`);
368
+ }
369
+ const backToBack = rows.filter((h, i) => i > 0 && nagAddDays(rows[i - 1].date, 1) === h.date);
370
+ if (backToBack.length) {
371
+ lines.push(` ${backToBack.length} holiday(s) fall on the day after another: ${backToBack.map((h) => `${h.date} ${h.localName}`).join(", ")}`);
372
+ }
373
+ return lines.join("\n");
36
374
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns null when the caller may run the tool, or a short message telling
3
+ * them how to get a key.
4
+ *
5
+ * A good key is remembered for an hour, so a busy session does not hit the
6
+ * license server on every call and keeps working through a brief outage.
7
+ */
8
+ export declare function premiumRequired(tool: string): Promise<string | null>;
@@ -0,0 +1,79 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "nager-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "working_day_calendar",
8
+ "holiday_observance_audit",
9
+ ]);
10
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
+ // The license check calls the marketplace's verify endpoint directly instead of
12
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
+ // check it makes is rejected by Supabase before it reaches the function. The
14
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
15
+ // own JavaScript and only ever reaches their licence check.
16
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
+ const OK_CACHE_MS = 60 * 60 * 1000;
19
+ const BAD_CACHE_MS = 60 * 1000;
20
+ const seen = new Map();
21
+ const REASONS = {
22
+ missing_key: "no key is set",
23
+ invalid_format: "that key is not a valid MCP Marketplace key",
24
+ not_found: "that key was not found",
25
+ revoked: "that key has been revoked",
26
+ rotated: "that key was rotated, use your new one",
27
+ expired: "that key has expired, renew it",
28
+ rate_limited: "there were too many checks just now, try again shortly",
29
+ network_error: "the license server could not be reached",
30
+ };
31
+ function blocked(tool, reason) {
32
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
+ return `"${tool}" needs a license key, but ${why}. ` +
34
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
+ }
36
+ async function verify(key) {
37
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ apikey: PUBLISHABLE_KEY,
41
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify({ key, slug: SLUG }),
45
+ // an unusable key answers 400 with a JSON body, so read the body either way
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ const data = (await res.json());
49
+ if (typeof data?.valid !== "boolean")
50
+ return { valid: false, reason: "unexpected_response" };
51
+ return data;
52
+ }
53
+ /**
54
+ * Returns null when the caller may run the tool, or a short message telling
55
+ * them how to get a key.
56
+ *
57
+ * A good key is remembered for an hour, so a busy session does not hit the
58
+ * license server on every call and keeps working through a brief outage.
59
+ */
60
+ export async function premiumRequired(tool) {
61
+ if (!PREMIUM.has(tool))
62
+ return null;
63
+ const key = process.env.MCP_LICENSE_KEY;
64
+ if (!key)
65
+ return blocked(tool, "missing_key");
66
+ const hit = seen.get(key);
67
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
+ }
70
+ let result;
71
+ try {
72
+ result = await verify(key);
73
+ }
74
+ catch {
75
+ result = { valid: false, reason: "network_error" };
76
+ }
77
+ seen.set(key, { ...result, at: Date.now() });
78
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
+ }
package/dist/server.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { errorMessage, listCountries, getHolidays, nextHolidays, } from "./api.js";
3
+ import { countries, countryInfo, errorMessage, formatCountryInfo, formatHoliday, formatHolidays, formatLongWeekends, holidays, isTodayHoliday, longWeekends, nextHolidays, worldwideHolidays, regionalHolidays, holidayOnDate, compareCountries, workingDayCalendar, holidayObservanceAudit } from "./api.js";
4
+ import { premiumRequired } from "./license.js";
4
5
  const text = (t) => ({ content: [{ type: "text", text: t }] });
5
6
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
7
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
8
+ const COUNTRY = z.string().describe("Two-letter country code, e.g. 'US', 'DE', 'JP'");
9
+ const YEAR = z.string().describe("Four-digit year, e.g. '2026'");
7
10
  export function createServer() {
8
11
  const server = new McpServer({
9
12
  name: "nager-mcp",
@@ -11,28 +14,41 @@ export function createServer() {
11
14
  });
12
15
  server.registerTool("list_countries", {
13
16
  title: "List countries",
14
- description: "Supported country codes.",
17
+ description: "Every country the public holiday API covers, with its two-letter code.",
15
18
  inputSchema: z.object({}),
16
19
  annotations: READ_ONLY,
17
- }, async ({}) => {
20
+ }, async () => {
18
21
  try {
19
- return text(await listCountries());
22
+ const list = await countries();
23
+ const lines = list.map((c) => ` ${c.countryCode} ${c.name}`);
24
+ return text(`${list.length} countries:\n\n${lines.join("\n")}`);
25
+ }
26
+ catch (e) {
27
+ return textError(errorMessage(e));
28
+ }
29
+ });
30
+ server.registerTool("country_info", {
31
+ title: "Country info",
32
+ description: "Official name, region and land borders for a country.",
33
+ inputSchema: z.object({ country: COUNTRY }),
34
+ annotations: READ_ONLY,
35
+ }, async ({ country }) => {
36
+ try {
37
+ return text(formatCountryInfo(await countryInfo(country)));
20
38
  }
21
39
  catch (e) {
22
40
  return textError(errorMessage(e));
23
41
  }
24
42
  });
25
43
  server.registerTool("get_holidays", {
26
- title: "Year holidays",
27
- description: "Holidays for a year and country.",
28
- inputSchema: z.object({
29
- year: z.string().describe("Year, e.g. 2026."),
30
- country: z.string().describe("ISO code, e.g. US.")
31
- }),
44
+ title: "Public holidays",
45
+ description: "All public holidays for a country and year, with local and English names.",
46
+ inputSchema: z.object({ year: YEAR, country: COUNTRY }),
32
47
  annotations: READ_ONLY,
33
48
  }, async ({ year, country }) => {
34
49
  try {
35
- return text(await getHolidays(year, country));
50
+ const list = await holidays(year, country);
51
+ return text(formatHolidays(list, `${country.toUpperCase()} public holidays in ${year}`));
36
52
  }
37
53
  catch (e) {
38
54
  return textError(errorMessage(e));
@@ -40,14 +56,148 @@ export function createServer() {
40
56
  });
41
57
  server.registerTool("next_holidays", {
42
58
  title: "Upcoming holidays",
43
- description: "Next holidays for a country.",
59
+ description: "The next public holidays for a country, starting from today.",
60
+ inputSchema: z.object({ country: COUNTRY }),
61
+ annotations: READ_ONLY,
62
+ }, async ({ country }) => {
63
+ try {
64
+ const list = await nextHolidays(country);
65
+ return text(formatHolidays(list, `Upcoming holidays in ${country.toUpperCase()}`));
66
+ }
67
+ catch (e) {
68
+ return textError(errorMessage(e));
69
+ }
70
+ });
71
+ server.registerTool("worldwide_holidays", {
72
+ title: "Holidays worldwide",
73
+ description: "The next public holidays around the world - useful for planning around other countries' closures.",
44
74
  inputSchema: z.object({
45
- country: z.string().describe("ISO code, e.g. US.")
75
+ limit: z.number().int().min(1).max(100).default(25).describe("How many holidays to show"),
46
76
  }),
47
77
  annotations: READ_ONLY,
78
+ }, async ({ limit }) => {
79
+ try {
80
+ const list = (await worldwideHolidays()).sort((a, b) => a.date.localeCompare(b.date));
81
+ const shown = list.slice(0, limit);
82
+ const lines = shown.map((h) => `${formatHoliday(h)} [${h.countryCode}]`);
83
+ return text(`Next holidays worldwide (${shown.length} of ${list.length}):\n\n${lines.join("\n")}`);
84
+ }
85
+ catch (e) {
86
+ return textError(errorMessage(e));
87
+ }
88
+ });
89
+ server.registerTool("long_weekends", {
90
+ title: "Long weekends",
91
+ description: "Long weekends in a country and year, and whether a bridge day off is needed to get them.",
92
+ inputSchema: z.object({ year: YEAR, country: COUNTRY }),
93
+ annotations: READ_ONLY,
94
+ }, async ({ year, country }) => {
95
+ try {
96
+ const list = await longWeekends(year, country);
97
+ return text(formatLongWeekends(list, `Long weekends in ${country.toUpperCase()} ${year}`));
98
+ }
99
+ catch (e) {
100
+ return textError(errorMessage(e));
101
+ }
102
+ });
103
+ server.registerTool("is_today_holiday", {
104
+ title: "Is today a holiday",
105
+ description: "Whether today is a public holiday in a country.",
106
+ inputSchema: z.object({ country: COUNTRY }),
107
+ annotations: READ_ONLY,
48
108
  }, async ({ country }) => {
49
109
  try {
50
- return text(await nextHolidays(country));
110
+ const result = await isTodayHoliday(country);
111
+ if (!result.isHoliday) {
112
+ return text(`Today is not a public holiday in ${country.toUpperCase()}.`);
113
+ }
114
+ return text(`Today is a public holiday in ${country.toUpperCase()}:\n\n${formatHoliday(result.holiday).trim()}`);
115
+ }
116
+ catch (e) {
117
+ return textError(errorMessage(e));
118
+ }
119
+ });
120
+ server.registerTool("regional_holidays", {
121
+ title: "Regional holidays",
122
+ description: "Holidays that actually apply in one subdivision (US-CA, DE-BE, CA-ON): national days plus local ones.",
123
+ inputSchema: z.object({
124
+ year: z.string().describe("Four-digit year, e.g. 2026."),
125
+ country: z.string().describe("Two-letter country code, e.g. US."),
126
+ county: z.string().describe("ISO 3166-2 subdivision code, e.g. US-CA.")
127
+ }),
128
+ annotations: READ_ONLY,
129
+ }, async ({ year, country, county }) => {
130
+ try {
131
+ return text(await regionalHolidays(year, country, county));
132
+ }
133
+ catch (e) {
134
+ return textError(errorMessage(e));
135
+ }
136
+ });
137
+ server.registerTool("holiday_on_date", {
138
+ title: "Holiday on a date",
139
+ description: "Is one specific date a public holiday? Answers yes or no, with the previous and next holiday.",
140
+ inputSchema: z.object({
141
+ date: z.string().describe("Date to check, YYYY-MM-DD."),
142
+ country: z.string().describe("Two-letter country code, e.g. US.")
143
+ }),
144
+ annotations: READ_ONLY,
145
+ }, async ({ date, country }) => {
146
+ try {
147
+ return text(await holidayOnDate(date, country));
148
+ }
149
+ catch (e) {
150
+ return textError(errorMessage(e));
151
+ }
152
+ });
153
+ server.registerTool("compare_countries", {
154
+ title: "Compare countries",
155
+ description: "Shared, and country-specific, public holidays for two countries in one year.",
156
+ inputSchema: z.object({
157
+ year: z.string().describe("Four-digit year, e.g. 2026."),
158
+ country_a: z.string().describe("First country code, e.g. US."),
159
+ country_b: z.string().describe("Second country code, e.g. GB.")
160
+ }),
161
+ annotations: READ_ONLY,
162
+ }, async ({ year, country_a: countryA, country_b: countryB }) => {
163
+ try {
164
+ return text(await compareCountries(year, countryA, countryB));
165
+ }
166
+ catch (e) {
167
+ return textError(errorMessage(e));
168
+ }
169
+ });
170
+ server.registerTool("working_day_calendar", {
171
+ title: "Working day calendar",
172
+ description: "Month-by-month working-day arithmetic for a country and year, computed from the holiday list: weekdays, weekend days, how many of those weekdays are holidays, the working-day count, and the longest unbroken run of working days in the year.",
173
+ inputSchema: z.object({ country: z.string().describe("Two-letter country code, e.g. AT."), year: z.string().describe("Four-digit year, e.g. 2026.") }),
174
+ annotations: READ_ONLY,
175
+ }, async (args) => {
176
+ // ---- paywall: working_day_calendar ----
177
+ const paywallMessage = await premiumRequired("working_day_calendar");
178
+ if (paywallMessage)
179
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
180
+ // ---- paywall: end ----
181
+ try {
182
+ return text(await workingDayCalendar(args));
183
+ }
184
+ catch (e) {
185
+ return textError(errorMessage(e));
186
+ }
187
+ });
188
+ server.registerTool("holiday_observance_audit", {
189
+ title: "Holiday observance audit",
190
+ description: "Where every holiday in a country and year actually lands: the weekday it falls on, whether it costs a working day, how many free working days follow it, which holidays are regional, which fall on a weekend and which run back to back.",
191
+ inputSchema: z.object({ country: z.string().describe("Two-letter country code, e.g. DE."), year: z.string().describe("Four-digit year, e.g. 2026.") }),
192
+ annotations: READ_ONLY,
193
+ }, async (args) => {
194
+ // ---- paywall: holiday_observance_audit ----
195
+ const paywallMessage = await premiumRequired("holiday_observance_audit");
196
+ if (paywallMessage)
197
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
198
+ // ---- paywall: end ----
199
+ try {
200
+ return text(await holidayObservanceAudit(args));
51
201
  }
52
202
  catch (e) {
53
203
  return textError(errorMessage(e));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nager-mcp",
3
- "version": "1.0.0",
4
- "description": "Nager public holidays. Keyless.",
3
+ "version": "1.0.1",
4
+ "description": "Worldwide public holidays and long weekends - no API key.",
5
5
  "type": "module",
6
6
  "mcpName": "io.github.mrfentmen/nager-mcp",
7
7
  "repository": {
@@ -22,11 +22,11 @@
22
22
  "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
23
23
  },
24
24
  "keywords": [
25
- "mcp",
26
25
  "holidays",
27
26
  "calendar",
27
+ "planning",
28
28
  "countries",
29
- "keyless"
29
+ "mcp"
30
30
  ],
31
31
  "license": "MIT",
32
32
  "dependencies": {