@onereason/svenska-dagar-mcp 0.2.0 → 0.2.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
@@ -86,16 +86,23 @@ disabled through tool arguments:
86
86
  - **Enum coercion** — `"röd dag"` and `"arbetsfri dag"` are coerced to exactly `"Ja"` or `"Nej"`
87
87
  - **Hardcoded flags** — presence-only fields (`klämdagen`, `dag före arbetsfri helgdag`) are always written as `"Ja"` regardless of what the API sends
88
88
  - **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
89
+ - **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
90
 
91
91
  ## Development
92
92
 
93
93
  ```bash
94
94
  npm install # install dependencies
95
95
  npm run build # compile TypeScript → dist/
96
- npm test # run unit + integration tests (requires network for API tests)
96
+ npm test # run network-independent unit and security-boundary tests
97
+ npm run test:api # run live integration tests (requires access to the upstream API)
97
98
  ```
98
99
 
100
+ Required CI runs the network-independent tests, build, dependency audit, and
101
+ signature verification on Node.js 22 and 24. Live integration tests run separately
102
+ through the manually triggered **Live API tests** workflow in GitHub Actions.
103
+ Upstream outages or access restrictions still fail live tests, but do not block
104
+ pull requests.
105
+
99
106
  - **Running the server** requires a supported Node.js release ≥ 22.
100
107
  - **Running the tests** requires Node.js ≥ 22.6 (`--experimental-strip-types`).
101
108
 
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.2",
4
4
  "description": "MCP server for the Swedish public holiday and calendar API (sholiday.faboul.se)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -32,13 +32,14 @@
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/security-boundary.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",
36
+ "test:api": "node --experimental-strip-types --test test/api.test.ts"
36
37
  },
37
38
  "dependencies": {
38
- "@modelcontextprotocol/sdk": "1.30.0"
39
+ "@modelcontextprotocol/sdk": "1.32.2"
39
40
  },
40
41
  "devDependencies": {
41
- "@types/node": "22.20.3",
42
- "typescript": "6.0.3"
42
+ "@types/node": "22.20.4",
43
+ "typescript": "7.0.2"
43
44
  }
44
45
  }