enrico-mcp 1.0.0 → 1.0.2

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
@@ -19,3 +19,21 @@ node dist/index.js
19
19
  ```
20
20
 
21
21
  Data comes from the public Enrico API.
22
+
23
+ <!-- paywall -->
24
+ ## Premium tools
25
+
26
+ These tools need a license key:
27
+
28
+ * `country_calendar`
29
+
30
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-enrico-mcp and set it in your MCP client config:
31
+
32
+ ```json
33
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
34
+ ```
35
+
36
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
37
+ working offline once one check has succeeded. Every other tool on this server
38
+ stays free.
39
+ <!-- /paywall -->
package/dist/api.js CHANGED
@@ -29,3 +29,152 @@ export async function holidays(args) {
29
29
  return `* ${date}: ${h.name?.[0]?.text ?? '?'}`;
30
30
  }).join('\n');
31
31
  }
32
+ // ---- muxE tools:
33
+ // The two existing tools print a country's code and name, or one year of its
34
+ // public holidays. The new ones use the parts of the payload neither reads: the
35
+ // coverage window and holiday type list every country record carries, and the
36
+ // non public holiday types the service also serves per country.
37
+ const ENRICO = "https://kayaposoft.com/enrico/json/v2.0";
38
+ const ENRICO_UA = "mrfentmen-enrico-mcp/1.0";
39
+ async function enricoJson(params) {
40
+ const qs = new URLSearchParams(params).toString();
41
+ const res = await fetch(`${ENRICO}/?${qs}`, {
42
+ headers: { "User-Agent": ENRICO_UA, Accept: "application/json" },
43
+ signal: AbortSignal.timeout(30000),
44
+ });
45
+ if (!res.ok)
46
+ throw new Error(`Enrico returned ${res.status}`);
47
+ const raw = await res.text();
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch {
53
+ throw new Error("Enrico returned a body that is not JSON.");
54
+ }
55
+ if (parsed && typeof parsed.error === "string")
56
+ throw new Error(`Enrico said: ${parsed.error}`);
57
+ return parsed;
58
+ }
59
+ const isoDate = (d) => {
60
+ if (!d || typeof d.year !== "number")
61
+ return "not reported";
62
+ // 52 of the 60 country records carry 32767 as a placeholder end year, verified
63
+ // live, so a real date is not printed for it
64
+ if (d.year >= 30000)
65
+ return "open ended, the record carries a placeholder year";
66
+ return `${d.year}-${String(d.month ?? 1).padStart(2, "0")}-${String(d.day ?? 1).padStart(2, "0")}`;
67
+ };
68
+ /** the name array is localised, so pick English when the service offers it */
69
+ const localised = (names) => {
70
+ if (!Array.isArray(names) || !names.length)
71
+ return "name not reported";
72
+ const english = names.find((n) => String(n?.lang ?? "").toLowerCase().startsWith("en"));
73
+ if (english?.text)
74
+ return english.text;
75
+ const first = names.find((n) => n?.text);
76
+ const others = names.length - 1;
77
+ return `${first?.text ?? "name not reported"}${others > 0 ? ` (only ${names.length} translation${names.length === 1 ? "" : "s"}; ${names.map((n) => n?.lang ?? "?").join(", ")})` : ""}`;
78
+ };
79
+ const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
80
+ export async function countryCoverage(_args = {}) {
81
+ const list = await enricoJson({ action: "getSupportedCountries" });
82
+ const countries = Array.isArray(list) ? list : [];
83
+ if (!countries.length)
84
+ throw new Error("Enrico returned no countries.");
85
+ const typeCount = new Map();
86
+ for (const c of countries) {
87
+ for (const t of c.holidayTypes ?? [])
88
+ typeCount.set(t, (typeCount.get(t) ?? 0) + 1);
89
+ }
90
+ const fromYears = countries
91
+ .map((c) => c.fromDate?.year)
92
+ .filter((y) => typeof y === "number");
93
+ const bounded = countries.filter((c) => typeof c.toDate?.year === "number" && c.toDate.year < 30000);
94
+ const withRegions = countries.filter((c) => Array.isArray(c.regions) && c.regions.length > 0);
95
+ const lines = [
96
+ `What the Enrico holiday service actually covers, across its ${countries.length} countries:`,
97
+ ` every country offers: ${[...typeCount.entries()].filter(([, n]) => n === countries.length).map(([t]) => t).join(", ") || "none of them"}`,
98
+ ` distinct holiday types in the catalogue: ${typeCount.size}`,
99
+ ];
100
+ for (const [type, n] of [...typeCount.entries()].sort((a, b) => b[1] - a[1])) {
101
+ const names = countries.filter((c) => (c.holidayTypes ?? []).includes(type)).map((c) => (c.countryCode ?? "?").toUpperCase());
102
+ const shown = names.slice(0, 12).join(", ");
103
+ lines.push(` ${type}: ${n} of ${countries.length} countries${names.length ? ` (${shown}${names.length > 12 ? `, and ${names.length - 12} more` : ""})` : ""}`);
104
+ }
105
+ lines.push(` countries that publish a start year: ${fromYears.length}, earliest ${fromYears.length ? Math.min(...fromYears) : "not reported"}, latest ${fromYears.length ? Math.max(...fromYears) : "not reported"}`);
106
+ lines.push(` countries whose records stop: ${bounded.length ? `${bounded.length} name a real end year (${[...new Set(bounded.map((c) => c.toDate?.year))].sort((a, b) => (a ?? 0) - (b ?? 0)).join(", ")}), the other ${countries.length - bounded.length} carry a placeholder end year` : `none name a real end year, all ${countries.length} carry a placeholder`}`);
107
+ lines.push(` countries split into regions: ${withRegions.length}`);
108
+ for (const c of withRegions.slice(0, 8)) {
109
+ lines.push(` ${(c.countryCode ?? "?").toUpperCase()} ${c.fullName ?? ""}: ${(c.regions ?? []).length} region code(s) - ${(c.regions ?? []).join(", ")}`);
110
+ }
111
+ if (withRegions.length > 8)
112
+ lines.push(` and ${withRegions.length - 8} more region-split countries`);
113
+ const longest = [...countries].sort((a, b) => (b.holidayTypes ?? []).length - (a.holidayTypes ?? []).length)[0];
114
+ lines.push(` richest country record: ${(longest.countryCode ?? "?").toUpperCase()} ${longest.fullName ?? ""} with ${(longest.holidayTypes ?? []).length} holiday types, covering ${isoDate(longest.fromDate)} to ${isoDate(longest.toDate)}`);
115
+ lines.push(" the countries tool prints only the code and the name and stops at 30 of them, so none of this is visible there.");
116
+ return lines.join("\n");
117
+ }
118
+ export async function countryCalendar(args) {
119
+ const raw = (args?.country ?? "").trim().toLowerCase();
120
+ if (!raw)
121
+ throw new Error("Provide a country code, like USA or DEU.");
122
+ if (!/^[a-z]{3}$/.test(raw))
123
+ throw new Error("Enrico country codes are three letters, like USA or DEU.");
124
+ const year = Math.floor(Number(args?.year));
125
+ if (!Number.isInteger(year) || year < 1940 || year > 2100) {
126
+ throw new Error("Provide a year between 1940 and 2100.");
127
+ }
128
+ const list = await enricoJson({ action: "getSupportedCountries" });
129
+ const countries = Array.isArray(list) ? list : [];
130
+ const record = countries.find((c) => String(c.countryCode ?? "").toLowerCase() === raw);
131
+ if (!record) {
132
+ const near = countries.map((c) => String(c.countryCode ?? "").toUpperCase()).filter((c) => c.includes(raw.toUpperCase())).slice(0, 8);
133
+ throw new Error(near.length
134
+ ? `Enrico has no country "${raw.toUpperCase()}". Close by: ${near.join(", ")}.`
135
+ : `Enrico has no country "${raw.toUpperCase()}".`);
136
+ }
137
+ const code = String(record.countryCode ?? raw).toUpperCase();
138
+ const types = Array.isArray(record.holidayTypes) && record.holidayTypes.length ? record.holidayTypes : ["public_holiday"];
139
+ const fromYear = typeof record.fromDate?.year === "number" ? record.fromDate.year : null;
140
+ const toYear = typeof record.toDate?.year === "number" && record.toDate.year < 30000 ? record.toDate.year : null;
141
+ const inWindow = (fromYear === null || year >= fromYear) && (toYear === null || year <= toYear);
142
+ if (!inWindow) {
143
+ return [
144
+ `Enrico holds no record for ${code} ${record.fullName ?? ""} in ${year}.`,
145
+ ` its coverage window is ${isoDate(record.fromDate)} to ${isoDate(record.toDate)}, and ${year} sits outside it.`,
146
+ ` regions it is split into: ${(record.regions ?? []).length ? (record.regions ?? []).join(", ") : "none"}`,
147
+ " no figures are guessed for a year the service does not cover.",
148
+ ].join("\n");
149
+ }
150
+ const lines = [
151
+ `Every holiday Enrico publishes for ${code} ${record.fullName ?? ""} in ${year}:`,
152
+ ` coverage window: ${isoDate(record.fromDate)} to ${isoDate(record.toDate)}, and ${year} is inside it`,
153
+ ` regions the service splits this country into: ${(record.regions ?? []).length ? (record.regions ?? []).join(", ") : "none"}`,
154
+ ` holiday types offered: ${types.join(", ")}`,
155
+ ];
156
+ const seen = new Set();
157
+ for (const type of types) {
158
+ const rows = await enricoJson({
159
+ action: "getHolidaysForYear",
160
+ year: String(year),
161
+ country: raw,
162
+ holidayType: type,
163
+ });
164
+ const holidays = Array.isArray(rows) ? rows : [];
165
+ lines.push(` ${type}: ${holidays.length} record(s)`);
166
+ for (const h of holidays.slice(0, 20)) {
167
+ const day = h.date?.dayOfWeek;
168
+ const stamp = h.date ? `${String(h.date.year ?? year)}-${String(h.date.month ?? 1).padStart(2, "0")}-${String(h.date.day ?? 1).padStart(2, "0")}` : "date not reported";
169
+ const weekday = typeof day === "number" && day >= 1 && day <= 7 ? ` (${WEEKDAYS[day - 1]})` : "";
170
+ const line = ` ${stamp}${weekday}: ${localised(h.name)}`;
171
+ lines.push(line);
172
+ seen.add(line);
173
+ }
174
+ if (holidays.length > 20)
175
+ lines.push(` and ${holidays.length - 20} more ${type} record(s) not listed here`);
176
+ }
177
+ lines.push(` ${seen.size} distinct dated record(s) across all ${types.length} type(s).`);
178
+ lines.push(" the holidays tool reads only the public_holiday type and only the first name in each record, so a country whose first name is not English comes back in its own language there.");
179
+ return lines.join("\n");
180
+ }
@@ -0,0 +1,78 @@
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 = "enrico-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "country_calendar",
8
+ ]);
9
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
10
+ // The license check calls the marketplace's verify endpoint directly instead of
11
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
12
+ // check it makes is rejected by Supabase before it reaches the function. The
13
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
14
+ // own JavaScript and only ever reaches their licence check.
15
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
16
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
17
+ const OK_CACHE_MS = 60 * 60 * 1000;
18
+ const BAD_CACHE_MS = 60 * 1000;
19
+ const seen = new Map();
20
+ const REASONS = {
21
+ missing_key: "no key is set",
22
+ invalid_format: "that key is not a valid MCP Marketplace key",
23
+ not_found: "that key was not found",
24
+ revoked: "that key has been revoked",
25
+ rotated: "that key was rotated, use your new one",
26
+ expired: "that key has expired, renew it",
27
+ rate_limited: "there were too many checks just now, try again shortly",
28
+ network_error: "the license server could not be reached",
29
+ };
30
+ function blocked(tool, reason) {
31
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
32
+ return `"${tool}" needs a license key, but ${why}. ` +
33
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
34
+ }
35
+ async function verify(key) {
36
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
37
+ method: "POST",
38
+ headers: {
39
+ apikey: PUBLISHABLE_KEY,
40
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
41
+ "Content-Type": "application/json",
42
+ },
43
+ body: JSON.stringify({ key, slug: SLUG }),
44
+ // an unusable key answers 400 with a JSON body, so read the body either way
45
+ signal: AbortSignal.timeout(15000),
46
+ });
47
+ const data = (await res.json());
48
+ if (typeof data?.valid !== "boolean")
49
+ return { valid: false, reason: "unexpected_response" };
50
+ return data;
51
+ }
52
+ /**
53
+ * Returns null when the caller may run the tool, or a short message telling
54
+ * them how to get a key.
55
+ *
56
+ * A good key is remembered for an hour, so a busy session does not hit the
57
+ * license server on every call and keeps working through a brief outage.
58
+ */
59
+ export async function premiumRequired(tool) {
60
+ if (!PREMIUM.has(tool))
61
+ return null;
62
+ const key = process.env.MCP_LICENSE_KEY;
63
+ if (!key)
64
+ return blocked(tool, "missing_key");
65
+ const hit = seen.get(key);
66
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
67
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
68
+ }
69
+ let result;
70
+ try {
71
+ result = await verify(key);
72
+ }
73
+ catch {
74
+ result = { valid: false, reason: "network_error" };
75
+ }
76
+ seen.set(key, { ...result, at: Date.now() });
77
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
78
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { countries } from "./api.js";
4
- import { holidays } from "./api.js";
4
+ import { holidays, countryCoverage, countryCalendar } from "./api.js";
5
+ import { premiumRequired } from "./license.js";
5
6
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
7
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
8
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -34,5 +35,36 @@ export function createServer() {
34
35
  return textError(error(e));
35
36
  }
36
37
  });
38
+ server.registerTool("country_coverage", {
39
+ title: "Country coverage",
40
+ description: "What the Enrico holiday service actually covers across all its countries: every holiday type in the catalogue and how many countries offer each, the earliest year any country starts at, which countries name a real end year rather than a placeholder, and which countries are split into regions. The countries tool prints only a code and a name and stops at 30 of them.",
41
+ inputSchema: z.object({}),
42
+ annotations: READ_ONLY,
43
+ }, async (args) => {
44
+ try {
45
+ return text(await countryCoverage());
46
+ }
47
+ catch (e) {
48
+ return textError(error(e));
49
+ }
50
+ });
51
+ server.registerTool("country_calendar", {
52
+ title: "Country calendar",
53
+ description: "Every holiday type Enrico serves for one country in one year, not just public holidays: observances, other days, extra working days, school holidays and postal holidays, with the English name where the record carries one, the weekday, the country's real coverage window and its region codes. The holidays tool reads a single type and takes only the first name in each record.",
54
+ inputSchema: z.object({ country: z.string().describe("Three letter country code like USA or DEU."), year: z.number().describe("Year, 1940 to 2100.") }),
55
+ annotations: READ_ONLY,
56
+ }, async (args) => {
57
+ // ---- paywall: country_calendar ----
58
+ const paywallMessage = await premiumRequired("country_calendar");
59
+ if (paywallMessage)
60
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
61
+ // ---- paywall: end ----
62
+ try {
63
+ return text(await countryCalendar(args));
64
+ }
65
+ catch (e) {
66
+ return textError(error(e));
67
+ }
68
+ });
37
69
  return server;
38
70
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0",
2
+ "version": "1.0.2",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/mrfentmen/enrico-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.0",
9
+ "version": "1.0.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "enrico-mcp",
14
- "version": "1.0.0",
14
+ "version": "1.0.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }