@onereason/svenska-dagar-mcp 0.2.0 → 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
@@ -86,7 +88,7 @@ disabled through tool arguments:
86
88
  - **Enum coercion** — `"röd dag"` and `"arbetsfri dag"` are coerced to exactly `"Ja"` or `"Nej"`
87
89
  - **Hardcoded flags** — presence-only fields (`klämdagen`, `dag före arbetsfri helgdag`) are always written as `"Ja"` regardless of what the API sends
88
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
89
- - **Bounded upstream reads** — non-JSON responses, redirects, and responses over 1 MB are rejected
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
90
92
 
91
93
  ## Development
92
94
 
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,11 +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
  }
85
- export function serializeCalendarResponse(raw) {
86
- return JSON.stringify(sanitizeCalendarResponse(raw), null, 0);
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;
87
129
  }
package/dist/server.js CHANGED
@@ -3,13 +3,13 @@ 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 { serializeCalendarResponse } from "./sanitize.js";
6
+ import { RESPONSE_DAY_LIMITS, serializeCalendarResponse, } from "./sanitize.js";
7
7
  import { fetchCalendar } from "./upstream.js";
8
8
  import { TOOL_DEFINITIONS } from "./tools.js";
9
9
  // ---------------------------------------------------------------------------
10
10
  // MCP server
11
11
  // ---------------------------------------------------------------------------
12
- const server = new Server({ name: "svenska-dagar", version: "0.2.0" }, { capabilities: { tools: {} } });
12
+ const server = new Server({ name: "svenska-dagar", version: "0.2.1" }, { capabilities: { tools: {} } });
13
13
  // Tool descriptions are static strings — no API data is embedded in them.
14
14
  server.setRequestHandler(ListToolsRequestSchema, () => ({
15
15
  tools: [...TOOL_DEFINITIONS],
@@ -19,6 +19,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
19
19
  const { year, month, day } = args;
20
20
  try {
21
21
  let data;
22
+ let dayLimits;
22
23
  if (name === "get_swedish_day") {
23
24
  validateYear(year);
24
25
  validateMonth(month);
@@ -27,6 +28,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
27
28
  const m = month;
28
29
  const d = day;
29
30
  data = await fetchCalendar(`${y}/${String(m).padStart(2, "0")}/${String(d).padStart(2, "0")}`);
31
+ dayLimits = RESPONSE_DAY_LIMITS.day;
30
32
  }
31
33
  else if (name === "get_swedish_month") {
32
34
  validateYear(year);
@@ -34,16 +36,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
34
36
  const y = year;
35
37
  const m = month;
36
38
  data = await fetchCalendar(`${y}/${String(m).padStart(2, "0")}`);
39
+ dayLimits = RESPONSE_DAY_LIMITS.month;
37
40
  }
38
41
  else if (name === "get_swedish_year") {
39
42
  validateYear(year);
40
43
  data = await fetchCalendar(String(year));
44
+ dayLimits = RESPONSE_DAY_LIMITS.year;
41
45
  }
42
46
  else {
43
47
  throw new Error(`unknown tool: ${name}`);
44
48
  }
45
49
  return {
46
- content: [{ type: "text", text: serializeCalendarResponse(data) }],
50
+ content: [{
51
+ type: "text",
52
+ text: serializeCalendarResponse(data, dayLimits),
53
+ }],
47
54
  };
48
55
  }
49
56
  catch (err) {
package/dist/upstream.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const BASE_URL = "https://sholiday.faboul.se/dagar/v2.1";
2
2
  const TIMEOUT_MS = 10_000;
3
- export const MAX_RESPONSE_BYTES = 1_000_000;
3
+ export const MAX_RESPONSE_BYTES = 256 * 1024;
4
4
  export async function readJsonResponse(response) {
5
5
  if (!response.ok) {
6
6
  throw new Error(`upstream returned HTTP ${response.status}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onereason/svenska-dagar-mcp",
3
- "version": "0.2.0",
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",
@@ -39,6 +39,6 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "22.20.3",
42
- "typescript": "6.0.3"
42
+ "typescript": "7.0.2"
43
43
  }
44
44
  }