@onereason/svenska-dagar-mcp 0.1.5 → 0.2.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/README.md CHANGED
@@ -38,7 +38,6 @@ Returns calendar data for a single date.
38
38
  | `year` | integer | Year (1753–2100) |
39
39
  | `month` | integer | Month (1–12) |
40
40
  | `day` | integer | Day of month (1–31) |
41
- | `sanitize` | boolean | See [Security](#security) — default `true` |
42
41
 
43
42
  ### `get_swedish_month`
44
43
 
@@ -48,7 +47,6 @@ Returns calendar data for every day in a month.
48
47
  |-----------|------|-------------|
49
48
  | `year` | integer | Year (1753–2100) |
50
49
  | `month` | integer | Month (1–12) |
51
- | `sanitize` | boolean | Default `true` |
52
50
 
53
51
  ### `get_swedish_year`
54
52
 
@@ -57,7 +55,6 @@ Returns calendar data for every day in a year (~365 records). Prefer `get_swedis
57
55
  | Parameter | Type | Description |
58
56
  |-----------|------|-------------|
59
57
  | `year` | integer | Year (1753–2100) |
60
- | `sanitize` | boolean | Default `true` |
61
58
 
62
59
  ## Response fields
63
60
 
@@ -80,7 +77,8 @@ Each day record contains:
80
77
 
81
78
  ## Security
82
79
 
83
- All responses are sanitized before reaching the LLM by default:
80
+ All responses are sanitized before reaching the LLM. Sanitization cannot be
81
+ disabled through tool arguments:
84
82
 
85
83
  - **Field allowlist** — unknown fields are dropped
86
84
  - **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 +86,7 @@ All responses are sanitized before reaching the LLM by default:
88
86
  - **Enum coercion** — `"röd dag"` and `"arbetsfri dag"` are coerced to exactly `"Ja"` or `"Nej"`
89
87
  - **Hardcoded flags** — presence-only fields (`klämdagen`, `dag före arbetsfri helgdag`) are always written as `"Ja"` regardless of what the API sends
90
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
91
-
92
- Pass `sanitize: false` to receive the raw upstream response (useful for debugging).
89
+ - **Bounded upstream reads** — non-JSON responses, redirects, and responses over 1 MB are rejected
93
90
 
94
91
  ## Development
95
92
 
@@ -99,8 +96,8 @@ npm run build # compile TypeScript → dist/
99
96
  npm test # run unit + integration tests (requires network for API tests)
100
97
  ```
101
98
 
102
- **Running the server** requires Node.js ≥ 18 (native `fetch`).
103
- **Running the tests** requires Node.js ≥ 22.6 (`--experimental-strip-types`).
99
+ - **Running the server** requires a supported Node.js release ≥ 22.
100
+ - **Running the tests** requires Node.js ≥ 22.6 (`--experimental-strip-types`).
104
101
 
105
102
  ## License
106
103
 
package/dist/sanitize.js CHANGED
@@ -82,3 +82,6 @@ export function sanitizeCalendarResponse(raw) {
82
82
  : [],
83
83
  };
84
84
  }
85
+ export function serializeCalendarResponse(raw) {
86
+ return JSON.stringify(sanitizeCalendarResponse(raw), null, 0);
87
+ }
package/dist/server.js CHANGED
@@ -3,103 +3,20 @@ 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 { 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.0" }, { 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;
105
22
  if (name === "get_swedish_day") {
@@ -126,7 +43,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
126
43
  throw new Error(`unknown tool: ${name}`);
127
44
  }
128
45
  return {
129
- content: [{ type: "text", text: applyPolicy(data, sanitize) }],
46
+ content: [{ type: "text", text: serializeCalendarResponse(data) }],
130
47
  };
131
48
  }
132
49
  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 = 1_000_000;
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.0",
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",
41
+ "@types/node": "22.20.3",
42
42
  "typescript": "6.0.3"
43
43
  }
44
44
  }