@onereason/svenska-dagar-mcp 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Balazs Suhajda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @onereason/svenska-dagar-mcp
2
+
3
+ MCP server exposing the Swedish public holiday and calendar API ([sholiday.faboul.se](https://sholiday.faboul.se)) to Claude and other MCP clients.
4
+
5
+ ## Installation
6
+
7
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "svenska-dagar": {
13
+ "command": "npx",
14
+ "args": ["-y", "@onereason/svenska-dagar-mcp"]
15
+ }
16
+ }
17
+ }
18
+ ```
19
+
20
+ Restart Claude Desktop. The three tools below are then available in every conversation.
21
+
22
+ ## Tools
23
+
24
+ ### `get_swedish_day`
25
+
26
+ Returns calendar data for a single date.
27
+
28
+ | Parameter | Type | Description |
29
+ |-----------|------|-------------|
30
+ | `year` | integer | Year (1753–2100) |
31
+ | `month` | integer | Month (1–12) |
32
+ | `day` | integer | Day of month (1–31) |
33
+ | `sanitize` | boolean | See [Security](#security) — default `true` |
34
+
35
+ ### `get_swedish_month`
36
+
37
+ Returns calendar data for every day in a month.
38
+
39
+ | Parameter | Type | Description |
40
+ |-----------|------|-------------|
41
+ | `year` | integer | Year (1753–2100) |
42
+ | `month` | integer | Month (1–12) |
43
+ | `sanitize` | boolean | Default `true` |
44
+
45
+ ### `get_swedish_year`
46
+
47
+ Returns calendar data for every day in a year (~365 records). Prefer `get_swedish_month` when only a subset is needed.
48
+
49
+ | Parameter | Type | Description |
50
+ |-----------|------|-------------|
51
+ | `year` | integer | Year (1753–2100) |
52
+ | `sanitize` | boolean | Default `true` |
53
+
54
+ ## Response fields
55
+
56
+ Each day record contains:
57
+
58
+ | Field | Type | Description |
59
+ |-------|------|-------------|
60
+ | `datum` | string | Date (`YYYY-MM-DD`) |
61
+ | `veckodag` | string | Weekday name in Swedish |
62
+ | `vecka` | string | ISO week number |
63
+ | `dag i vecka` | string | Day of week (1 = Monday) |
64
+ | `arbetsfri dag` | `"Ja"` \| `"Nej"` | Work-free day |
65
+ | `röd dag` | `"Ja"` \| `"Nej"` | Public holiday (red day) |
66
+ | `namnsdag` | string[] | Name days (namnsdagar) |
67
+ | `flaggdag` | string | Flag day name, or `""` |
68
+ | `helgdag` | string | Holiday or named eve (e.g. `"Juldagen"`, `"Nyårsafton"`) — present only when applicable |
69
+ | `helgdagsafton` | string | Eve before a red day (e.g. `"Pingstafton"`) — present only when applicable |
70
+ | `klämdagen` | `"Ja"` | Squeeze day (klämdagen) — present only when applicable |
71
+ | `dag före arbetsfri helgdag` | `"Ja"` | Day before a work-free holiday — present only when applicable |
72
+
73
+ ## Security
74
+
75
+ All responses are sanitized before reaching the LLM by default:
76
+
77
+ - **Field allowlist** — unknown fields are dropped
78
+ - **Injection scan** — string values are checked against known prompt-injection patterns (ChatML tokens, role prefixes, markdown headers, etc.) and replaced with `[redacted: suspicious content]` if matched
79
+ - **Length cap** — strings longer than 100 characters are redacted
80
+ - **Enum coercion** — `"röd dag"` and `"arbetsfri dag"` are coerced to exactly `"Ja"` or `"Nej"`
81
+ - **Hardcoded flags** — presence-only fields (`klämdagen`, `dag före arbetsfri helgdag`) are always written as `"Ja"` regardless of what the API sends
82
+ - **Non-object responses** — if the upstream returns a string, array, or any non-object, an empty safe structure is returned instead of passing through raw content
83
+
84
+ Pass `sanitize: false` to receive the raw upstream response (useful for debugging).
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ npm install # install dependencies
90
+ npm run build # compile TypeScript → dist/
91
+ npm test # run unit + integration tests (requires network for API tests)
92
+ ```
93
+
94
+ **Running the server** requires Node.js ≥ 18 (native `fetch`).
95
+ **Running the tests** requires Node.js ≥ 22.6 (`--experimental-strip-types`).
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,84 @@
1
+ // Max length for any single string value from the API.
2
+ // Legitimate Swedish calendar strings (day names, holiday names) are never
3
+ // longer than this. Anything longer is almost certainly injected content.
4
+ const MAX_STRING_LEN = 100;
5
+ // Patterns that appear in prompt injection payloads but never in calendar data.
6
+ const INJECTION_PATTERNS = [
7
+ /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|context|text)/i,
8
+ /\bsystem\s*:/i,
9
+ /\bassistant\s*:/i,
10
+ /\bhuman\s*:/i,
11
+ /\buser\s*:/i,
12
+ /<\|im_(start|end|sep)\|>/,
13
+ /\[INST\]|\[\/INST\]/i,
14
+ /<<SYS>>|<\/SYS>/i,
15
+ /^#{1,6}\s/m,
16
+ /```/,
17
+ /<\s*(script|system|prompt|instruction|command)\b/i,
18
+ ];
19
+ export function isSuspicious(value) {
20
+ if (value.length > MAX_STRING_LEN)
21
+ return true;
22
+ return INJECTION_PATTERNS.some((re) => re.test(value));
23
+ }
24
+ function safeStr(raw) {
25
+ if (typeof raw !== "string")
26
+ return "[redacted: unexpected type]";
27
+ if (isSuspicious(raw))
28
+ return "[redacted: suspicious content]";
29
+ return raw;
30
+ }
31
+ // For "Ja"/"Nej" fields: only allow the two known values, never pass through
32
+ // arbitrary strings (an attacker could otherwise use these as injection vectors).
33
+ function jaOrNej(raw) {
34
+ return raw === "Ja" ? "Ja" : "Nej";
35
+ }
36
+ function sanitizeDay(raw) {
37
+ const day = {
38
+ datum: safeStr(raw["datum"]),
39
+ veckodag: safeStr(raw["veckodag"]),
40
+ "arbetsfri dag": jaOrNej(raw["arbetsfri dag"]),
41
+ "röd dag": jaOrNej(raw["röd dag"]),
42
+ vecka: safeStr(raw["vecka"]),
43
+ "dag i vecka": safeStr(raw["dag i vecka"]),
44
+ namnsdag: Array.isArray(raw["namnsdag"])
45
+ ? raw["namnsdag"]
46
+ .map((n) => safeStr(n))
47
+ .filter((n) => !n.startsWith("[redacted"))
48
+ : [],
49
+ flaggdag: safeStr(raw["flaggdag"] ?? ""),
50
+ };
51
+ // Optional text fields — present only when applicable.
52
+ if ("helgdag" in raw)
53
+ day["helgdag"] = safeStr(raw["helgdag"]);
54
+ if ("helgdagsafton" in raw)
55
+ day["helgdagsafton"] = safeStr(raw["helgdagsafton"]);
56
+ // Presence-only boolean flags — if they appear at all the value is always
57
+ // "Ja", so we hardcode it rather than forwarding whatever the API sent.
58
+ if ("klämdagen" in raw)
59
+ day["klämdagen"] = "Ja";
60
+ if ("dag före arbetsfri helgdag" in raw)
61
+ day["dag före arbetsfri helgdag"] = "Ja";
62
+ return day;
63
+ }
64
+ export function sanitizeCalendarResponse(raw) {
65
+ // If the upstream returns a non-object (string, number, array at root level),
66
+ // do NOT pass it through — return an empty safe structure instead.
67
+ // A hijacked API could otherwise inject content via a raw string response
68
+ // that would bypass all field-level sanitization.
69
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
70
+ return { version: "", startdatum: "", slutdatum: "", dagar: [] };
71
+ }
72
+ const obj = raw;
73
+ return {
74
+ // Metadata: keep only the fields an LLM consumer actually needs.
75
+ // Drop "cachetid" (unstable timestamp) and "uri" (mirrors our own request
76
+ // and could be used as an injection surface if the host is compromised).
77
+ version: safeStr(obj["version"]),
78
+ startdatum: safeStr(obj["startdatum"]),
79
+ slutdatum: safeStr(obj["slutdatum"]),
80
+ dagar: Array.isArray(obj["dagar"])
81
+ ? obj["dagar"].map((d) => sanitizeDay(d))
82
+ : [],
83
+ };
84
+ }
package/dist/server.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { validateYear, validateMonth, validateDay } from "./validate.js";
6
+ import { sanitizeCalendarResponse } from "./sanitize.js";
7
+ // Hardcoded HTTPS base — callers cannot override the scheme or host.
8
+ const BASE_URL = "https://sholiday.faboul.se/dagar/v2.1";
9
+ const TIMEOUT_MS = 10_000;
10
+ // Shared input schema fragment added to every tool.
11
+ const SANITIZE_SCHEMA = {
12
+ sanitize: {
13
+ type: "boolean",
14
+ description: "When true (default), API response values are validated against known " +
15
+ "formats, unknown fields are dropped, and strings are scanned for prompt " +
16
+ "injection patterns before being returned. Set to false only if you need " +
17
+ "the raw upstream response.",
18
+ },
19
+ };
20
+ // ---------------------------------------------------------------------------
21
+ // HTTP helper — path is always built from validated integers only.
22
+ // No JSONP callback parameter is ever used.
23
+ // ---------------------------------------------------------------------------
24
+ async function fetchCalendar(path) {
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
27
+ try {
28
+ const response = await fetch(`${BASE_URL}/${path}`, {
29
+ signal: controller.signal,
30
+ });
31
+ if (!response.ok) {
32
+ throw new Error(`upstream returned HTTP ${response.status}`);
33
+ }
34
+ return await response.json();
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ function applyPolicy(data, sanitize) {
41
+ return JSON.stringify(sanitize ? sanitizeCalendarResponse(data) : data, null, 0);
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // MCP server
45
+ // ---------------------------------------------------------------------------
46
+ const server = new Server({ name: "svenska-dagar", version: "0.1.0" }, { capabilities: { tools: {} } });
47
+ // Tool descriptions are static strings — no API data is embedded in them.
48
+ server.setRequestHandler(ListToolsRequestSchema, () => ({
49
+ tools: [
50
+ {
51
+ name: "get_swedish_day",
52
+ description: "Return Swedish calendar data for a single date: weekday name, week number, " +
53
+ "whether it is a work-free day (arbetsfri dag), a red day (röd dag / public holiday), " +
54
+ "a flag day (flaggdag), name days (namnsdag), holiday name (helgdag), " +
55
+ "holiday eve (helgdagsafton), and squeeze day (klämdagen).",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ year: { type: "integer", description: "Year (1753–2100)" },
60
+ month: { type: "integer", description: "Month (1–12)" },
61
+ day: { type: "integer", description: "Day of month (1–31)" },
62
+ ...SANITIZE_SCHEMA,
63
+ },
64
+ required: ["year", "month", "day"],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ {
69
+ name: "get_swedish_month",
70
+ description: "Return Swedish calendar data for every day in a calendar month. " +
71
+ "Each day entry contains the same fields as get_swedish_day.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ year: { type: "integer", description: "Year (1753–2100)" },
76
+ month: { type: "integer", description: "Month (1–12)" },
77
+ ...SANITIZE_SCHEMA,
78
+ },
79
+ required: ["year", "month"],
80
+ additionalProperties: false,
81
+ },
82
+ },
83
+ {
84
+ name: "get_swedish_year",
85
+ description: "Return Swedish calendar data for every day in a calendar year (~365 records). " +
86
+ "Prefer get_swedish_month when only a subset of the year is needed.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ year: { type: "integer", description: "Year (1753–2100)" },
91
+ ...SANITIZE_SCHEMA,
92
+ },
93
+ required: ["year"],
94
+ additionalProperties: false,
95
+ },
96
+ },
97
+ ],
98
+ }));
99
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
100
+ const { name, arguments: args = {} } = request.params;
101
+ const { year, month, day } = args;
102
+ const sanitize = args["sanitize"] !== false;
103
+ try {
104
+ let data;
105
+ if (name === "get_swedish_day") {
106
+ validateYear(year);
107
+ validateMonth(month);
108
+ validateDay(day);
109
+ const y = year;
110
+ const m = month;
111
+ const d = day;
112
+ data = await fetchCalendar(`${y}/${String(m).padStart(2, "0")}/${String(d).padStart(2, "0")}`);
113
+ }
114
+ else if (name === "get_swedish_month") {
115
+ validateYear(year);
116
+ validateMonth(month);
117
+ const y = year;
118
+ const m = month;
119
+ data = await fetchCalendar(`${y}/${String(m).padStart(2, "0")}`);
120
+ }
121
+ else if (name === "get_swedish_year") {
122
+ validateYear(year);
123
+ data = await fetchCalendar(String(year));
124
+ }
125
+ else {
126
+ throw new Error(`unknown tool: ${name}`);
127
+ }
128
+ return {
129
+ content: [{ type: "text", text: applyPolicy(data, sanitize) }],
130
+ };
131
+ }
132
+ catch (err) {
133
+ const message = err instanceof Error ? err.message : String(err);
134
+ return {
135
+ content: [{ type: "text", text: `Error: ${message}` }],
136
+ isError: true,
137
+ };
138
+ }
139
+ });
140
+ const transport = new StdioServerTransport();
141
+ await server.connect(transport);
@@ -0,0 +1,15 @@
1
+ export function validateYear(year) {
2
+ if (!Number.isInteger(year) || year < 1753 || year > 2100) {
3
+ throw new Error(`year must be an integer between 1753 and 2100, got ${year}`);
4
+ }
5
+ }
6
+ export function validateMonth(month) {
7
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
8
+ throw new Error(`month must be an integer between 1 and 12, got ${month}`);
9
+ }
10
+ }
11
+ export function validateDay(day) {
12
+ if (!Number.isInteger(day) || day < 1 || day > 31) {
13
+ throw new Error(`day must be an integer between 1 and 31, got ${day}`);
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@onereason/svenska-dagar-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the Swedish public holiday and calendar API (sholiday.faboul.se)",
5
+ "keywords": ["mcp", "swedish", "calendar", "holidays", "namnsdagar"],
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "files": ["dist"],
9
+ "bin": {
10
+ "svenska-dagar-mcp": "./dist/server.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/suhajdab/svenska-dagar-mcp.git"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "prepublishOnly": "npm run build",
25
+ "start": "node dist/server.js",
26
+ "test": "node --experimental-strip-types --test test/unit.test.ts test/sanitize.test.ts test/api.test.ts"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "1.29.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "22.15.21",
33
+ "typescript": "5.8.3"
34
+ }
35
+ }