@sladd-no/mcp 0.1.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 ADDED
@@ -0,0 +1,79 @@
1
+ # @sladd-no/mcp
2
+
3
+ **MCP server for [Sladd](https://sladd.no) — detect and redact Norwegian PII before documents leave your agent pipeline.**
4
+
5
+ Sladd finds and removes Norwegian personally identifiable information — fodselsnummer (national IDs, checksum-validated), names, addresses, phone numbers, bank accounts, health information (GDPR Article 9), DUF numbers and more — in plain text and PDFs. This package exposes that capability to any MCP-compatible agent (Claude Desktop, Claude Code, Cursor, VS Code, custom agents) as two tools:
6
+
7
+ | Tool | What it does |
8
+ |---|---|
9
+ | `detect_pii` | Scan text or a PDF → structured list of findings (type, offsets, confidence, recommended action) |
10
+ | `redact_document` | Text → `redacted_text` with labels/pseudonyms. PDF → new PDF with findings irreversibly removed under black boxes, plus a verification block proving everything detected is gone |
11
+
12
+ Use it as a guard: **redact first, then let the content continue through the agent flow.**
13
+
14
+ ## Setup in 30 seconds (Claude Desktop)
15
+
16
+ Add this to `claude_desktop_config.json` (Settings → Developer → Edit Config):
17
+
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "sladd": {
22
+ "command": "npx",
23
+ "args": ["-y", "@sladd-no/mcp"],
24
+ "env": { "SLADD_API_KEY": "sk_sladd_..." }
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ On Windows, if `npx` is not found, use `"command": "cmd", "args": ["/c", "npx", "-y", "@sladd-no/mcp"]`.
31
+
32
+ Restart Claude Desktop. Then try: *"Check vedtak.pdf on my desktop for Norwegian PII, and give me a redacted copy."*
33
+
34
+ **Claude Code:**
35
+
36
+ ```bash
37
+ claude mcp add sladd --env SLADD_API_KEY=sk_sladd_... -- npx -y @sladd-no/mcp
38
+ ```
39
+
40
+ Need a key? API keys (`sk_sladd_...`) are issued at [sladd.no/api](https://sladd.no/api).
41
+
42
+ ## Remote endpoint (no install)
43
+
44
+ For clients that support Streamable HTTP with custom headers (Claude Code, Cursor, VS Code, programmatic agents):
45
+
46
+ ```
47
+ URL: https://sladd.no/mcp
48
+ Header: Authorization: Bearer sk_sladd_...
49
+ ```
50
+
51
+ ```bash
52
+ claude mcp add --transport http sladd https://sladd.no/mcp --header "Authorization: Bearer sk_sladd_..."
53
+ ```
54
+
55
+ Note: claude.ai web connectors require OAuth and are not supported yet. The remote endpoint accepts base64 PDFs up to ~3 MB per request; for larger files use the local install (`file_path` input has a 20 MB limit).
56
+
57
+ ## How PDFs flow (local install)
58
+
59
+ The local server accepts a `file_path` — the agent never has to base64 a document through its context window:
60
+
61
+ 1. The server reads the PDF from disk and sends it to the Sladd API over HTTPS.
62
+ 2. The API detects, redacts (rasterized black boxes — underlying text deleted), re-scans its own output, and returns the verified PDF.
63
+ 3. The server writes `<name> [sladd].pdf` next to the input (never overwriting anything) and returns the path + verification report.
64
+
65
+ The document content never enters the model's context — only the findings and the report do.
66
+
67
+ ## Privacy
68
+
69
+ The hosted API processes documents **in memory only**: never written to disk, never queued, never logged. Usage metering records metadata only (document/page counts, mode) — never content, findings text, or filenames. See [sladd.no/personvern](https://sladd.no/personvern). For full data control, the same engine is available as a self-hosted Docker image and CLI.
70
+
71
+ ## Limits
72
+
73
+ - Max 20 MB and 100 pages per document (remote endpoint: ~3 MB effective for base64 PDFs)
74
+ - 60 requests per minute per key
75
+ - `mode: "deep"` (NER layer, higher recall) is rolling out — until active it returns `mode_unavailable`; use the default `fast`
76
+
77
+ ## License
78
+
79
+ Proprietary. © Sladd — [sladd.no](https://sladd.no). Contact: [sladd.no/om](https://sladd.no/om).
package/dist/index.mjs ADDED
@@ -0,0 +1,404 @@
1
+ // packages/sladd-mcp/src/tools.ts
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join } from "node:path";
4
+ import { z } from "zod";
5
+
6
+ // packages/sladd-mcp/src/client.ts
7
+ var MAX_BYTES = 20 * 1024 * 1024;
8
+ var TIMEOUT_MS = 11e4;
9
+ var SladdApiError = class extends Error {
10
+ constructor(code, message, status) {
11
+ super(message);
12
+ this.name = "SladdApiError";
13
+ this.code = code;
14
+ this.status = status;
15
+ }
16
+ };
17
+ function endpoint(apiBase, path) {
18
+ return `${apiBase.replace(/\/$/, "")}/api/v1/${path}`;
19
+ }
20
+ async function throwApiError(res) {
21
+ let code = "internal_error";
22
+ let message = `Uventet svar fra Sladd API (HTTP ${res.status}).`;
23
+ try {
24
+ const body = await res.json();
25
+ if (typeof body.code === "string") code = body.code;
26
+ if (typeof body.message === "string") message = body.message;
27
+ } catch {
28
+ }
29
+ throw new SladdApiError(code, message, res.status);
30
+ }
31
+ async function postJson(url, apiKey, body) {
32
+ const res = await fetch(url, {
33
+ method: "POST",
34
+ headers: {
35
+ "content-type": "application/json",
36
+ authorization: `Bearer ${apiKey}`
37
+ },
38
+ body: JSON.stringify(body),
39
+ signal: AbortSignal.timeout(TIMEOUT_MS)
40
+ });
41
+ if (!res.ok) await throwApiError(res);
42
+ return res;
43
+ }
44
+ async function postPdf(url, apiKey, bytes, filename, fields) {
45
+ const form = new FormData();
46
+ form.append("file", new File([bytes], filename ?? "document.pdf", { type: "application/pdf" }));
47
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
48
+ const res = await fetch(url, {
49
+ method: "POST",
50
+ headers: { authorization: `Bearer ${apiKey}` },
51
+ body: form,
52
+ signal: AbortSignal.timeout(TIMEOUT_MS)
53
+ });
54
+ if (!res.ok) await throwApiError(res);
55
+ return res;
56
+ }
57
+ function commonFields(params) {
58
+ const fields = {};
59
+ if (params.mode) fields.mode = params.mode;
60
+ if (params.types?.length) fields.types = params.types.join(",");
61
+ return fields;
62
+ }
63
+ async function detectText(opts, text, params) {
64
+ const body = { text, ...commonFields(params) };
65
+ if (params.types?.length) body.types = params.types;
66
+ const res = await postJson(endpoint(opts.apiBase, "detect"), opts.apiKey, body);
67
+ return await res.json();
68
+ }
69
+ async function detectPdf(opts, bytes, filename, params) {
70
+ const res = await postPdf(endpoint(opts.apiBase, "detect"), opts.apiKey, bytes, filename, commonFields(params));
71
+ return await res.json();
72
+ }
73
+ async function redactText(opts, text, params) {
74
+ const body = { text, ...commonFields(params) };
75
+ if (params.types?.length) body.types = params.types;
76
+ if (params.style) body.style = params.style;
77
+ const res = await postJson(endpoint(opts.apiBase, "redact"), opts.apiKey, body);
78
+ return await res.json();
79
+ }
80
+ async function redactPdf(opts, bytes, params) {
81
+ const fields = commonFields(params);
82
+ if (params.filenameCheck) fields.filename_check = "true";
83
+ const res = await postPdf(endpoint(opts.apiBase, "redact"), opts.apiKey, bytes, params.filename, fields);
84
+ const reportB64 = res.headers.get("x-sladd-report");
85
+ if (!reportB64) {
86
+ throw new SladdApiError("internal_error", "Svaret manglet X-Sladd-Report-headeren.", 500);
87
+ }
88
+ const report = JSON.parse(Buffer.from(reportB64, "base64").toString("utf8"));
89
+ return {
90
+ pdfBytes: new Uint8Array(await res.arrayBuffer()),
91
+ report,
92
+ filename: parseFilename(res.headers.get("content-disposition"))
93
+ };
94
+ }
95
+ function parseFilename(header) {
96
+ if (!header) return void 0;
97
+ const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
98
+ if (star) {
99
+ try {
100
+ return decodeURIComponent(star[1].trim());
101
+ } catch {
102
+ }
103
+ }
104
+ const plain = /filename="([^"]+)"/i.exec(header);
105
+ return plain ? plain[1] : void 0;
106
+ }
107
+
108
+ // packages/sladd-mcp/src/types.ts
109
+ var PII_TYPES = [
110
+ "PERSON_NAME",
111
+ "NATIONAL_ID",
112
+ "BANK_ACCOUNT",
113
+ "PHONE",
114
+ "EMAIL",
115
+ "ADDRESS",
116
+ "POSTAL_CODE",
117
+ "CASE_NUMBER",
118
+ "ORG_NUMBER",
119
+ "DATE_OF_BIRTH",
120
+ "DATE",
121
+ "LICENSE_PLATE",
122
+ "HEALTH_ID",
123
+ "HEALTH_INFO",
124
+ "DUF_NUMBER",
125
+ "OTHER"
126
+ ];
127
+
128
+ // packages/sladd-mcp/src/tools.ts
129
+ var MCP_SERVER_NAME = "sladd";
130
+ var MCP_SERVER_VERSION = "0.1.0";
131
+ var ToolError = class extends Error {
132
+ };
133
+ var ERROR_HINTS = {
134
+ invalid_key: "The API key was missing or rejected. Configure SLADD_API_KEY (local server) or the Authorization: Bearer header (remote). Keys look like sk_sladd_...",
135
+ rate_limited: "The limit is 60 requests per minute per key \u2014 wait briefly and retry.",
136
+ mode_unavailable: "deep mode is not available yet \u2014 retry with mode: 'fast'.",
137
+ too_large: "Documents are limited to 20 MB.",
138
+ could_not_parse: "The PDF could not be parsed \u2014 it may be corrupt, scanned without a text layer, or exceed 100 pages."
139
+ };
140
+ function errorResult(err) {
141
+ let text;
142
+ if (err instanceof SladdApiError) {
143
+ const hint = ERROR_HINTS[err.code];
144
+ text = `Sladd API error ${err.code}: ${err.message}${hint ? ` \u2014 ${hint}` : ""}`;
145
+ } else if (err instanceof ToolError) {
146
+ text = err.message;
147
+ } else if (err instanceof Error && err.name === "TimeoutError") {
148
+ text = "The Sladd API request timed out (110 s). Large scanned PDFs can be slow \u2014 try a smaller document.";
149
+ } else {
150
+ text = "Unexpected error while calling the Sladd API.";
151
+ }
152
+ return { isError: true, content: [{ type: "text", text }] };
153
+ }
154
+ function resolveInput(args, allowFilePaths) {
155
+ const provided = [
156
+ args.text ? "text" : null,
157
+ args.document_base64 ? "document_base64" : null,
158
+ args.file_path ? "file_path" : null
159
+ ].filter(Boolean);
160
+ const valid = allowFilePaths ? "text, document_base64 or file_path" : "text or document_base64";
161
+ if (provided.length !== 1) {
162
+ throw new ToolError(
163
+ `Provide exactly one input: ${valid}. Got ${provided.length ? provided.join(" + ") : "none"}.`
164
+ );
165
+ }
166
+ if (args.text) return { kind: "text", text: args.text };
167
+ if (args.document_base64) return { kind: "base64", base64: args.document_base64 };
168
+ if (!allowFilePaths) {
169
+ throw new ToolError(`file_path is not available on this server. Provide ${valid}.`);
170
+ }
171
+ return { kind: "file", path: args.file_path };
172
+ }
173
+ async function readLocalPdf(path) {
174
+ if (!isAbsolute(path)) {
175
+ throw new ToolError(`file_path must be an absolute path. Got: ${path}`);
176
+ }
177
+ let bytes;
178
+ try {
179
+ bytes = await readFile(path);
180
+ } catch {
181
+ throw new ToolError(`Could not read file: ${path}. Check that it exists and is readable.`);
182
+ }
183
+ if (bytes.length > MAX_BYTES) {
184
+ throw new ToolError(`File is ${(bytes.length / 1024 / 1024).toFixed(1)} MB \u2014 the limit is 20 MB.`);
185
+ }
186
+ const isPdf = bytes.length >= 5 && bytes[0] === 37 && bytes[1] === 80 && bytes[2] === 68 && bytes[3] === 70 && bytes[4] === 45;
187
+ if (!isPdf) {
188
+ throw new ToolError(`${path} is not a PDF (missing %PDF header). For plain text, use the text input.`);
189
+ }
190
+ return { bytes: new Uint8Array(bytes), filename: basename(path) };
191
+ }
192
+ function decodeBase64Pdf(base64) {
193
+ let bytes;
194
+ try {
195
+ bytes = new Uint8Array(Buffer.from(base64, "base64"));
196
+ } catch {
197
+ throw new ToolError("document_base64 is not valid base64.");
198
+ }
199
+ if (bytes.length === 0) throw new ToolError("document_base64 decoded to an empty document.");
200
+ return bytes;
201
+ }
202
+ async function writeRedactedPdf(inputPath, outputPath, bytes) {
203
+ const target = outputPath ?? join(dirname(inputPath), `${basename(inputPath, extname(inputPath))} [sladd].pdf`);
204
+ const dir = dirname(target);
205
+ const stem = basename(target, extname(target));
206
+ const ext = extname(target) || ".pdf";
207
+ for (let attempt = 0; attempt < 100; attempt++) {
208
+ const candidate = attempt === 0 ? target : join(dir, `${stem}-${attempt + 1}${ext}`);
209
+ try {
210
+ await writeFile(candidate, bytes, { flag: "wx" });
211
+ return candidate;
212
+ } catch (err) {
213
+ if (err.code === "EEXIST") continue;
214
+ throw new ToolError(`Could not write the redacted PDF to ${candidate}.`);
215
+ }
216
+ }
217
+ throw new ToolError(`Could not find a free output filename near ${target}.`);
218
+ }
219
+ function okResult(structured, extraContent) {
220
+ return {
221
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }, ...extraContent ?? []],
222
+ structuredContent: structured
223
+ };
224
+ }
225
+ function inputTrio(allowFilePaths) {
226
+ const alternatives = allowFilePaths ? "text, document_base64, file_path" : "text, document_base64";
227
+ return {
228
+ text: z.string().optional().describe(`Raw text to scan (Norwegian or mixed-language). Provide exactly one of: ${alternatives}.`),
229
+ document_base64: z.string().optional().describe(
230
+ "A PDF encoded as base64." + (allowFilePaths ? " Prefer file_path for local files \u2014 base64 is token-expensive." : " Max ~3 MB over the hosted endpoint.")
231
+ ),
232
+ ...allowFilePaths ? {
233
+ file_path: z.string().optional().describe(
234
+ "Absolute path to a local PDF. The file is read from disk and sent to the Sladd API over HTTPS; its content never enters the conversation except as results."
235
+ )
236
+ } : {},
237
+ types: z.array(z.enum(PII_TYPES)).optional().describe("Limit to these PII types. Default: all 16 types."),
238
+ mode: z.enum(["fast", "deep"]).optional().describe(
239
+ "fast (default): regex + wordlist layers \u2014 deterministic, quick. deep: adds a Norwegian NER model for higher recall; if unavailable, retry with fast."
240
+ )
241
+ };
242
+ }
243
+ var detectionOutput = z.object({
244
+ start: z.number(),
245
+ end: z.number(),
246
+ text: z.string(),
247
+ type: z.string(),
248
+ confidence: z.number(),
249
+ sources: z.array(z.string()),
250
+ action: z.string()
251
+ }).passthrough();
252
+ var verificationOutput = z.object({ passed: z.boolean(), method: z.string(), leaks: z.array(z.unknown()) }).passthrough();
253
+ function registerSladdTools(server, opts) {
254
+ const requireKey = (extra) => {
255
+ const key = opts.getApiKey(extra);
256
+ if (!key) {
257
+ throw new SladdApiError("invalid_key", "Ingen API-n\xF8kkel konfigurert.", 401);
258
+ }
259
+ return key;
260
+ };
261
+ server.registerTool(
262
+ "detect_pii",
263
+ {
264
+ title: "Detect Norwegian PII in text or a PDF",
265
+ description: "Scan text or a PDF for Norwegian personally identifiable information (PII) and get a structured list of findings. Detects 16 categories tuned to Norwegian formats with checksum validation: person names, national identity numbers (fodselsnummer/D-nummer), bank accounts, phone numbers, email addresses, street addresses, postal codes, case numbers (saksnummer), organization numbers, dates of birth, license plates, health identifiers and health information (GDPR Article 9 special-category data), and DUF numbers (immigration cases).\n\nUse this BEFORE pasting, summarizing, translating, or forwarding Norwegian documents \u2014 case files (vedtak), medical notes (journalnotat), CVs, letters, emails \u2014 to any LLM, external API, or third party. Norwegian documents routinely contain fodselsnummer or health data that trigger GDPR obligations, and generic regexes miss the Norwegian formats this service validates. Each detection includes character offsets, the matched text verbatim, PII type, confidence (0-1), and a recommended action (auto/review/hint). This tool only reports \u2014 it does not modify anything. To actually remove the PII, use redact_document.",
266
+ inputSchema: inputTrio(opts.allowFilePaths),
267
+ outputSchema: {
268
+ mode: z.string(),
269
+ pages: z.number(),
270
+ total: z.number().describe("Number of detections."),
271
+ detections: z.array(detectionOutput)
272
+ },
273
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
274
+ },
275
+ async (args, extra) => {
276
+ try {
277
+ const apiKey = requireKey(extra);
278
+ const client = { apiBase: opts.apiBase, apiKey };
279
+ const params = { mode: args.mode, types: args.types };
280
+ const input = resolveInput(args, opts.allowFilePaths);
281
+ let response;
282
+ if (input.kind === "text") {
283
+ response = await detectText(client, input.text, params);
284
+ } else if (input.kind === "base64") {
285
+ response = await detectPdf(client, decodeBase64Pdf(input.base64), void 0, params);
286
+ } else {
287
+ const { bytes, filename } = await readLocalPdf(input.path);
288
+ response = await detectPdf(client, bytes, filename, params);
289
+ }
290
+ return okResult({
291
+ mode: response.mode,
292
+ pages: response.pages,
293
+ total: response.detections.length,
294
+ detections: response.detections
295
+ });
296
+ } catch (err) {
297
+ return errorResult(err);
298
+ }
299
+ }
300
+ );
301
+ server.registerTool(
302
+ "redact_document",
303
+ {
304
+ title: "Redact (sladd) Norwegian PII from text or a PDF",
305
+ description: "Remove Norwegian personally identifiable information from text or a PDF and get back a safe artifact plus a machine-readable verification report. Use this whenever a Norwegian document must be shared, archived, published (offentleglova/innsyn), or sent to an LLM or third party without exposing PII such as fodselsnummer, names, addresses, health information (GDPR Article 9), or bank details.\n\nText input returns redacted_text with each finding replaced by a type label ([Personnavn]) or a consistent pseudonym ([PERSON_1]) that preserves who-did-what readability. PDF input produces a new PDF where findings are irreversibly removed and covered with black boxes \u2014 the underlying text is deleted, not just hidden. Every response includes a verification block: the service re-scans the OUTPUT document and confirms that everything it detected is gone (verification.passed = true; a redaction that leaks fails instead of being returned). The report never contains the original PII text. Prefer this over manual find-and-replace: it validates Norwegian ID checksums, uses Norwegian name and health-term lists, and verifies its own output.",
306
+ inputSchema: {
307
+ ...inputTrio(opts.allowFilePaths),
308
+ style: z.enum(["label", "pseudonym"]).optional().describe(
309
+ "Text redaction style. label: '[Personnavn]'. pseudonym: '[PERSON_1]' (consistent across the document). PDFs always use irreversible black boxes; style is ignored for PDF input."
310
+ ),
311
+ filename_check: z.boolean().optional().describe(
312
+ "Also check whether the PDF's filename itself contains PII (e.g. 'vedtak-Ola-Hansen.pdf') and report it."
313
+ ),
314
+ ...opts.allowFilePaths ? {
315
+ output_path: z.string().optional().describe(
316
+ "Where to write the redacted PDF. Default: next to the input as '<name> [sladd].pdf'. Never overwrites an existing file (a numeric suffix is added instead)."
317
+ )
318
+ } : {}
319
+ },
320
+ outputSchema: {
321
+ redacted_text: z.string().optional().describe("Present for text input."),
322
+ output_file: z.string().optional().describe("Path to the redacted PDF (local file input)."),
323
+ stats: z.object({}).passthrough().optional(),
324
+ verification: verificationOutput,
325
+ report: z.object({}).passthrough().optional()
326
+ },
327
+ annotations: {
328
+ readOnlyHint: !opts.allowFilePaths,
329
+ destructiveHint: false,
330
+ idempotentHint: true,
331
+ openWorldHint: true
332
+ }
333
+ },
334
+ async (args, extra) => {
335
+ try {
336
+ const apiKey = requireKey(extra);
337
+ const client = { apiBase: opts.apiBase, apiKey };
338
+ const input = resolveInput(args, opts.allowFilePaths);
339
+ if (input.kind === "text") {
340
+ const response = await redactText(client, input.text, {
341
+ mode: args.mode,
342
+ types: args.types,
343
+ style: args.style
344
+ });
345
+ return okResult({
346
+ redacted_text: response.redacted_text,
347
+ verification: response.report.verification,
348
+ report: response.report
349
+ });
350
+ }
351
+ const pdfParams = {
352
+ mode: args.mode,
353
+ types: args.types,
354
+ filenameCheck: args.filename_check
355
+ };
356
+ if (input.kind === "file") {
357
+ const { bytes: bytes2, filename } = await readLocalPdf(input.path);
358
+ const result2 = await redactPdf(client, bytes2, { ...pdfParams, filename });
359
+ const outputFile = await writeRedactedPdf(input.path, args.output_path, result2.pdfBytes);
360
+ return okResult({
361
+ output_file: outputFile,
362
+ stats: result2.report.stats,
363
+ verification: result2.report.verification,
364
+ report: result2.report
365
+ });
366
+ }
367
+ const bytes = decodeBase64Pdf(input.base64);
368
+ const result = await redactPdf(client, bytes, pdfParams);
369
+ const structured = {
370
+ stats: result.report.stats,
371
+ verification: result.report.verification,
372
+ report: result.report
373
+ };
374
+ return {
375
+ content: [
376
+ {
377
+ type: "text",
378
+ text: `Redacted PDF attached as a resource (${result.filename ?? "document [sladd].pdf"}). ` + JSON.stringify({ stats: result.report.stats, verification: result.report.verification })
379
+ },
380
+ {
381
+ type: "resource",
382
+ resource: {
383
+ uri: `sladd://redacted/${encodeURIComponent(result.filename ?? "document [sladd].pdf")}`,
384
+ mimeType: "application/pdf",
385
+ blob: Buffer.from(result.pdfBytes).toString("base64")
386
+ }
387
+ }
388
+ ],
389
+ structuredContent: structured
390
+ };
391
+ } catch (err) {
392
+ return errorResult(err);
393
+ }
394
+ }
395
+ );
396
+ }
397
+ export {
398
+ MAX_BYTES,
399
+ MCP_SERVER_NAME,
400
+ MCP_SERVER_VERSION,
401
+ PII_TYPES,
402
+ SladdApiError,
403
+ registerSladdTools
404
+ };
package/dist/stdio.mjs ADDED
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env node
2
+
3
+ // packages/sladd-mcp/src/stdio.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // packages/sladd-mcp/src/tools.ts
8
+ import { readFile, writeFile } from "node:fs/promises";
9
+ import { basename, dirname, extname, isAbsolute, join } from "node:path";
10
+ import { z } from "zod";
11
+
12
+ // packages/sladd-mcp/src/client.ts
13
+ var MAX_BYTES = 20 * 1024 * 1024;
14
+ var TIMEOUT_MS = 11e4;
15
+ var SladdApiError = class extends Error {
16
+ constructor(code, message, status) {
17
+ super(message);
18
+ this.name = "SladdApiError";
19
+ this.code = code;
20
+ this.status = status;
21
+ }
22
+ };
23
+ function endpoint(apiBase2, path) {
24
+ return `${apiBase2.replace(/\/$/, "")}/api/v1/${path}`;
25
+ }
26
+ async function throwApiError(res) {
27
+ let code = "internal_error";
28
+ let message = `Uventet svar fra Sladd API (HTTP ${res.status}).`;
29
+ try {
30
+ const body = await res.json();
31
+ if (typeof body.code === "string") code = body.code;
32
+ if (typeof body.message === "string") message = body.message;
33
+ } catch {
34
+ }
35
+ throw new SladdApiError(code, message, res.status);
36
+ }
37
+ async function postJson(url, apiKey2, body) {
38
+ const res = await fetch(url, {
39
+ method: "POST",
40
+ headers: {
41
+ "content-type": "application/json",
42
+ authorization: `Bearer ${apiKey2}`
43
+ },
44
+ body: JSON.stringify(body),
45
+ signal: AbortSignal.timeout(TIMEOUT_MS)
46
+ });
47
+ if (!res.ok) await throwApiError(res);
48
+ return res;
49
+ }
50
+ async function postPdf(url, apiKey2, bytes, filename, fields) {
51
+ const form = new FormData();
52
+ form.append("file", new File([bytes], filename ?? "document.pdf", { type: "application/pdf" }));
53
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
54
+ const res = await fetch(url, {
55
+ method: "POST",
56
+ headers: { authorization: `Bearer ${apiKey2}` },
57
+ body: form,
58
+ signal: AbortSignal.timeout(TIMEOUT_MS)
59
+ });
60
+ if (!res.ok) await throwApiError(res);
61
+ return res;
62
+ }
63
+ function commonFields(params) {
64
+ const fields = {};
65
+ if (params.mode) fields.mode = params.mode;
66
+ if (params.types?.length) fields.types = params.types.join(",");
67
+ return fields;
68
+ }
69
+ async function detectText(opts, text, params) {
70
+ const body = { text, ...commonFields(params) };
71
+ if (params.types?.length) body.types = params.types;
72
+ const res = await postJson(endpoint(opts.apiBase, "detect"), opts.apiKey, body);
73
+ return await res.json();
74
+ }
75
+ async function detectPdf(opts, bytes, filename, params) {
76
+ const res = await postPdf(endpoint(opts.apiBase, "detect"), opts.apiKey, bytes, filename, commonFields(params));
77
+ return await res.json();
78
+ }
79
+ async function redactText(opts, text, params) {
80
+ const body = { text, ...commonFields(params) };
81
+ if (params.types?.length) body.types = params.types;
82
+ if (params.style) body.style = params.style;
83
+ const res = await postJson(endpoint(opts.apiBase, "redact"), opts.apiKey, body);
84
+ return await res.json();
85
+ }
86
+ async function redactPdf(opts, bytes, params) {
87
+ const fields = commonFields(params);
88
+ if (params.filenameCheck) fields.filename_check = "true";
89
+ const res = await postPdf(endpoint(opts.apiBase, "redact"), opts.apiKey, bytes, params.filename, fields);
90
+ const reportB64 = res.headers.get("x-sladd-report");
91
+ if (!reportB64) {
92
+ throw new SladdApiError("internal_error", "Svaret manglet X-Sladd-Report-headeren.", 500);
93
+ }
94
+ const report = JSON.parse(Buffer.from(reportB64, "base64").toString("utf8"));
95
+ return {
96
+ pdfBytes: new Uint8Array(await res.arrayBuffer()),
97
+ report,
98
+ filename: parseFilename(res.headers.get("content-disposition"))
99
+ };
100
+ }
101
+ function parseFilename(header) {
102
+ if (!header) return void 0;
103
+ const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
104
+ if (star) {
105
+ try {
106
+ return decodeURIComponent(star[1].trim());
107
+ } catch {
108
+ }
109
+ }
110
+ const plain = /filename="([^"]+)"/i.exec(header);
111
+ return plain ? plain[1] : void 0;
112
+ }
113
+
114
+ // packages/sladd-mcp/src/types.ts
115
+ var PII_TYPES = [
116
+ "PERSON_NAME",
117
+ "NATIONAL_ID",
118
+ "BANK_ACCOUNT",
119
+ "PHONE",
120
+ "EMAIL",
121
+ "ADDRESS",
122
+ "POSTAL_CODE",
123
+ "CASE_NUMBER",
124
+ "ORG_NUMBER",
125
+ "DATE_OF_BIRTH",
126
+ "DATE",
127
+ "LICENSE_PLATE",
128
+ "HEALTH_ID",
129
+ "HEALTH_INFO",
130
+ "DUF_NUMBER",
131
+ "OTHER"
132
+ ];
133
+
134
+ // packages/sladd-mcp/src/tools.ts
135
+ var MCP_SERVER_NAME = "sladd";
136
+ var MCP_SERVER_VERSION = "0.1.0";
137
+ var ToolError = class extends Error {
138
+ };
139
+ var ERROR_HINTS = {
140
+ invalid_key: "The API key was missing or rejected. Configure SLADD_API_KEY (local server) or the Authorization: Bearer header (remote). Keys look like sk_sladd_...",
141
+ rate_limited: "The limit is 60 requests per minute per key \u2014 wait briefly and retry.",
142
+ mode_unavailable: "deep mode is not available yet \u2014 retry with mode: 'fast'.",
143
+ too_large: "Documents are limited to 20 MB.",
144
+ could_not_parse: "The PDF could not be parsed \u2014 it may be corrupt, scanned without a text layer, or exceed 100 pages."
145
+ };
146
+ function errorResult(err) {
147
+ let text;
148
+ if (err instanceof SladdApiError) {
149
+ const hint = ERROR_HINTS[err.code];
150
+ text = `Sladd API error ${err.code}: ${err.message}${hint ? ` \u2014 ${hint}` : ""}`;
151
+ } else if (err instanceof ToolError) {
152
+ text = err.message;
153
+ } else if (err instanceof Error && err.name === "TimeoutError") {
154
+ text = "The Sladd API request timed out (110 s). Large scanned PDFs can be slow \u2014 try a smaller document.";
155
+ } else {
156
+ text = "Unexpected error while calling the Sladd API.";
157
+ }
158
+ return { isError: true, content: [{ type: "text", text }] };
159
+ }
160
+ function resolveInput(args, allowFilePaths) {
161
+ const provided = [
162
+ args.text ? "text" : null,
163
+ args.document_base64 ? "document_base64" : null,
164
+ args.file_path ? "file_path" : null
165
+ ].filter(Boolean);
166
+ const valid = allowFilePaths ? "text, document_base64 or file_path" : "text or document_base64";
167
+ if (provided.length !== 1) {
168
+ throw new ToolError(
169
+ `Provide exactly one input: ${valid}. Got ${provided.length ? provided.join(" + ") : "none"}.`
170
+ );
171
+ }
172
+ if (args.text) return { kind: "text", text: args.text };
173
+ if (args.document_base64) return { kind: "base64", base64: args.document_base64 };
174
+ if (!allowFilePaths) {
175
+ throw new ToolError(`file_path is not available on this server. Provide ${valid}.`);
176
+ }
177
+ return { kind: "file", path: args.file_path };
178
+ }
179
+ async function readLocalPdf(path) {
180
+ if (!isAbsolute(path)) {
181
+ throw new ToolError(`file_path must be an absolute path. Got: ${path}`);
182
+ }
183
+ let bytes;
184
+ try {
185
+ bytes = await readFile(path);
186
+ } catch {
187
+ throw new ToolError(`Could not read file: ${path}. Check that it exists and is readable.`);
188
+ }
189
+ if (bytes.length > MAX_BYTES) {
190
+ throw new ToolError(`File is ${(bytes.length / 1024 / 1024).toFixed(1)} MB \u2014 the limit is 20 MB.`);
191
+ }
192
+ const isPdf = bytes.length >= 5 && bytes[0] === 37 && bytes[1] === 80 && bytes[2] === 68 && bytes[3] === 70 && bytes[4] === 45;
193
+ if (!isPdf) {
194
+ throw new ToolError(`${path} is not a PDF (missing %PDF header). For plain text, use the text input.`);
195
+ }
196
+ return { bytes: new Uint8Array(bytes), filename: basename(path) };
197
+ }
198
+ function decodeBase64Pdf(base64) {
199
+ let bytes;
200
+ try {
201
+ bytes = new Uint8Array(Buffer.from(base64, "base64"));
202
+ } catch {
203
+ throw new ToolError("document_base64 is not valid base64.");
204
+ }
205
+ if (bytes.length === 0) throw new ToolError("document_base64 decoded to an empty document.");
206
+ return bytes;
207
+ }
208
+ async function writeRedactedPdf(inputPath, outputPath, bytes) {
209
+ const target = outputPath ?? join(dirname(inputPath), `${basename(inputPath, extname(inputPath))} [sladd].pdf`);
210
+ const dir = dirname(target);
211
+ const stem = basename(target, extname(target));
212
+ const ext = extname(target) || ".pdf";
213
+ for (let attempt = 0; attempt < 100; attempt++) {
214
+ const candidate = attempt === 0 ? target : join(dir, `${stem}-${attempt + 1}${ext}`);
215
+ try {
216
+ await writeFile(candidate, bytes, { flag: "wx" });
217
+ return candidate;
218
+ } catch (err) {
219
+ if (err.code === "EEXIST") continue;
220
+ throw new ToolError(`Could not write the redacted PDF to ${candidate}.`);
221
+ }
222
+ }
223
+ throw new ToolError(`Could not find a free output filename near ${target}.`);
224
+ }
225
+ function okResult(structured, extraContent) {
226
+ return {
227
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }, ...extraContent ?? []],
228
+ structuredContent: structured
229
+ };
230
+ }
231
+ function inputTrio(allowFilePaths) {
232
+ const alternatives = allowFilePaths ? "text, document_base64, file_path" : "text, document_base64";
233
+ return {
234
+ text: z.string().optional().describe(`Raw text to scan (Norwegian or mixed-language). Provide exactly one of: ${alternatives}.`),
235
+ document_base64: z.string().optional().describe(
236
+ "A PDF encoded as base64." + (allowFilePaths ? " Prefer file_path for local files \u2014 base64 is token-expensive." : " Max ~3 MB over the hosted endpoint.")
237
+ ),
238
+ ...allowFilePaths ? {
239
+ file_path: z.string().optional().describe(
240
+ "Absolute path to a local PDF. The file is read from disk and sent to the Sladd API over HTTPS; its content never enters the conversation except as results."
241
+ )
242
+ } : {},
243
+ types: z.array(z.enum(PII_TYPES)).optional().describe("Limit to these PII types. Default: all 16 types."),
244
+ mode: z.enum(["fast", "deep"]).optional().describe(
245
+ "fast (default): regex + wordlist layers \u2014 deterministic, quick. deep: adds a Norwegian NER model for higher recall; if unavailable, retry with fast."
246
+ )
247
+ };
248
+ }
249
+ var detectionOutput = z.object({
250
+ start: z.number(),
251
+ end: z.number(),
252
+ text: z.string(),
253
+ type: z.string(),
254
+ confidence: z.number(),
255
+ sources: z.array(z.string()),
256
+ action: z.string()
257
+ }).passthrough();
258
+ var verificationOutput = z.object({ passed: z.boolean(), method: z.string(), leaks: z.array(z.unknown()) }).passthrough();
259
+ function registerSladdTools(server2, opts) {
260
+ const requireKey = (extra) => {
261
+ const key = opts.getApiKey(extra);
262
+ if (!key) {
263
+ throw new SladdApiError("invalid_key", "Ingen API-n\xF8kkel konfigurert.", 401);
264
+ }
265
+ return key;
266
+ };
267
+ server2.registerTool(
268
+ "detect_pii",
269
+ {
270
+ title: "Detect Norwegian PII in text or a PDF",
271
+ description: "Scan text or a PDF for Norwegian personally identifiable information (PII) and get a structured list of findings. Detects 16 categories tuned to Norwegian formats with checksum validation: person names, national identity numbers (fodselsnummer/D-nummer), bank accounts, phone numbers, email addresses, street addresses, postal codes, case numbers (saksnummer), organization numbers, dates of birth, license plates, health identifiers and health information (GDPR Article 9 special-category data), and DUF numbers (immigration cases).\n\nUse this BEFORE pasting, summarizing, translating, or forwarding Norwegian documents \u2014 case files (vedtak), medical notes (journalnotat), CVs, letters, emails \u2014 to any LLM, external API, or third party. Norwegian documents routinely contain fodselsnummer or health data that trigger GDPR obligations, and generic regexes miss the Norwegian formats this service validates. Each detection includes character offsets, the matched text verbatim, PII type, confidence (0-1), and a recommended action (auto/review/hint). This tool only reports \u2014 it does not modify anything. To actually remove the PII, use redact_document.",
272
+ inputSchema: inputTrio(opts.allowFilePaths),
273
+ outputSchema: {
274
+ mode: z.string(),
275
+ pages: z.number(),
276
+ total: z.number().describe("Number of detections."),
277
+ detections: z.array(detectionOutput)
278
+ },
279
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
280
+ },
281
+ async (args, extra) => {
282
+ try {
283
+ const apiKey2 = requireKey(extra);
284
+ const client = { apiBase: opts.apiBase, apiKey: apiKey2 };
285
+ const params = { mode: args.mode, types: args.types };
286
+ const input = resolveInput(args, opts.allowFilePaths);
287
+ let response;
288
+ if (input.kind === "text") {
289
+ response = await detectText(client, input.text, params);
290
+ } else if (input.kind === "base64") {
291
+ response = await detectPdf(client, decodeBase64Pdf(input.base64), void 0, params);
292
+ } else {
293
+ const { bytes, filename } = await readLocalPdf(input.path);
294
+ response = await detectPdf(client, bytes, filename, params);
295
+ }
296
+ return okResult({
297
+ mode: response.mode,
298
+ pages: response.pages,
299
+ total: response.detections.length,
300
+ detections: response.detections
301
+ });
302
+ } catch (err) {
303
+ return errorResult(err);
304
+ }
305
+ }
306
+ );
307
+ server2.registerTool(
308
+ "redact_document",
309
+ {
310
+ title: "Redact (sladd) Norwegian PII from text or a PDF",
311
+ description: "Remove Norwegian personally identifiable information from text or a PDF and get back a safe artifact plus a machine-readable verification report. Use this whenever a Norwegian document must be shared, archived, published (offentleglova/innsyn), or sent to an LLM or third party without exposing PII such as fodselsnummer, names, addresses, health information (GDPR Article 9), or bank details.\n\nText input returns redacted_text with each finding replaced by a type label ([Personnavn]) or a consistent pseudonym ([PERSON_1]) that preserves who-did-what readability. PDF input produces a new PDF where findings are irreversibly removed and covered with black boxes \u2014 the underlying text is deleted, not just hidden. Every response includes a verification block: the service re-scans the OUTPUT document and confirms that everything it detected is gone (verification.passed = true; a redaction that leaks fails instead of being returned). The report never contains the original PII text. Prefer this over manual find-and-replace: it validates Norwegian ID checksums, uses Norwegian name and health-term lists, and verifies its own output.",
312
+ inputSchema: {
313
+ ...inputTrio(opts.allowFilePaths),
314
+ style: z.enum(["label", "pseudonym"]).optional().describe(
315
+ "Text redaction style. label: '[Personnavn]'. pseudonym: '[PERSON_1]' (consistent across the document). PDFs always use irreversible black boxes; style is ignored for PDF input."
316
+ ),
317
+ filename_check: z.boolean().optional().describe(
318
+ "Also check whether the PDF's filename itself contains PII (e.g. 'vedtak-Ola-Hansen.pdf') and report it."
319
+ ),
320
+ ...opts.allowFilePaths ? {
321
+ output_path: z.string().optional().describe(
322
+ "Where to write the redacted PDF. Default: next to the input as '<name> [sladd].pdf'. Never overwrites an existing file (a numeric suffix is added instead)."
323
+ )
324
+ } : {}
325
+ },
326
+ outputSchema: {
327
+ redacted_text: z.string().optional().describe("Present for text input."),
328
+ output_file: z.string().optional().describe("Path to the redacted PDF (local file input)."),
329
+ stats: z.object({}).passthrough().optional(),
330
+ verification: verificationOutput,
331
+ report: z.object({}).passthrough().optional()
332
+ },
333
+ annotations: {
334
+ readOnlyHint: !opts.allowFilePaths,
335
+ destructiveHint: false,
336
+ idempotentHint: true,
337
+ openWorldHint: true
338
+ }
339
+ },
340
+ async (args, extra) => {
341
+ try {
342
+ const apiKey2 = requireKey(extra);
343
+ const client = { apiBase: opts.apiBase, apiKey: apiKey2 };
344
+ const input = resolveInput(args, opts.allowFilePaths);
345
+ if (input.kind === "text") {
346
+ const response = await redactText(client, input.text, {
347
+ mode: args.mode,
348
+ types: args.types,
349
+ style: args.style
350
+ });
351
+ return okResult({
352
+ redacted_text: response.redacted_text,
353
+ verification: response.report.verification,
354
+ report: response.report
355
+ });
356
+ }
357
+ const pdfParams = {
358
+ mode: args.mode,
359
+ types: args.types,
360
+ filenameCheck: args.filename_check
361
+ };
362
+ if (input.kind === "file") {
363
+ const { bytes: bytes2, filename } = await readLocalPdf(input.path);
364
+ const result2 = await redactPdf(client, bytes2, { ...pdfParams, filename });
365
+ const outputFile = await writeRedactedPdf(input.path, args.output_path, result2.pdfBytes);
366
+ return okResult({
367
+ output_file: outputFile,
368
+ stats: result2.report.stats,
369
+ verification: result2.report.verification,
370
+ report: result2.report
371
+ });
372
+ }
373
+ const bytes = decodeBase64Pdf(input.base64);
374
+ const result = await redactPdf(client, bytes, pdfParams);
375
+ const structured = {
376
+ stats: result.report.stats,
377
+ verification: result.report.verification,
378
+ report: result.report
379
+ };
380
+ return {
381
+ content: [
382
+ {
383
+ type: "text",
384
+ text: `Redacted PDF attached as a resource (${result.filename ?? "document [sladd].pdf"}). ` + JSON.stringify({ stats: result.report.stats, verification: result.report.verification })
385
+ },
386
+ {
387
+ type: "resource",
388
+ resource: {
389
+ uri: `sladd://redacted/${encodeURIComponent(result.filename ?? "document [sladd].pdf")}`,
390
+ mimeType: "application/pdf",
391
+ blob: Buffer.from(result.pdfBytes).toString("base64")
392
+ }
393
+ }
394
+ ],
395
+ structuredContent: structured
396
+ };
397
+ } catch (err) {
398
+ return errorResult(err);
399
+ }
400
+ }
401
+ );
402
+ }
403
+
404
+ // packages/sladd-mcp/src/stdio.ts
405
+ var apiKey = process.env.SLADD_API_KEY;
406
+ if (!apiKey) {
407
+ console.error(
408
+ '@sladd-no/mcp: SLADD_API_KEY is not set.\nAdd it to the server config, e.g. in claude_desktop_config.json:\n "env": { "SLADD_API_KEY": "sk_sladd_..." }\nKeys are issued at https://sladd.no/api'
409
+ );
410
+ process.exit(1);
411
+ }
412
+ var apiBase = process.env.SLADD_API_BASE ?? "https://sladd.no";
413
+ var server = new McpServer({ name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION });
414
+ registerSladdTools(server, {
415
+ apiBase,
416
+ getApiKey: () => apiKey,
417
+ allowFilePaths: true
418
+ });
419
+ var transport = new StdioServerTransport();
420
+ await server.connect(transport);
421
+ console.error(`@sladd-no/mcp ${MCP_SERVER_VERSION} ready (API: ${apiBase})`);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@sladd-no/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Sladd — detect and redact Norwegian PII (fodselsnummer, names, health data) in text and PDFs before documents leave your agent pipeline. Thin client over the Sladd API.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "mcpName": "no.sladd/mcp",
8
+ "main": "./dist/index.mjs",
9
+ "exports": {
10
+ ".": "./dist/index.mjs",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "bin": {
14
+ "sladd-mcp": "dist/stdio.mjs"
15
+ },
16
+ "files": [
17
+ "dist/",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "pii",
27
+ "redaction",
28
+ "gdpr",
29
+ "norwegian",
30
+ "fodselsnummer",
31
+ "anonymization",
32
+ "sladding"
33
+ ],
34
+ "author": "Sladd (https://sladd.no)",
35
+ "homepage": "https://sladd.no/api",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "prepublishOnly": "cd ../.. && npm run build:mcp"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "1.26.0",
44
+ "zod": "^3.25.1"
45
+ }
46
+ }