@onereason/svenska-dagar-mcp 0.1.4 → 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 +5 -8
- package/dist/sanitize.js +3 -0
- package/dist/server.js +6 -89
- package/dist/tools.js +46 -0
- package/dist/upstream.js +75 -0
- package/package.json +5 -5
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
|
|
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 ≥
|
|
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
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 {
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
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:
|
|
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
|
+
];
|
package/dist/upstream.js
ADDED
|
@@ -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.
|
|
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": ">=
|
|
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
|
-
"@modelcontextprotocol/sdk": "1.
|
|
38
|
+
"@modelcontextprotocol/sdk": "1.30.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@types/node": "
|
|
41
|
+
"@types/node": "22.20.3",
|
|
42
42
|
"typescript": "6.0.3"
|
|
43
43
|
}
|
|
44
44
|
}
|