@onereason/svenska-dagar-mcp 0.1.5 → 0.2.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,5 +1,7 @@
1
1
  # @onereason/svenska-dagar-mcp
2
2
 
3
+ [![MCP Trust Score](https://mcptrustchecker.com/registry/onereason-svenska-dagar-mcp/badge.svg)](https://mcptrustchecker.com/registry/onereason-svenska-dagar-mcp)
4
+
3
5
  MCP server exposing the Swedish public holiday and calendar API ([sholiday.faboul.se](https://sholiday.faboul.se)) to Claude and other MCP clients.
4
6
 
5
7
  ## Installation
@@ -38,7 +40,6 @@ Returns calendar data for a single date.
38
40
  | `year` | integer | Year (1753–2100) |
39
41
  | `month` | integer | Month (1–12) |
40
42
  | `day` | integer | Day of month (1–31) |
41
- | `sanitize` | boolean | See [Security](#security) — default `true` |
42
43
 
43
44
  ### `get_swedish_month`
44
45
 
@@ -48,7 +49,6 @@ Returns calendar data for every day in a month.
48
49
  |-----------|------|-------------|
49
50
  | `year` | integer | Year (1753–2100) |
50
51
  | `month` | integer | Month (1–12) |
51
- | `sanitize` | boolean | Default `true` |
52
52
 
53
53
  ### `get_swedish_year`
54
54
 
@@ -57,7 +57,6 @@ Returns calendar data for every day in a year (~365 records). Prefer `get_swedis
57
57
  | Parameter | Type | Description |
58
58
  |-----------|------|-------------|
59
59
  | `year` | integer | Year (1753–2100) |
60
- | `sanitize` | boolean | Default `true` |
61
60
 
62
61
  ## Response fields
63
62
 
@@ -80,7 +79,8 @@ Each day record contains:
80
79
 
81
80
  ## Security
82
81
 
83
- All responses are sanitized before reaching the LLM by default:
82
+ All responses are sanitized before reaching the LLM. Sanitization cannot be
83
+ disabled through tool arguments:
84
84
 
85
85
  - **Field allowlist** — unknown fields are dropped
86
86
  - **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
@@ -88,8 +88,7 @@ All responses are sanitized before reaching the LLM by default:
88
88
  - **Enum coercion** — `"röd dag"` and `"arbetsfri dag"` are coerced to exactly `"Ja"` or `"Nej"`
89
89
  - **Hardcoded flags** — presence-only fields (`klämdagen`, `dag före arbetsfri helgdag`) are always written as `"Ja"` regardless of what the API sends
90
90
  - **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
91
-
92
- Pass `sanitize: false` to receive the raw upstream response (useful for debugging).
91
+ - **Bounded upstream data** — non-JSON responses, redirects, payloads over 256 KiB, oversized record lists, invalid day records, and sanitized output over 128 KiB are rejected
93
92
 
94
93
  ## Development
95
94
 
@@ -99,8 +98,8 @@ npm run build # compile TypeScript → dist/
99
98
  npm test # run unit + integration tests (requires network for API tests)
100
99
  ```
101
100
 
102
- **Running the server** requires Node.js ≥ 18 (native `fetch`).
103
- **Running the tests** requires Node.js ≥ 22.6 (`--experimental-strip-types`).
101
+ - **Running the server** requires a supported Node.js release ≥ 22.
102
+ - **Running the tests** requires Node.js ≥ 22.6 (`--experimental-strip-types`).
104
103
 
105
104
  ## License
106
105
 
package/dist/sanitize.js CHANGED
@@ -2,6 +2,14 @@
2
2
  // Legitimate Swedish calendar strings (day names, holiday names) are never
3
3
  // longer than this. Anything longer is almost certainly injected content.
4
4
  const MAX_STRING_LEN = 100;
5
+ export const MAX_DAY_RECORDS = 366;
6
+ export const MAX_NAME_DAYS_PER_DAY = 10;
7
+ export const MAX_SERIALIZED_BYTES = 128 * 1024;
8
+ export const RESPONSE_DAY_LIMITS = {
9
+ day: { min: 1, max: 1 },
10
+ month: { min: 0, max: 31 },
11
+ year: { min: 0, max: MAX_DAY_RECORDS },
12
+ };
5
13
  // Patterns that appear in prompt injection payloads but never in calendar data.
6
14
  const INJECTION_PATTERNS = [
7
15
  /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|context|text)/i,
@@ -34,6 +42,11 @@ function jaOrNej(raw) {
34
42
  return raw === "Ja" ? "Ja" : "Nej";
35
43
  }
36
44
  function sanitizeDay(raw) {
45
+ const rawNameDays = raw["namnsdag"];
46
+ if (Array.isArray(rawNameDays)
47
+ && rawNameDays.length > MAX_NAME_DAYS_PER_DAY) {
48
+ throw new Error("upstream response contains too many name days");
49
+ }
37
50
  const day = {
38
51
  datum: safeStr(raw["datum"]),
39
52
  veckodag: safeStr(raw["veckodag"]),
@@ -41,8 +54,8 @@ function sanitizeDay(raw) {
41
54
  "röd dag": jaOrNej(raw["röd dag"]),
42
55
  vecka: safeStr(raw["vecka"]),
43
56
  "dag i vecka": safeStr(raw["dag i vecka"]),
44
- namnsdag: Array.isArray(raw["namnsdag"])
45
- ? raw["namnsdag"]
57
+ namnsdag: Array.isArray(rawNameDays)
58
+ ? rawNameDays
46
59
  .map((n) => safeStr(n))
47
60
  .filter((n) => !n.startsWith("[redacted"))
48
61
  : [],
@@ -61,15 +74,33 @@ function sanitizeDay(raw) {
61
74
  day["dag före arbetsfri helgdag"] = "Ja";
62
75
  return day;
63
76
  }
64
- export function sanitizeCalendarResponse(raw) {
77
+ export function sanitizeCalendarResponse(raw, limits = RESPONSE_DAY_LIMITS.year) {
78
+ if (!Number.isSafeInteger(limits.min)
79
+ || !Number.isSafeInteger(limits.max)
80
+ || limits.min < 0
81
+ || limits.max < limits.min
82
+ || limits.max > MAX_DAY_RECORDS) {
83
+ throw new Error("invalid response day limits");
84
+ }
65
85
  // If the upstream returns a non-object (string, number, array at root level),
66
86
  // do NOT pass it through — return an empty safe structure instead.
67
87
  // A hijacked API could otherwise inject content via a raw string response
68
88
  // that would bypass all field-level sanitization.
69
89
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
90
+ if (limits.min > 0) {
91
+ throw new Error("upstream response contains an unexpected day count");
92
+ }
70
93
  return { version: "", startdatum: "", slutdatum: "", dagar: [] };
71
94
  }
72
95
  const obj = raw;
96
+ const rawDays = obj["dagar"];
97
+ const dayCount = Array.isArray(rawDays) ? rawDays.length : 0;
98
+ if (dayCount < limits.min) {
99
+ throw new Error("upstream response contains an unexpected day count");
100
+ }
101
+ if (dayCount > limits.max) {
102
+ throw new Error("upstream response contains too many day records");
103
+ }
73
104
  return {
74
105
  // Metadata: keep only the fields an LLM consumer actually needs.
75
106
  // Drop "cachetid" (unstable timestamp) and "uri" (mirrors our own request
@@ -77,8 +108,22 @@ export function sanitizeCalendarResponse(raw) {
77
108
  version: safeStr(obj["version"]),
78
109
  startdatum: safeStr(obj["startdatum"]),
79
110
  slutdatum: safeStr(obj["slutdatum"]),
80
- dagar: Array.isArray(obj["dagar"])
81
- ? obj["dagar"].map((d) => sanitizeDay(d))
111
+ dagar: Array.isArray(rawDays)
112
+ ? rawDays.map((day) => {
113
+ if (typeof day !== "object"
114
+ || day === null
115
+ || Array.isArray(day)) {
116
+ throw new Error("upstream response contains an invalid day record");
117
+ }
118
+ return sanitizeDay(day);
119
+ })
82
120
  : [],
83
121
  };
84
122
  }
123
+ export function serializeCalendarResponse(raw, limits = RESPONSE_DAY_LIMITS.year) {
124
+ const serialized = JSON.stringify(sanitizeCalendarResponse(raw, limits), null, 0);
125
+ if (new TextEncoder().encode(serialized).byteLength > MAX_SERIALIZED_BYTES) {
126
+ throw new Error("sanitized response is too large");
127
+ }
128
+ return serialized;
129
+ }
package/dist/server.js CHANGED
@@ -3,105 +3,23 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
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
- }
6
+ import { RESPONSE_DAY_LIMITS, serializeCalendarResponse, } from "./sanitize.js";
7
+ import { fetchCalendar } from "./upstream.js";
8
+ import { TOOL_DEFINITIONS } from "./tools.js";
43
9
  // ---------------------------------------------------------------------------
44
10
  // MCP server
45
11
  // ---------------------------------------------------------------------------
46
- const server = new Server({ name: "svenska-dagar", version: "0.1.5" }, { capabilities: { tools: {} } });
12
+ const server = new Server({ name: "svenska-dagar", version: "0.2.1" }, { capabilities: { tools: {} } });
47
13
  // Tool descriptions are static strings — no API data is embedded in them.
48
14
  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
- ],
15
+ tools: [...TOOL_DEFINITIONS],
98
16
  }));
99
17
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
100
18
  const { name, arguments: args = {} } = request.params;
101
19
  const { year, month, day } = args;
102
- const sanitize = args["sanitize"] !== false;
103
20
  try {
104
21
  let data;
22
+ let dayLimits;
105
23
  if (name === "get_swedish_day") {
106
24
  validateYear(year);
107
25
  validateMonth(month);
@@ -110,6 +28,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
110
28
  const m = month;
111
29
  const d = day;
112
30
  data = await fetchCalendar(`${y}/${String(m).padStart(2, "0")}/${String(d).padStart(2, "0")}`);
31
+ dayLimits = RESPONSE_DAY_LIMITS.day;
113
32
  }
114
33
  else if (name === "get_swedish_month") {
115
34
  validateYear(year);
@@ -117,16 +36,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
117
36
  const y = year;
118
37
  const m = month;
119
38
  data = await fetchCalendar(`${y}/${String(m).padStart(2, "0")}`);
39
+ dayLimits = RESPONSE_DAY_LIMITS.month;
120
40
  }
121
41
  else if (name === "get_swedish_year") {
122
42
  validateYear(year);
123
43
  data = await fetchCalendar(String(year));
44
+ dayLimits = RESPONSE_DAY_LIMITS.year;
124
45
  }
125
46
  else {
126
47
  throw new Error(`unknown tool: ${name}`);
127
48
  }
128
49
  return {
129
- content: [{ type: "text", text: applyPolicy(data, sanitize) }],
50
+ content: [{
51
+ type: "text",
52
+ text: serializeCalendarResponse(data, dayLimits),
53
+ }],
130
54
  };
131
55
  }
132
56
  catch (err) {
package/dist/tools.js ADDED
@@ -0,0 +1,46 @@
1
+ export const TOOL_DEFINITIONS = [
2
+ {
3
+ name: "get_swedish_day",
4
+ description: "Return Swedish calendar data for a single date: weekday name, week number, " +
5
+ "whether it is a work-free day (arbetsfri dag), a red day (röd dag / public holiday), " +
6
+ "a flag day (flaggdag), name days (namnsdag), holiday name (helgdag), " +
7
+ "holiday eve (helgdagsafton), and squeeze day (klämdagen).",
8
+ inputSchema: {
9
+ type: "object",
10
+ properties: {
11
+ year: { type: "integer", description: "Year (1753–2100)" },
12
+ month: { type: "integer", description: "Month (1–12)" },
13
+ day: { type: "integer", description: "Day of month (1–31)" },
14
+ },
15
+ required: ["year", "month", "day"],
16
+ additionalProperties: false,
17
+ },
18
+ },
19
+ {
20
+ name: "get_swedish_month",
21
+ description: "Return Swedish calendar data for every day in a calendar month. " +
22
+ "Each day entry contains the same fields as get_swedish_day.",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ year: { type: "integer", description: "Year (1753–2100)" },
27
+ month: { type: "integer", description: "Month (1–12)" },
28
+ },
29
+ required: ["year", "month"],
30
+ additionalProperties: false,
31
+ },
32
+ },
33
+ {
34
+ name: "get_swedish_year",
35
+ description: "Return Swedish calendar data for every day in a calendar year (~365 records). " +
36
+ "Prefer get_swedish_month when only a subset of the year is needed.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ year: { type: "integer", description: "Year (1753–2100)" },
41
+ },
42
+ required: ["year"],
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ ];
@@ -0,0 +1,75 @@
1
+ const BASE_URL = "https://sholiday.faboul.se/dagar/v2.1";
2
+ const TIMEOUT_MS = 10_000;
3
+ export const MAX_RESPONSE_BYTES = 256 * 1024;
4
+ export async function readJsonResponse(response) {
5
+ if (!response.ok) {
6
+ throw new Error(`upstream returned HTTP ${response.status}`);
7
+ }
8
+ const mediaType = response.headers
9
+ .get("content-type")
10
+ ?.split(";", 1)[0]
11
+ ?.trim()
12
+ .toLowerCase();
13
+ if (mediaType !== "application/json") {
14
+ throw new Error("upstream returned an unexpected content type");
15
+ }
16
+ const contentLength = response.headers.get("content-length");
17
+ if (contentLength !== null) {
18
+ const declaredLength = Number(contentLength);
19
+ if (!Number.isSafeInteger(declaredLength) || declaredLength < 0) {
20
+ throw new Error("upstream returned an invalid content length");
21
+ }
22
+ if (declaredLength > MAX_RESPONSE_BYTES) {
23
+ throw new Error("upstream response is too large");
24
+ }
25
+ }
26
+ if (response.body === null) {
27
+ throw new Error("upstream returned an empty response");
28
+ }
29
+ const reader = response.body.getReader();
30
+ const chunks = [];
31
+ let totalBytes = 0;
32
+ try {
33
+ while (true) {
34
+ const { done, value } = await reader.read();
35
+ if (done)
36
+ break;
37
+ totalBytes += value.byteLength;
38
+ if (totalBytes > MAX_RESPONSE_BYTES) {
39
+ await reader.cancel();
40
+ throw new Error("upstream response is too large");
41
+ }
42
+ chunks.push(value);
43
+ }
44
+ }
45
+ finally {
46
+ reader.releaseLock();
47
+ }
48
+ const body = new Uint8Array(totalBytes);
49
+ let offset = 0;
50
+ for (const chunk of chunks) {
51
+ body.set(chunk, offset);
52
+ offset += chunk.byteLength;
53
+ }
54
+ try {
55
+ return JSON.parse(new TextDecoder().decode(body));
56
+ }
57
+ catch {
58
+ throw new Error("upstream returned invalid JSON");
59
+ }
60
+ }
61
+ export async function fetchCalendar(path) {
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
64
+ try {
65
+ const response = await fetch(`${BASE_URL}/${path}`, {
66
+ headers: { accept: "application/json" },
67
+ redirect: "error",
68
+ signal: controller.signal,
69
+ });
70
+ return await readJsonResponse(response);
71
+ }
72
+ finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onereason/svenska-dagar-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server for the Swedish public holiday and calendar API (sholiday.faboul.se)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -18,7 +18,7 @@
18
18
  "svenska-dagar-mcp": "dist/server.js"
19
19
  },
20
20
  "engines": {
21
- "node": ">=18"
21
+ "node": ">=22"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -32,13 +32,13 @@
32
32
  "build": "tsc && chmod 755 dist/server.js",
33
33
  "prepublishOnly": "npm run build",
34
34
  "start": "node dist/server.js",
35
- "test": "node --experimental-strip-types --test test/unit.test.ts test/sanitize.test.ts test/api.test.ts"
35
+ "test": "node --experimental-strip-types --test test/unit.test.ts test/sanitize.test.ts test/security-boundary.test.ts test/api.test.ts"
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "1.30.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@types/node": "25.9.1",
42
- "typescript": "6.0.3"
41
+ "@types/node": "22.20.3",
42
+ "typescript": "7.0.2"
43
43
  }
44
44
  }