@redactic/mcp 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Redactic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,227 @@
1
- # Temporary Holding Version
1
+ # Redactic MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Mask, restore and black out personal data in text and files from Claude Desktop, Claude Code,
4
+ Gemini CLI, Cursor and any other MCP client.
5
+
6
+ The server is a thin client of the API of [Redactic](https://redactic.eu/ai-assistants/) (redactic.eu). Your text and
7
+ files are sent to Redactic servers in the European Union, where the personal data is detected.
8
+ The AI model receives labels such as `⟦PERSON_1⟧`. The map that turns the labels back into the
9
+ original values stays in the memory of this server, on your computer.
10
+
11
+ ## What you need
12
+
13
+ - A Redactic workspace and an API key. Create the key in Redactic under Admin, API. It starts
14
+ with `rk_live_` and is shown once. Every snippet below has `rk_live_...` where your key goes.
15
+ - For every client except the Claude Desktop extension: Node.js 18 or later.
16
+ - The folders in which the file tools may read and write, as full paths. Separate several
17
+ folders with `:` on macOS and Linux and with `;` on Windows. Inside a JSON file, write each
18
+ Windows backslash twice.
19
+
20
+ | System | Folder example | Inside a JSON file |
21
+ |---|---|---|
22
+ | macOS | `/Users/you/Documents/redactic` | `"/Users/you/Documents/redactic"` |
23
+ | Windows | `C:\Users\you\Documents\redactic` | `"C:\\Users\\you\\Documents\\redactic"` |
24
+ | Linux | `/home/you/Documents/redactic` | `"/home/you/Documents/redactic"` |
25
+
26
+ ## Install
27
+
28
+ The same instructions are on [redactic.eu/developers](https://redactic.eu/developers/#mcp-install).
29
+
30
+ ### Claude Desktop
31
+
32
+ With the extension: download `redactic-<version>.mcpb` from
33
+ [redactic.eu/developers](https://redactic.eu/developers/#mcp-desktop) and compare its SHA-256
34
+ with the one printed on that page. Open the file, or drag it into Claude Desktop, or choose
35
+ Settings, Extensions, Advanced settings, Install Extension. Claude Desktop asks for the API key,
36
+ which it keeps in the keychain of your system, and for the folders the file tools may use. Claude
37
+ Desktop brings its own Node.js, so nothing else needs to be installed.
38
+
39
+ With the configuration file, which needs Node.js: in Claude Desktop choose Settings, Developer,
40
+ Edit Config. The file it opens is:
41
+
42
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
43
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
44
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
45
+
46
+ Add the server, save the file, then quit Claude Desktop completely and open it again.
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "redactic": {
52
+ "command": "npx",
53
+ "args": ["-y", "@redactic/mcp"],
54
+ "env": {
55
+ "REDACTIC_API_KEY": "rk_live_...",
56
+ "REDACTIC_ALLOWED_DIRS": "/Users/you/Documents/redactic"
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ On Windows, the folder line reads:
64
+
65
+ ```json
66
+ "REDACTIC_ALLOWED_DIRS": "C:\\Users\\you\\Documents\\redactic"
67
+ ```
68
+
69
+ ### Claude Code
70
+
71
+ Run this command in a terminal. Keep the options before the name `redactic`: a name that comes
72
+ right after `--env` is read as one more variable and the command fails. Everything after `--` is
73
+ the command that starts the server. With `--scope user` the server is available in all your
74
+ projects.
75
+
76
+ ```bash
77
+ claude mcp add --env REDACTIC_API_KEY=rk_live_... --env REDACTIC_ALLOWED_DIRS="$HOME/Documents/redactic" --transport stdio --scope user redactic -- npx -y @redactic/mcp
78
+ ```
79
+
80
+ On Windows, write the folder as a Windows path:
81
+
82
+ ```bash
83
+ claude mcp add --env REDACTIC_API_KEY=rk_live_... --env REDACTIC_ALLOWED_DIRS="C:\Users\you\Documents\redactic" --transport stdio --scope user redactic -- npx -y @redactic/mcp
84
+ ```
85
+
86
+ A team can share the server in the `.mcp.json` file of a project and keep the key out of it.
87
+ Each person sets `REDACTIC_API_KEY` in their own environment, and Claude Code reads it:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "redactic": {
93
+ "type": "stdio",
94
+ "command": "npx",
95
+ "args": ["-y", "@redactic/mcp"],
96
+ "env": {
97
+ "REDACTIC_API_KEY": "${REDACTIC_API_KEY}",
98
+ "REDACTIC_ALLOWED_DIRS": "${REDACTIC_ALLOWED_DIRS}"
99
+ }
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### Gemini CLI
106
+
107
+ Add the server to `~/.gemini/settings.json` for all projects, or to `.gemini/settings.json` in
108
+ one project.
109
+
110
+ Write the key in the `env` block. Gemini CLI removes from the environment it passes to a server
111
+ every variable whose name contains KEY, TOKEN or SECRET, so a key set only in your shell does not
112
+ reach the server.
113
+
114
+ ```json
115
+ {
116
+ "mcpServers": {
117
+ "redactic": {
118
+ "command": "npx",
119
+ "args": ["-y", "@redactic/mcp"],
120
+ "env": {
121
+ "REDACTIC_API_KEY": "rk_live_...",
122
+ "REDACTIC_ALLOWED_DIRS": "/home/you/Documents/redactic"
123
+ }
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ On Windows, the folder line reads:
130
+
131
+ ```json
132
+ "REDACTIC_ALLOWED_DIRS": "C:\\Users\\you\\Documents\\redactic"
133
+ ```
134
+
135
+ ### Cursor
136
+
137
+ Add the server to `~/.cursor/mcp.json` for all projects, or to `.cursor/mcp.json` in one
138
+ project. Cursor can read the key from your environment with `${env:REDACTIC_API_KEY}`.
139
+
140
+ ```json
141
+ {
142
+ "mcpServers": {
143
+ "redactic": {
144
+ "command": "npx",
145
+ "args": ["-y", "@redactic/mcp"],
146
+ "env": {
147
+ "REDACTIC_API_KEY": "${env:REDACTIC_API_KEY}",
148
+ "REDACTIC_ALLOWED_DIRS": "/Users/you/Documents/redactic"
149
+ }
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ The [developers page](https://redactic.eu/developers/#mcp-cursor) also has an "Add to Cursor"
156
+ link. It fills in the server with placeholder values: replace the key and the folder before you
157
+ save.
158
+
159
+ ### ChatGPT, Microsoft 365 Copilot and claude.ai
160
+
161
+ This server runs on your computer, so an assistant in the browser cannot use it. ChatGPT and
162
+ Microsoft 365 Copilot connect to remote MCP servers. A remote Redactic server is in preparation.
163
+
164
+ A file that you upload into a web chat reaches the model as it is. Mask it in Redactic first,
165
+ then upload the masked copy.
166
+
167
+ ## Settings
168
+
169
+ | Variable | Meaning |
170
+ |---|---|
171
+ | `REDACTIC_API_KEY` | Your API key. Required. |
172
+ | `REDACTIC_ALLOWED_DIRS` | The folders in which the file tools may read and write. Separate several folders with `:` on macOS and Linux and with `;` on Windows. Folders can also be given as command line arguments after the name of the server, one argument each; the Claude Desktop extension passes them this way. Without any folder the file tools answer with the instruction to set one. |
173
+ | `REDACTIC_REGIONS` | Default countries of your documents, for example `IT,DE` (at most four). Without it Redactic detects the country. |
174
+ | `REDACTIC_LANGUAGE` | Default language hint, for example `it`. |
175
+ | `REDACTIC_DETECTION_MODE` | Default detection mode: `accurate` or `fast`. |
176
+ | `REDACTIC_RESTORE_INLINE` | Set to `1` to receive restored text in the conversation. The model then reads the original values. |
177
+ | `REDACTIC_API_URL` | Address of the API, for test environments. |
178
+
179
+ ## Tools
180
+
181
+ | Tool | What it does |
182
+ |---|---|
183
+ | `mask_text` | Masks a text and returns the masked text with a `session_id`. Pass the same `session_id` again and each person keeps the same label for the whole conversation. |
184
+ | `restore_text` | Puts the values back into a text with labels and writes the result to a new file. It returns the path of the file. |
185
+ | `mask_file` | Masks a file and saves `<name> (masked)` next to the original. It returns a `session_id` for `restore_file`. |
186
+ | `restore_file` | Puts the values back into a masked text, Word or Excel file and saves `<name> (restored)`. It works on your computer and does not call the API. |
187
+ | `redact_file` | Blacks out a PDF or an image for good and saves `<name> (redacted)` with its certificate. |
188
+ | `check_file` | Checks a PDF or Word file for hidden data: text under shapes, metadata, older revisions, comments, attachments. |
189
+ | `get_usage` | Returns the usage of the month and the limits of the workspace. |
190
+
191
+ Every masking tool accepts `detection_mode`: `accurate` (the default) takes a second look at
192
+ places, addresses and organisations, and `fast` skips it and answers sooner.
193
+
194
+ ## How your data is handled
195
+
196
+ - A text is processed in memory on Redactic servers in the EU and is gone when the answer is sent.
197
+ - A file is deleted from the Redactic servers as soon as the server has saved the result on your
198
+ computer.
199
+ - The restore map is kept in the memory of this server for eight hours after its last use. It is
200
+ never written to disk, never logged and never part of a tool result. When the server stops, the
201
+ map is gone, so restore what you need before you close your MCP client.
202
+ - `restore_text` writes to a file and returns its path, so the original values stay out of the
203
+ conversation.
204
+ - No tool overwrites a file. A new file gets a new name, with a number when the name is taken.
205
+ - A masked PDF cannot be restored. Keep the original PDF when you may need its values again.
206
+
207
+ ## Limits
208
+
209
+ One document is processed at a time for each API key. When Redactic is busy the server waits and
210
+ tries again for up to a minute, then tells you when to retry. The monthly allowance is the one of
211
+ your Redactic plan; `get_usage` shows where you stand.
212
+
213
+ ## Development
214
+
215
+ ```bash
216
+ npm install
217
+ npm test
218
+ npm run build:mcpb
219
+ ```
220
+
221
+ The tests run against a fake API on localhost and need no key. `npm run build:mcpb` builds
222
+ `dist/redactic-<version>.mcpb` and prints its SHA-256. [RELEASE.md](RELEASE.md) lists the steps of
223
+ a release.
224
+
225
+ ## Licence
226
+
227
+ MIT, see [LICENSE](LICENSE).
package/manifest.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "manifest_version": "0.3",
3
+ "name": "redactic",
4
+ "display_name": "Redactic",
5
+ "version": "0.1.0",
6
+ "description": "Mask, restore and black out personal data in text and files.",
7
+ "long_description": "Redactic finds names, addresses, account numbers and national identifiers in text and documents and replaces them with labels, so an AI model can work on the content. The map that restores the values stays in this extension, on your computer. Detection runs on Redactic servers in the European Union.",
8
+ "author": {
9
+ "name": "Redactic",
10
+ "url": "https://redactic.eu"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/redactic/redactic-mcp"
15
+ },
16
+ "homepage": "https://redactic.eu/ai-assistants/",
17
+ "documentation": "https://redactic.eu/developers/#mcp",
18
+ "support": "https://github.com/redactic/redactic-mcp/issues",
19
+ "icon": "icon.png",
20
+ "server": {
21
+ "type": "node",
22
+ "entry_point": "src/index.js",
23
+ "mcp_config": {
24
+ "command": "node",
25
+ "args": [
26
+ "${__dirname}/src/index.js",
27
+ "${user_config.allowed_dirs}"
28
+ ],
29
+ "env": {
30
+ "REDACTIC_API_KEY": "${user_config.api_key}",
31
+ "REDACTIC_REGIONS": "${user_config.regions}"
32
+ }
33
+ }
34
+ },
35
+ "user_config": {
36
+ "api_key": {
37
+ "type": "string",
38
+ "title": "Redactic API key",
39
+ "description": "Create a key in Redactic under Admin, API. It starts with rk_live_.",
40
+ "sensitive": true,
41
+ "required": true
42
+ },
43
+ "allowed_dirs": {
44
+ "type": "directory",
45
+ "title": "Folders for files",
46
+ "description": "The folders in which Redactic may read your files and save the results.",
47
+ "multiple": true,
48
+ "required": false
49
+ },
50
+ "regions": {
51
+ "type": "string",
52
+ "title": "Countries of your documents",
53
+ "description": "Up to four country codes separated by commas, for example IT,DE. Leave empty to let Redactic detect the country.",
54
+ "required": false,
55
+ "default": ""
56
+ }
57
+ },
58
+ "tools": [
59
+ {
60
+ "name": "mask_text",
61
+ "description": "Mask the personal data of a text."
62
+ },
63
+ {
64
+ "name": "restore_text",
65
+ "description": "Put the original values back into a text, into a new file."
66
+ },
67
+ {
68
+ "name": "mask_file",
69
+ "description": "Mask the personal data of a file."
70
+ },
71
+ {
72
+ "name": "restore_file",
73
+ "description": "Put the original values back into a masked text, Word or Excel file."
74
+ },
75
+ {
76
+ "name": "redact_file",
77
+ "description": "Black out a PDF or an image for good."
78
+ },
79
+ {
80
+ "name": "check_file",
81
+ "description": "Check a PDF or Word file for hidden data."
82
+ },
83
+ {
84
+ "name": "get_usage",
85
+ "description": "Usage of the month and limits."
86
+ }
87
+ ],
88
+ "compatibility": {
89
+ "platforms": [
90
+ "darwin",
91
+ "win32"
92
+ ],
93
+ "runtimes": {
94
+ "node": ">=18.0.0"
95
+ }
96
+ },
97
+ "keywords": [
98
+ "redaction",
99
+ "pii",
100
+ "privacy",
101
+ "gdpr"
102
+ ],
103
+ "license": "MIT",
104
+ "privacy_policies": [
105
+ "https://redactic.eu/privacy-policy/"
106
+ ]
107
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@redactic/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "mcpName": "eu.redactic/mcp",
5
+ "description": "MCP server for Redactic (redactic.eu): mask, restore and black out personal data in text and files from Claude and other MCP clients.",
6
+ "type": "module",
7
+ "bin": {
8
+ "redactic-mcp": "src/index.js"
9
+ },
10
+ "main": "src/server.js",
11
+ "files": [
12
+ "src",
13
+ "README.md",
14
+ "LICENSE",
15
+ "manifest.json"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test test/*.test.js",
22
+ "build:mcpb": "node scripts/build-mcpb.mjs"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "redaction",
27
+ "pii",
28
+ "anonymization",
29
+ "gdpr",
30
+ "redactic"
31
+ ],
32
+ "homepage": "https://redactic.eu/ai-assistants/",
33
+ "bugs": {
34
+ "url": "https://github.com/redactic/redactic-mcp/issues"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/redactic/redactic-mcp.git"
39
+ },
40
+ "author": "Redactic (https://redactic.eu)",
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.20.0",
47
+ "fflate": "^0.8.2",
48
+ "zod": "^3.25.0"
49
+ },
50
+ "devDependencies": {
51
+ "@anthropic-ai/mcpb": "2.1.2"
52
+ }
53
+ }
package/src/api.js ADDED
@@ -0,0 +1,118 @@
1
+ // Thin HTTP client of the Redactic API. No detection logic lives here.
2
+ //
3
+ // A 429 (rate_limited, busy, job_in_progress) is retried after the seconds of Retry-After, for
4
+ // at most RETRY_BUDGET_MS in total; then the error goes back to the tool. Nothing a request or
5
+ // an answer carries is ever logged.
6
+ import { RETRY_BUDGET_MS, VERSION } from './config.js';
7
+
8
+ export class ApiError extends Error {
9
+ constructor(status, code, message, extra = {}) {
10
+ super(message || code);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.retryAfter = extra.retryAfter;
14
+ this.details = extra.details || {};
15
+ }
16
+ }
17
+
18
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
+
20
+ export class RedacticApi {
21
+ constructor({ apiKey, apiUrl, fetchImpl = fetch, sleepImpl = sleep, retryBudgetMs = RETRY_BUDGET_MS }) {
22
+ this.apiKey = apiKey;
23
+ this.apiUrl = apiUrl;
24
+ this.fetch = fetchImpl;
25
+ this.sleep = sleepImpl;
26
+ this.retryBudgetMs = retryBudgetMs;
27
+ }
28
+
29
+ async request(method, route, { json, form, accept, raw = false, retry = true } = {}) {
30
+ if (!this.apiKey) {
31
+ throw new ApiError(0, 'missing_key', 'REDACTIC_API_KEY is not set. Create a key in Redactic under Admin, API, '
32
+ + 'and add it to the configuration of this MCP server.');
33
+ }
34
+ let waited = 0;
35
+ for (;;) {
36
+ const headers = { Authorization: `Bearer ${this.apiKey}`, 'User-Agent': `redactic-mcp/${VERSION}` };
37
+ let body;
38
+ if (json !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(json); }
39
+ if (form !== undefined) body = form(); // a fresh FormData for each attempt
40
+ if (accept) headers.Accept = accept;
41
+ let response;
42
+ try {
43
+ response = await this.fetch(this.apiUrl + route, { method, headers, body });
44
+ } catch {
45
+ throw new ApiError(0, 'network_error', 'The Redactic API could not be reached. Check the network connection.');
46
+ }
47
+ if (response.ok) return raw ? response : response.json();
48
+ const error = await toError(response);
49
+ if (response.status === 429 && retry) {
50
+ const pause = Math.max(1, error.retryAfter || 2) * 1000;
51
+ if (waited + pause <= this.retryBudgetMs) {
52
+ waited += pause;
53
+ await this.sleep(pause);
54
+ continue;
55
+ }
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ maskText(payload) { return this.request('POST', '/text/mask', { json: payload }); }
62
+
63
+ submitDocument({ bytes, name, options }) {
64
+ return this.request('POST', '/documents', {
65
+ form: () => {
66
+ const form = new FormData();
67
+ form.append('file', new Blob([bytes]), name);
68
+ form.append('options', new Blob([JSON.stringify(options)], { type: 'application/json' }));
69
+ return form;
70
+ },
71
+ });
72
+ }
73
+
74
+ document(id) { return this.request('GET', `/documents/${id}`); }
75
+
76
+ async download(id, kind) {
77
+ const response = await this.request('GET', `/documents/${id}/${kind}`, { raw: true });
78
+ return {
79
+ bytes: new Uint8Array(await response.arrayBuffer()),
80
+ name: fileNameOf(response.headers.get('content-disposition')),
81
+ contentType: response.headers.get('content-type') || '',
82
+ };
83
+ }
84
+
85
+ documentMap(id) { return this.request('GET', `/documents/${id}/map`); }
86
+
87
+ deleteDocument(id) { return this.request('DELETE', `/documents/${id}`, { retry: false }); }
88
+
89
+ check({ bytes, name }) {
90
+ return this.request('POST', '/check', {
91
+ form: () => { const form = new FormData(); form.append('file', new Blob([bytes]), name); return form; },
92
+ });
93
+ }
94
+
95
+ usage() { return this.request('GET', '/usage'); }
96
+ }
97
+
98
+ async function toError(response) {
99
+ let code = 'http_' + response.status;
100
+ let message = `The Redactic API answered ${response.status}.`;
101
+ let details = {};
102
+ try {
103
+ const body = await response.json();
104
+ if (body && body.error && typeof body.error.code === 'string') {
105
+ ({ code, message, ...details } = body.error);
106
+ }
107
+ } catch { /* not JSON: keep the generic message */ }
108
+ const retryAfter = Number.parseInt(response.headers.get('retry-after') || '', 10);
109
+ return new ApiError(response.status, code, message, { retryAfter: Number.isFinite(retryAfter) ? retryAfter : undefined, details });
110
+ }
111
+
112
+ export function fileNameOf(disposition) {
113
+ if (!disposition) return undefined;
114
+ const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(disposition);
115
+ if (star) { try { return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')); } catch { /* fall through */ } }
116
+ const plain = /filename="?([^";]+)"?/i.exec(disposition);
117
+ return plain ? plain[1].trim() : undefined;
118
+ }
package/src/config.js ADDED
@@ -0,0 +1,47 @@
1
+ // Configuration of the server, read once from the environment and the command line.
2
+ import path from 'node:path';
3
+
4
+ export const VERSION = '0.1.0';
5
+ export const DEFAULT_API_URL = 'https://redactic.eu/api/v1';
6
+ export const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // a restore map lives 8 hours after its last use
7
+ export const RETRY_BUDGET_MS = 60 * 1000; // how long a 429 is retried before the tool answers
8
+ export const POLL_BUDGET_MS = 90 * 60 * 1000; // how long a document is waited for
9
+
10
+ // Claude Desktop leaves `${user_config.x}` as it is when the user left that setting empty.
11
+ const PLACEHOLDER = /^\$\{[^}]+\}$/;
12
+
13
+ function clean(value) {
14
+ const text = (value || '').trim();
15
+ return PLACEHOLDER.test(text) ? '' : text;
16
+ }
17
+
18
+ /**
19
+ * The allowed folders, absolute and without duplicates. `fromEnv` is REDACTIC_ALLOWED_DIRS
20
+ * (several folders separated by the path delimiter of the system: ":" or ";" on Windows), `fromArgs`
21
+ * the command line arguments, one folder each: the Claude Desktop extension passes the folders
22
+ * chosen in its settings this way. `p` is `path` or, in tests, `path.win32` / `path.posix`.
23
+ */
24
+ export function allowedDirsFrom(fromEnv, fromArgs = [], p = path) {
25
+ const candidates = [...clean(fromEnv).split(p.delimiter), ...fromArgs];
26
+ const dirs = [];
27
+ for (const candidate of candidates) {
28
+ const dir = clean(candidate);
29
+ if (!dir) continue;
30
+ const resolved = p.resolve(dir);
31
+ if (!dirs.includes(resolved)) dirs.push(resolved);
32
+ }
33
+ return dirs;
34
+ }
35
+
36
+ export function readConfig(env = process.env, argv = []) {
37
+ const mode = clean(env.REDACTIC_DETECTION_MODE);
38
+ return {
39
+ apiKey: clean(env.REDACTIC_API_KEY),
40
+ apiUrl: (clean(env.REDACTIC_API_URL) || DEFAULT_API_URL).replace(/\/+$/, ''),
41
+ allowedDirs: allowedDirsFrom(env.REDACTIC_ALLOWED_DIRS, argv),
42
+ regions: clean(env.REDACTIC_REGIONS).split(/[\s,;]+/).map((r) => r.trim().toUpperCase()).filter(Boolean),
43
+ language: clean(env.REDACTIC_LANGUAGE) || undefined,
44
+ detectionMode: mode === 'fast' || mode === 'accurate' ? mode : undefined,
45
+ restoreInline: clean(env.REDACTIC_RESTORE_INLINE) === '1',
46
+ };
47
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // Entry point: the server on stdio. stdout belongs to the protocol; nothing else is written to it.
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { readConfig } from './config.js';
5
+ import { createServer } from './server.js';
6
+
7
+ // Every command line argument is an allowed folder (the Claude Desktop extension passes them so).
8
+ const server = createServer(readConfig(process.env, process.argv.slice(2)));
9
+ await server.connect(new StdioServerTransport());
package/src/paths.js ADDED
@@ -0,0 +1,72 @@
1
+ // Which files the tools may touch, and how a new file is named.
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const HOW_TO = 'In the Redactic extension of Claude Desktop, choose the folders under Folders for files in its settings. '
6
+ + 'In other clients, set REDACTIC_ALLOWED_DIRS in the configuration of this MCP server to the folders the file tools may use '
7
+ + `(several folders are separated by "${path.delimiter}"), then restart the client.`;
8
+
9
+ export function requireDirs(allowedDirs) {
10
+ if (!allowedDirs.length) throw new Error('The file tools are not set up yet. ' + HOW_TO);
11
+ }
12
+
13
+ async function real(target) {
14
+ try { return await fs.realpath(target); } catch { return null; }
15
+ }
16
+
17
+ /**
18
+ * Whether `target` is `dir` or below it. Both are absolute real paths. `p` is `path` or, in tests,
19
+ * `path.win32`: there the comparison ignores case, a path on another drive or share is outside, and
20
+ * `C:\data-old` is outside `C:\data`.
21
+ */
22
+ export function inside(dir, target, p = path) {
23
+ const relative = p.relative(dir, target);
24
+ if (relative === '') return true;
25
+ const first = relative.split(p.sep)[0];
26
+ return first !== '..' && !p.isAbsolute(relative);
27
+ }
28
+
29
+ /** The real path of an existing file inside an allowed folder. Symbolic links are followed first. */
30
+ export async function allowedFile(file, allowedDirs) {
31
+ requireDirs(allowedDirs);
32
+ if (typeof file !== 'string' || !path.isAbsolute(file)) throw new Error('Give the full path of the file.');
33
+ const target = await real(file);
34
+ if (!target) throw new Error('This file does not exist.');
35
+ for (const dir of allowedDirs) {
36
+ const root = await real(dir);
37
+ if (root && inside(root, target)) {
38
+ const stat = await fs.stat(target);
39
+ if (!stat.isFile()) throw new Error('This path is not a file.');
40
+ return target;
41
+ }
42
+ }
43
+ throw new Error('This file is outside the folders this MCP server may use. ' + HOW_TO);
44
+ }
45
+
46
+ /** `<name> (<tag>)<ext>` in `dir`, with a number when that name is taken: nothing is overwritten. */
47
+ export async function writeNew(dir, baseName, tag, extension, bytes) {
48
+ for (let index = 1; index < 1000; index += 1) {
49
+ const suffix = index === 1 ? '' : ` ${index}`;
50
+ const target = path.join(dir, `${baseName} (${tag}${suffix})${extension}`);
51
+ try {
52
+ await fs.writeFile(target, bytes, { flag: 'wx' });
53
+ return target;
54
+ } catch (error) {
55
+ if (error.code !== 'EEXIST') throw error;
56
+ }
57
+ }
58
+ throw new Error('Could not find a free file name.');
59
+ }
60
+
61
+ /** An output path the caller chose: inside an allowed folder and not existing yet. */
62
+ export async function allowedNewPath(file, allowedDirs) {
63
+ requireDirs(allowedDirs);
64
+ if (typeof file !== 'string' || !path.isAbsolute(file)) throw new Error('Give the full path of the output file.');
65
+ const parent = await real(path.dirname(file));
66
+ if (!parent) throw new Error('The folder of the output file does not exist.');
67
+ for (const dir of allowedDirs) {
68
+ const root = await real(dir);
69
+ if (root && inside(root, parent)) return path.join(parent, path.basename(file));
70
+ }
71
+ throw new Error('The output file is outside the folders this MCP server may use. ' + HOW_TO);
72
+ }
package/src/restore.js ADDED
@@ -0,0 +1,59 @@
1
+ // Putting the values back, on this computer. Nothing here calls the API.
2
+ import path from 'node:path';
3
+ import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
4
+ import { LABEL } from './sessions.js';
5
+
6
+ export const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.rst', '.csv', '.json', '.xml', '.html', '.htm']);
7
+ export const PACKAGE_EXTENSIONS = new Set(['.docx', '.xlsx']);
8
+
9
+ /** Replaces every label of `text` that the map knows. Returns the text and what was (not) found. */
10
+ export function restoreText(text, map, encode = (value) => value) {
11
+ let replaced = 0;
12
+ const unknown = new Set();
13
+ const out = text.replace(LABEL, (label) => {
14
+ if (map.has(label)) { replaced += 1; return encode(map.get(label)); }
15
+ unknown.add(label);
16
+ return label;
17
+ });
18
+ return { text: out, replaced, unknown: unknown.size };
19
+ }
20
+
21
+ const xmlEscape = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
22
+
23
+ /** A masked .docx or .xlsx with its values back: the labels are replaced inside the XML parts. */
24
+ export function restorePackage(bytes, map) {
25
+ const files = unzipSync(bytes);
26
+ let replaced = 0;
27
+ let unknown = 0;
28
+ for (const name of Object.keys(files)) {
29
+ if (!/\.(xml|rels)$/i.test(name)) continue;
30
+ const xml = strFromU8(files[name]);
31
+ if (!xml.includes('⟦')) continue;
32
+ const result = restoreText(xml, map, xmlEscape);
33
+ replaced += result.replaced;
34
+ unknown += result.unknown;
35
+ files[name] = strToU8(result.text);
36
+ }
37
+ // [Content_Types].xml first and stored, as Office writes it.
38
+ const ordered = {};
39
+ for (const name of Object.keys(files).sort((a, b) => (a === '[Content_Types].xml' ? -1 : b === '[Content_Types].xml' ? 1 : 0))) {
40
+ ordered[name] = files[name];
41
+ }
42
+ return { bytes: zipSync(ordered, { level: 6 }), replaced, unknown };
43
+ }
44
+
45
+ export function restoreFile(bytes, extension, map) {
46
+ const ext = extension.toLowerCase();
47
+ if (TEXT_EXTENSIONS.has(ext)) {
48
+ const result = restoreText(new TextDecoder('utf-8').decode(bytes), map);
49
+ return { bytes: new TextEncoder().encode(result.text), replaced: result.replaced, unknown: result.unknown };
50
+ }
51
+ if (PACKAGE_EXTENSIONS.has(ext)) return restorePackage(bytes, map);
52
+ if (ext === '.pdf') {
53
+ throw new Error('A masked PDF cannot be restored. Keep the original PDF when you may need its values again. '
54
+ + 'Text, Word and Excel files can be restored.');
55
+ }
56
+ throw new Error(`Files of type ${ext || '(none)'} cannot be restored. Text, Word and Excel files can.`);
57
+ }
58
+
59
+ export const extensionOf = (file) => path.extname(file).toLowerCase();
package/src/server.js ADDED
@@ -0,0 +1,251 @@
1
+ // The MCP server: seven tools over the Redactic API.
2
+ //
3
+ // What reaches the model: masked text, counts, file paths, a session id. What never does: a
4
+ // restore map, and (unless REDACTIC_RESTORE_INLINE=1) restored text.
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { z } from 'zod';
9
+ import { ApiError, RedacticApi } from './api.js';
10
+ import { POLL_BUDGET_MS, VERSION } from './config.js';
11
+ import { allowedFile, allowedNewPath, requireDirs, writeNew } from './paths.js';
12
+ import { extensionOf, restoreFile, restoreText } from './restore.js';
13
+ import { SessionStore } from './sessions.js';
14
+
15
+ const JOB_MESSAGES = {
16
+ monthly_cap: 'The monthly document limit of the plan has been reached.',
17
+ doc_too_large: 'The document has more pages than the plan allows for one document.',
18
+ input_expired: 'The document waited too long for a free slot. Try again.',
19
+ encrypted_pdf: 'The PDF is protected by a password. Remove the password and try again.',
20
+ redaction_incomplete: 'The produced file still held a detected value, so it was not delivered.',
21
+ redaction_unverifiable: 'The produced file could not be checked, so it was not delivered.',
22
+ ocr_failed: 'A page could not be read.',
23
+ timeout: 'The document took too long to process.',
24
+ };
25
+
26
+ const detectionMode = z.enum(['accurate', 'fast']).optional().describe(
27
+ 'How thoroughly the text is read. "accurate" (default) takes a second look at places, addresses and organisations. '
28
+ + '"fast" skips it and answers sooner.');
29
+ const maskingOptions = {
30
+ detection_mode: detectionMode,
31
+ regions: z.array(z.string()).max(4).optional().describe('Countries of the document as ISO codes, for example ["IT","DE"]. Leave out to detect the country.'),
32
+ language: z.string().optional().describe('Language of the text as a two-letter code, as a hint.'),
33
+ types: z.array(z.string()).optional().describe('Entity types to mask, for example ["PERSON","IBAN"]. Leave out for all types.'),
34
+ names: z.array(z.string()).optional().describe('Names that are always masked.'),
35
+ keep: z.array(z.string()).optional().describe('Values that stay in clear.'),
36
+ };
37
+
38
+ const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
39
+ const failure = (message) => ({ isError: true, content: [{ type: 'text', text: message }] });
40
+
41
+ function describe(error) {
42
+ if (error instanceof ApiError) {
43
+ if (error.status === 429) {
44
+ const wait = error.retryAfter ? ` Try again in ${error.retryAfter} seconds.` : ' Try again in a minute.';
45
+ return `Redactic is busy (${error.code}).${wait}`;
46
+ }
47
+ return `${error.message} (${error.code})`;
48
+ }
49
+ return error && error.message ? error.message : 'The operation failed.';
50
+ }
51
+
52
+ export function createServer(config, deps = {}) {
53
+ const api = deps.api || new RedacticApi(config);
54
+ const sessions = deps.sessions || new SessionStore();
55
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
56
+ const server = new McpServer({ name: 'redactic', version: VERSION });
57
+
58
+ const options = (args) => {
59
+ const out = {};
60
+ const mode = args.detection_mode || config.detectionMode;
61
+ if (mode) out.detection_mode = mode;
62
+ const regions = args.regions && args.regions.length ? args.regions : config.regions;
63
+ if (regions && regions.length) out.regions = regions;
64
+ const language = args.language || config.language;
65
+ if (language) out.language = language;
66
+ for (const name of ['types', 'names', 'keep']) if (args[name] && args[name].length) out[name] = args[name];
67
+ return out;
68
+ };
69
+
70
+ const tool = (name, definition, handler) => server.registerTool(name, definition, async (args) => {
71
+ try {
72
+ return await handler(args || {});
73
+ } catch (error) {
74
+ return failure(describe(error));
75
+ }
76
+ });
77
+
78
+ /** Runs one document through the API and always deletes it on the server afterwards. */
79
+ async function runDocument(file, documentOptions, collect) {
80
+ const bytes = await fs.readFile(file);
81
+ const submitted = await api.submitDocument({ bytes, name: path.basename(file), options: documentOptions });
82
+ try {
83
+ const started = Date.now();
84
+ let state = await api.document(submitted.id);
85
+ while (state.status === 'queued' || state.status === 'processing') {
86
+ if (Date.now() - started > POLL_BUDGET_MS) throw new Error('The document is still being processed. Try again later.');
87
+ await sleep(2000);
88
+ state = await api.document(submitted.id);
89
+ }
90
+ if (state.status === 'error') {
91
+ const code = state.error && state.error.code;
92
+ throw new Error(`${JOB_MESSAGES[code] || 'The document could not be processed.'} (${code || 'error'})`);
93
+ }
94
+ if (!state.file_available) {
95
+ const code = state.file_error && state.file_error.code;
96
+ throw new Error(`${JOB_MESSAGES[code] || 'The produced file failed its final check and was not delivered.'} (${code || 'no_file'})`);
97
+ }
98
+ return await collect(submitted.id, state);
99
+ } finally {
100
+ try { await api.deleteDocument(submitted.id); } catch { /* it expires on its own */ }
101
+ }
102
+ }
103
+
104
+ tool('mask_text', {
105
+ title: 'Mask text',
106
+ description: 'Replaces personal data in a text with labels such as ⟦PERSON_1⟧ and returns the masked text. '
107
+ + 'The text is sent to Redactic in the EU for detection. The map that restores the values stays in this MCP server '
108
+ + 'and is never returned. Pass the returned session_id to later calls of the same conversation so that the same '
109
+ + 'person keeps the same label, and to restore_text. Keep the labels unchanged when you rewrite the text.',
110
+ inputSchema: {
111
+ text: z.string().min(1).describe('The text to mask.'),
112
+ session_id: z.string().optional().describe('The session of an earlier mask_text call, to keep labels consistent.'),
113
+ ...maskingOptions,
114
+ },
115
+ annotations: { readOnlyHint: true, openWorldHint: true },
116
+ }, async (args) => {
117
+ const session = sessions.open(args.session_id);
118
+ const answer = await api.maskText({ text: args.text, mode: 'reversible', ...options(args) });
119
+ const masked = sessions.merge(session, answer.masked_text, answer.map);
120
+ return text({ masked_text: masked, entities: answer.entities, detection_mode: answer.detection_mode, session_id: session.id });
121
+ });
122
+
123
+ tool('restore_text', {
124
+ title: 'Restore text',
125
+ description: 'Puts the original values back into a text that contains labels of a session, and writes the result to a '
126
+ + 'file. Returns the path of the file, so the original values do not enter the conversation. The work is done on '
127
+ + 'this computer; nothing is sent to Redactic.',
128
+ inputSchema: {
129
+ text: z.string().min(1).describe('The text with labels.'),
130
+ session_id: z.string().describe('The session_id returned by mask_text.'),
131
+ output_path: z.string().optional().describe('Full path of the file to write. It must not exist yet. Default: a new file in the first allowed folder.'),
132
+ },
133
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
134
+ }, async (args) => {
135
+ const session = sessions.open(args.session_id);
136
+ const result = restoreText(args.text, session.map);
137
+ if (config.restoreInline) {
138
+ return text({ restored_text: result.text, replaced: result.replaced, labels_not_found: result.unknown });
139
+ }
140
+ const bytes = new TextEncoder().encode(result.text);
141
+ let written;
142
+ if (args.output_path) {
143
+ const target = await allowedNewPath(args.output_path, config.allowedDirs);
144
+ try {
145
+ await fs.writeFile(target, bytes, { flag: 'wx' });
146
+ } catch (error) {
147
+ if (error.code === 'EEXIST') throw new Error('The output file already exists. Choose another name.');
148
+ throw error;
149
+ }
150
+ written = target;
151
+ } else {
152
+ requireDirs(config.allowedDirs);
153
+ written = await writeNew(config.allowedDirs[0], 'text', 'restored', '.txt', bytes);
154
+ }
155
+ return text({ path: written, replaced: result.replaced, labels_not_found: result.unknown });
156
+ });
157
+
158
+ tool('mask_file', {
159
+ title: 'Mask a file',
160
+ description: 'Masks the personal data of a file (PDF, Word, Excel, CSV, text, image) and saves the masked copy next to '
161
+ + 'the original as "<name> (masked)". The file is sent to Redactic in the EU for detection and deleted there once '
162
+ + 'the copy is saved. Returns the path of the masked copy and a session_id for restore_file. The original file is '
163
+ + 'not changed.',
164
+ inputSchema: { path: z.string().describe('Full path of the file.'), ...maskingOptions },
165
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
166
+ }, async (args) => {
167
+ const file = await allowedFile(args.path, config.allowedDirs);
168
+ return runDocument(file, { mode: 'mask', reversible: true, ...options(args) }, async (id, state) => {
169
+ const produced = await api.download(id, 'file');
170
+ const session = sessions.open();
171
+ if (state.map_available) sessions.put(session, (await api.documentMap(id)).map);
172
+ const extension = extensionOf(produced.name || '') || extensionOf(file);
173
+ const written = await writeNew(path.dirname(file), path.basename(file, path.extname(file)), 'masked', extension, produced.bytes);
174
+ return text({ path: written, pages: state.pages, entities: state.entities, detection_mode: state.detection_mode, session_id: session.id });
175
+ });
176
+ });
177
+
178
+ tool('restore_file', {
179
+ title: 'Restore a file',
180
+ description: 'Puts the original values back into a masked text, Word (.docx) or Excel (.xlsx) file and saves the result '
181
+ + 'as "<name> (restored)". The work is done on this computer; nothing is sent to Redactic. A masked PDF cannot be restored.',
182
+ inputSchema: {
183
+ path: z.string().describe('Full path of the file that contains labels.'),
184
+ session_id: z.string().describe('The session_id returned by mask_file or mask_text.'),
185
+ },
186
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
187
+ }, async (args) => {
188
+ const file = await allowedFile(args.path, config.allowedDirs);
189
+ const session = sessions.open(args.session_id);
190
+ const result = restoreFile(await fs.readFile(file), extensionOf(file), session.map);
191
+ const base = path.basename(file, path.extname(file)).replace(/ \(masked(?: \d+)?\)$/, '');
192
+ const written = await writeNew(path.dirname(file), base, 'restored', path.extname(file), result.bytes);
193
+ return text({ path: written, replaced: result.replaced, labels_not_found: result.unknown });
194
+ });
195
+
196
+ tool('redact_file', {
197
+ title: 'Black out a file',
198
+ description: 'Removes the personal data of a PDF or an image for good and draws black boxes in its place, then saves '
199
+ + 'the result as "<name> (redacted)" with its certificate. This cannot be restored. The file is sent to Redactic in '
200
+ + 'the EU and deleted there once the result is saved. The original file is not changed.',
201
+ inputSchema: {
202
+ path: z.string().describe('Full path of the PDF or image.'),
203
+ blackout_images: z.boolean().optional().describe('Also black out every embedded picture, such as photos, signatures and logos.'),
204
+ ...maskingOptions,
205
+ },
206
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
207
+ }, async (args) => {
208
+ const file = await allowedFile(args.path, config.allowedDirs);
209
+ const documentOptions = { mode: 'blackout', ...options(args) };
210
+ if (args.blackout_images) documentOptions.blackout_images = true;
211
+ return runDocument(file, documentOptions, async (id, state) => {
212
+ const produced = await api.download(id, 'file');
213
+ const base = path.basename(file, path.extname(file));
214
+ const dir = path.dirname(file);
215
+ const result = { path: await writeNew(dir, base, 'redacted', extensionOf(produced.name || '') || extensionOf(file), produced.bytes) };
216
+ if (state.certificate_available) {
217
+ const certificate = await api.download(id, 'certificate');
218
+ result.certificate_path = await writeNew(dir, base, 'certificate', extensionOf(certificate.name || '') || '.pdf', certificate.bytes);
219
+ }
220
+ return text({ ...result, pages: state.pages, entities: state.entities, detection_mode: state.detection_mode });
221
+ });
222
+ });
223
+
224
+ tool('check_file', {
225
+ title: 'Check a file for hidden data',
226
+ description: 'Looks inside a PDF or Word file for data that a reader does not see and a recipient can still extract: '
227
+ + 'text under a covering shape, unapplied redactions, invisible text, document properties, older revisions, '
228
+ + 'attachments, comments and tracked changes. Excerpts come back masked. The file is sent to Redactic in the EU and '
229
+ + 'is not stored.',
230
+ inputSchema: { path: z.string().describe('Full path of the PDF or .docx file.') },
231
+ annotations: { readOnlyHint: true, openWorldHint: true },
232
+ }, async (args) => {
233
+ const file = await allowedFile(args.path, config.allowedDirs);
234
+ const report = await api.check({ bytes: await fs.readFile(file), name: path.basename(file) });
235
+ delete report.request_id;
236
+ return text(report);
237
+ });
238
+
239
+ tool('get_usage', {
240
+ title: 'Usage of the month',
241
+ description: 'Returns the plan of the Redactic workspace, the documents and text used this month, and the limits.',
242
+ inputSchema: {},
243
+ annotations: { readOnlyHint: true, openWorldHint: true },
244
+ }, async () => {
245
+ const usage = await api.usage();
246
+ delete usage.request_id;
247
+ return text(usage);
248
+ });
249
+
250
+ return server;
251
+ }
@@ -0,0 +1,115 @@
1
+ // Restore maps, in the memory of this process only.
2
+ //
3
+ // A session holds one map {label: value}. It is never written to disk, never logged and never
4
+ // part of a tool result. It expires SESSION_TTL_MS after its last use and with the process.
5
+ //
6
+ // The API numbers the labels of each call from 1, so two calls of one conversation can both
7
+ // use ⟦PERSON_1⟧ for different people. `merge` renumbers the labels of a new call against the
8
+ // session: a value the session already knows keeps its label, a new value gets a free one.
9
+ import { randomUUID } from 'node:crypto';
10
+ import { SESSION_TTL_MS } from './config.js';
11
+
12
+ export const LABEL = /⟦([A-Z_]+)_(\d+)(?:\.(\d+))?⟧/g;
13
+
14
+ function parse(label) {
15
+ const match = /^⟦([A-Z_]+)_(\d+)(?:\.(\d+))?⟧$/.exec(label);
16
+ return match ? { code: match[1], number: Number(match[2]), variant: match[3] ? Number(match[3]) : 1 } : null;
17
+ }
18
+
19
+ const format = (code, number, variant) => (variant > 1 ? `⟦${code}_${number}.${variant}⟧` : `⟦${code}_${number}⟧`);
20
+
21
+ export class SessionStore {
22
+ constructor({ ttlMs = SESSION_TTL_MS, now = () => Date.now() } = {}) {
23
+ this.ttlMs = ttlMs;
24
+ this.now = now;
25
+ this.sessions = new Map();
26
+ }
27
+
28
+ sweep() {
29
+ const limit = this.now() - this.ttlMs;
30
+ for (const [id, session] of this.sessions) if (session.usedAt < limit) this.sessions.delete(id);
31
+ }
32
+
33
+ /** The session of `id`, or a new one when `id` is empty. Throws for an id that is unknown or expired. */
34
+ open(id) {
35
+ this.sweep();
36
+ if (!id) {
37
+ const session = { id: randomUUID(), map: new Map(), usedAt: this.now() };
38
+ this.sessions.set(session.id, session);
39
+ return session;
40
+ }
41
+ const session = this.sessions.get(id);
42
+ if (!session) {
43
+ throw new Error('This session_id is unknown or has expired. The restore map of a session is kept in memory '
44
+ + 'for eight hours after its last use and is gone when the MCP server restarts.');
45
+ }
46
+ session.usedAt = this.now();
47
+ return session;
48
+ }
49
+
50
+ /** Adds the map of one API answer to the session and returns the masked text with the session's labels. */
51
+ merge(session, maskedText, apiMap) {
52
+ const byValue = new Map(); // value -> label, of the session
53
+ const used = new Map(); // code -> Set of numbers in use
54
+ const variants = new Map(); // "CODE_N" -> Set of variants in use
55
+ const note = (label) => {
56
+ const p = parse(label);
57
+ if (!p) return;
58
+ if (!used.has(p.code)) used.set(p.code, new Set());
59
+ used.get(p.code).add(p.number);
60
+ const base = `${p.code}_${p.number}`;
61
+ if (!variants.has(base)) variants.set(base, new Set());
62
+ variants.get(base).add(p.variant);
63
+ };
64
+ for (const [label, value] of session.map) { byValue.set(value, label); note(label); }
65
+
66
+ const groups = new Map(); // base of the API answer -> [[label, value]]
67
+ for (const [label, value] of Object.entries(apiMap || {})) {
68
+ const p = parse(label);
69
+ if (!p || typeof value !== 'string') continue;
70
+ const base = `${p.code}_${p.number}`;
71
+ if (!groups.has(base)) groups.set(base, { code: p.code, entries: [] });
72
+ groups.get(base).entries.push({ label, value, variant: p.variant });
73
+ }
74
+
75
+ const rename = new Map(); // label of the API answer -> label of the session
76
+ for (const group of groups.values()) {
77
+ group.entries.sort((a, b) => a.variant - b.variant);
78
+ // The identity is known when the session already holds one of its written forms.
79
+ const known = group.entries.map((e) => parse(byValue.get(e.value) || '')).find((p) => p && p.code === group.code);
80
+ let number;
81
+ if (known) {
82
+ number = known.number;
83
+ } else {
84
+ const taken = used.get(group.code) || new Set();
85
+ number = 1;
86
+ while (taken.has(number)) number += 1;
87
+ }
88
+ for (const entry of group.entries) {
89
+ let label = byValue.get(entry.value);
90
+ const current = label ? parse(label) : null;
91
+ if (!current || current.code !== group.code || current.number !== number) {
92
+ const base = `${group.code}_${number}`;
93
+ const takenVariants = variants.get(base) || new Set();
94
+ let variant = 1;
95
+ while (takenVariants.has(variant)) variant += 1;
96
+ label = format(group.code, number, variant);
97
+ note(label);
98
+ session.map.set(label, entry.value);
99
+ byValue.set(entry.value, label);
100
+ }
101
+ rename.set(entry.label, label);
102
+ }
103
+ }
104
+ session.usedAt = this.now();
105
+ return maskedText.replace(LABEL, (label) => rename.get(label) || label);
106
+ }
107
+
108
+ /** Stores a map as it is (a file has its own session). */
109
+ put(session, apiMap) {
110
+ for (const [label, value] of Object.entries(apiMap || {})) {
111
+ if (parse(label) && typeof value === 'string') session.map.set(label, value);
112
+ }
113
+ session.usedAt = this.now();
114
+ }
115
+ }