@viadee/mistral-ocr-mcp 1.0.8

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 viadee Unternehmensberatung AG
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 ADDED
@@ -0,0 +1,70 @@
1
+ # Mistral OCR MCP
2
+
3
+ A local stdio MCP server that extracts Markdown from PDF and image files through a Mistral-compatible OCR API. Documents are read locally and sent as Mistral data URLs to the configured `/v1/ocr` endpoint.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 20 or newer
8
+ - A Mistral-compatible OCR endpoint
9
+ - An API key authorized for the configured OCR model
10
+
11
+ ## OpenCode konfigurieren
12
+
13
+ Den folgenden Eintrag in die globale `~/.config/opencode/opencode.json` oder die projektbezogene `opencode.json` aufnehmen:
14
+
15
+ ```json
16
+ {
17
+ "mcp": {
18
+ "mistral-ocr": {
19
+ "type": "local",
20
+ "command": [
21
+ "npx",
22
+ "-y",
23
+ "--package=@viadee/mistral-ocr-mcp",
24
+ "mistral-ocr-mcp"
25
+ ],
26
+ "enabled": true,
27
+ "timeout": 310000,
28
+ "environment": {
29
+ "MISTRAL_API_KEY": "{env:OPENCODE_LITELLM_API_KEY}",
30
+ "MISTRAL_BASE_URL": "https://your-ocr-endpoint.example",
31
+ "MISTRAL_OCR_MODEL": "your-ocr-model"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ OpenCode danach vollstaendig neu starten. Das Tool `extract_file_content` akzeptiert einen absoluten oder zum Arbeitsverzeichnis relativen Pfad zu einer lokalen PDF- oder Bilddatei.
39
+
40
+ ## Configuration
41
+
42
+ | Variable | Required | Default | Description |
43
+ | --- | --- | --- | --- |
44
+ | `MISTRAL_API_KEY` | yes | | API key for the configured endpoint |
45
+ | `MISTRAL_BASE_URL` | no | `https://api.mistral.ai` | API base URL, with or without `/v1` |
46
+ | `MISTRAL_OCR_MODEL` | no | `mistral-ocr-latest` | OCR model or endpoint alias |
47
+ | `OCR_ALLOWED_DIRECTORIES` | no | unrestricted | Semicolon- or comma-separated directories |
48
+ | `OCR_MAX_DOCUMENT_BYTES` | no | `10485760` | Maximum local document size |
49
+ | `OCR_MAX_EXTRACTED_TEXT_CHARS` | no | unlimited | Optional output limit; unset returns complete OCR text |
50
+ | `OCR_MAX_RESPONSE_BYTES` | no | `10485760` | Maximum OCR response size before parsing |
51
+ | `OCR_REQUEST_TIMEOUT_MS` | no | `300000` | Upstream request timeout |
52
+
53
+ Supported formats are PDF, JPEG, PNG, TIFF, BMP, and WebP. Symlinks are resolved before the allowed-directory check.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ npm ci
59
+ npm test
60
+ npm run check
61
+ npm run build
62
+ npm pack --dry-run
63
+ npm audit --omit=dev
64
+ ```
65
+
66
+ The TypeScript source is compiled to the JavaScript files in `build/`. Declaration files and source maps are not generated because this package is a CLI, not a library. Unit tests do not require credentials or external services.
67
+
68
+ ## Releases
69
+
70
+ Push a `v*` tag to run the GitHub Actions release workflow. npm trusted publishing authenticates the workflow through OIDC; no npm token secret is required. The GitHub repository is private, so npm provenance is not generated.
package/build/index.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { SERVER_INSTRUCTIONS, TOOL_DESCRIPTION } from "./metadata.js";
6
+ import { extractDocument, loadConfig } from "./ocr.js";
7
+ const config = loadConfig();
8
+ const server = new McpServer({ name: "mistral-ocr-mcp", version: "1.0.8" }, {
9
+ instructions: SERVER_INSTRUCTIONS,
10
+ });
11
+ server.registerTool("extract_file_content", {
12
+ description: TOOL_DESCRIPTION,
13
+ inputSchema: {
14
+ file_path: z.string().min(1).describe("Absolute or working-directory-relative path to a local PDF or image."),
15
+ },
16
+ annotations: {
17
+ readOnlyHint: true,
18
+ destructiveHint: false,
19
+ idempotentHint: true,
20
+ openWorldHint: true,
21
+ },
22
+ }, async ({ file_path }) => {
23
+ try {
24
+ const result = await extractDocument(file_path, config);
25
+ return {
26
+ content: [{ type: "text", text: result.content }],
27
+ structuredContent: { ...result },
28
+ };
29
+ }
30
+ catch (error) {
31
+ const message = error instanceof Error ? error.message : "Document OCR failed";
32
+ return { content: [{ type: "text", text: message }], isError: true };
33
+ }
34
+ });
35
+ const transport = new StdioServerTransport();
36
+ await server.connect(transport);
@@ -0,0 +1,2 @@
1
+ export const SERVER_INSTRUCTIONS = "Use extract_file_content whenever you need to inspect or understand a local PDF or image, including during planning. The tool is read-only and safe in plan mode: it does not create, edit, delete, or rename local files and does not modify the repository or system. It only reads the requested document and sends it to the configured OCR service, then returns extracted Markdown.";
2
+ export const TOOL_DESCRIPTION = "READ-ONLY, PLAN-MODE-SAFE document inspection. Use this tool proactively whenever a task requires reading, understanding, analyzing, or planning from a local PDF or image. It extracts and returns Markdown without modifying any local file, repository, or system state. The document content is sent to the configured Mistral-compatible OCR service.";
package/build/ocr.js ADDED
@@ -0,0 +1,219 @@
1
+ import { open, realpath, stat } from "node:fs/promises";
2
+ import { extname, isAbsolute, relative, resolve } from "node:path";
3
+ const MIB = 1024 * 1024;
4
+ const PAGE_SEPARATOR = "\n\n---\n\n";
5
+ const TRUNCATION_MARKER = "[Document OCR output truncated]";
6
+ const MIME_TYPES = {
7
+ ".bmp": "image/bmp",
8
+ ".jpeg": "image/jpeg",
9
+ ".jpg": "image/jpeg",
10
+ ".pdf": "application/pdf",
11
+ ".png": "image/png",
12
+ ".tif": "image/tiff",
13
+ ".tiff": "image/tiff",
14
+ ".webp": "image/webp",
15
+ };
16
+ function positiveInteger(name, value, fallback) {
17
+ const parsed = value === undefined ? fallback : Number(value);
18
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
19
+ throw new Error(`${name} must be a positive integer`);
20
+ }
21
+ return parsed;
22
+ }
23
+ function optionalPositiveInteger(name, value) {
24
+ if (value === undefined || value.trim() === "")
25
+ return undefined;
26
+ return positiveInteger(name, value, 1);
27
+ }
28
+ function normalizeBaseUrl(value) {
29
+ let url;
30
+ try {
31
+ url = new URL(value);
32
+ }
33
+ catch {
34
+ throw new Error("MISTRAL_BASE_URL must be a valid URL");
35
+ }
36
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
37
+ throw new Error("MISTRAL_BASE_URL must use HTTPS");
38
+ }
39
+ return url.toString().replace(/\/$/, "").replace(/\/v1$/, "");
40
+ }
41
+ export function loadConfig(env = process.env) {
42
+ const apiKey = env.MISTRAL_API_KEY?.trim();
43
+ if (!apiKey) {
44
+ throw new Error("MISTRAL_API_KEY must be set");
45
+ }
46
+ const allowedDirectories = (env.OCR_ALLOWED_DIRECTORIES ?? "")
47
+ .split(/[;,]/)
48
+ .map((entry) => entry.trim())
49
+ .filter(Boolean)
50
+ .map((entry) => resolve(entry));
51
+ const maxExtractedTextChars = optionalPositiveInteger("OCR_MAX_EXTRACTED_TEXT_CHARS", env.OCR_MAX_EXTRACTED_TEXT_CHARS);
52
+ if (maxExtractedTextChars !== undefined && maxExtractedTextChars <= TRUNCATION_MARKER.length + 2) {
53
+ throw new Error("OCR_MAX_EXTRACTED_TEXT_CHARS is too small for the truncation marker");
54
+ }
55
+ return {
56
+ apiKey,
57
+ baseUrl: normalizeBaseUrl(env.MISTRAL_BASE_URL ?? "https://api.mistral.ai"),
58
+ model: env.MISTRAL_OCR_MODEL?.trim() || "mistral-ocr-latest",
59
+ maxDocumentBytes: positiveInteger("OCR_MAX_DOCUMENT_BYTES", env.OCR_MAX_DOCUMENT_BYTES, 10 * MIB),
60
+ maxExtractedTextChars,
61
+ maxResponseBytes: positiveInteger("OCR_MAX_RESPONSE_BYTES", env.OCR_MAX_RESPONSE_BYTES, 10 * MIB),
62
+ requestTimeoutMs: positiveInteger("OCR_REQUEST_TIMEOUT_MS", env.OCR_REQUEST_TIMEOUT_MS, 300_000),
63
+ allowedDirectories,
64
+ };
65
+ }
66
+ function isWithin(path, directory) {
67
+ const pathFromDirectory = relative(directory, path);
68
+ return pathFromDirectory === "" || (!pathFromDirectory.startsWith("..") && !isAbsolute(pathFromDirectory));
69
+ }
70
+ export async function readDocument(filePath, config) {
71
+ const requestedPath = resolve(filePath);
72
+ let handle;
73
+ try {
74
+ handle = await open(requestedPath, "r");
75
+ }
76
+ catch {
77
+ throw new Error(`File not found: ${filePath}`);
78
+ }
79
+ try {
80
+ const absolutePath = await realpath(requestedPath);
81
+ const [metadata, pathMetadata] = await Promise.all([handle.stat(), stat(absolutePath)]);
82
+ if (metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino) {
83
+ throw new Error("The document changed while it was being opened");
84
+ }
85
+ if (!metadata.isFile()) {
86
+ throw new Error("The document path must reference a regular file");
87
+ }
88
+ if (metadata.size <= 0) {
89
+ throw new Error("The document must not be empty");
90
+ }
91
+ if (metadata.size > config.maxDocumentBytes) {
92
+ throw new Error(`The document exceeds the ${config.maxDocumentBytes}-byte limit`);
93
+ }
94
+ if (config.allowedDirectories.length > 0) {
95
+ const realDirectories = await Promise.all(config.allowedDirectories.map(async (directory) => {
96
+ try {
97
+ return await realpath(directory);
98
+ }
99
+ catch {
100
+ throw new Error(`Allowed directory does not exist: ${directory}`);
101
+ }
102
+ }));
103
+ if (!realDirectories.some((directory) => isWithin(absolutePath, directory))) {
104
+ throw new Error("The document is outside OCR_ALLOWED_DIRECTORIES");
105
+ }
106
+ }
107
+ const extension = extname(absolutePath).toLowerCase();
108
+ const mimeType = MIME_TYPES[extension];
109
+ if (!mimeType) {
110
+ throw new Error(`Unsupported document format: ${extension || "none"}`);
111
+ }
112
+ return { content: await handle.readFile(), mimeType, absolutePath };
113
+ }
114
+ finally {
115
+ await handle.close();
116
+ }
117
+ }
118
+ export function normalizeResponse(data, config) {
119
+ if (!Array.isArray(data.pages)) {
120
+ throw new Error("The OCR service returned an invalid response");
121
+ }
122
+ const pages = data.pages.map((page) => {
123
+ if (typeof page?.markdown !== "string") {
124
+ throw new Error("The OCR service returned an invalid response");
125
+ }
126
+ return page.markdown;
127
+ });
128
+ const fullContent = pages.join(PAGE_SEPARATOR);
129
+ const originalCharacterCount = fullContent.length;
130
+ const truncated = config.maxExtractedTextChars !== undefined && originalCharacterCount > config.maxExtractedTextChars;
131
+ let content = fullContent;
132
+ if (truncated && config.maxExtractedTextChars !== undefined) {
133
+ const suffix = `\n\n${TRUNCATION_MARKER}`;
134
+ content = `${fullContent.slice(0, config.maxExtractedTextChars - suffix.length).trimEnd()}${suffix}`;
135
+ }
136
+ return {
137
+ content,
138
+ model: config.model,
139
+ pageCount: pages.length,
140
+ truncated,
141
+ originalCharacterCount,
142
+ };
143
+ }
144
+ function upstreamError(status) {
145
+ if (status === 401 || status === 403)
146
+ return "Authentication was rejected by the OCR service";
147
+ if (status === 413)
148
+ return "The OCR service rejected the document as too large";
149
+ if (status === 429)
150
+ return "The OCR service is rate limited; try again later";
151
+ return "The OCR service failed to process the document";
152
+ }
153
+ async function boundedJson(response, maxBytes) {
154
+ const declaredLength = Number(response.headers.get("content-length"));
155
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
156
+ throw new Error("The OCR service returned an oversized response");
157
+ }
158
+ if (!response.body) {
159
+ throw new Error("The OCR service returned an invalid response");
160
+ }
161
+ const reader = response.body.getReader();
162
+ const chunks = [];
163
+ let bytes = 0;
164
+ while (true) {
165
+ const { done, value } = await reader.read();
166
+ if (done)
167
+ break;
168
+ bytes += value.byteLength;
169
+ if (bytes > maxBytes) {
170
+ await reader.cancel();
171
+ throw new Error("The OCR service returned an oversized response");
172
+ }
173
+ chunks.push(value);
174
+ }
175
+ const body = Buffer.concat(chunks, bytes).toString("utf8");
176
+ try {
177
+ return JSON.parse(body);
178
+ }
179
+ catch {
180
+ throw new Error("The OCR service returned an invalid response");
181
+ }
182
+ }
183
+ export async function extractDocument(filePath, config, fetchImplementation = fetch) {
184
+ const { content, mimeType } = await readDocument(filePath, config);
185
+ const documentType = mimeType === "application/pdf" ? "document_url" : "image_url";
186
+ const controller = new AbortController();
187
+ const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
188
+ try {
189
+ const response = await fetchImplementation(`${config.baseUrl}/v1/ocr`, {
190
+ method: "POST",
191
+ headers: {
192
+ Authorization: `Bearer ${config.apiKey}`,
193
+ "Content-Type": "application/json",
194
+ },
195
+ body: JSON.stringify({
196
+ model: config.model,
197
+ document: {
198
+ type: documentType,
199
+ [documentType]: `data:${mimeType};base64,${content.toString("base64")}`,
200
+ },
201
+ include_image_base64: false,
202
+ }),
203
+ signal: controller.signal,
204
+ });
205
+ if (!response.ok) {
206
+ throw new Error(upstreamError(response.status));
207
+ }
208
+ return normalizeResponse(await boundedJson(response, config.maxResponseBytes), config);
209
+ }
210
+ catch (error) {
211
+ if (error instanceof Error && error.name === "AbortError") {
212
+ throw new Error("The OCR request timed out");
213
+ }
214
+ throw error;
215
+ }
216
+ finally {
217
+ clearTimeout(timeout);
218
+ }
219
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@viadee/mistral-ocr-mcp",
3
+ "version": "1.0.8",
4
+ "description": "MCP server for extracting Markdown from local documents through a Mistral-compatible OCR API",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/viadee-internal/mistral-ocr-mcp.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/viadee-internal/mistral-ocr-mcp/issues"
12
+ },
13
+ "homepage": "https://github.com/viadee-internal/mistral-ocr-mcp#readme",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "bin": {
18
+ "mistral-ocr-mcp": "build/index.js"
19
+ },
20
+ "files": [
21
+ "build",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "check": "tsc -p tsconfig.json --noEmit",
28
+ "test": "vitest run",
29
+ "prepublishOnly": "npm run test && npm run check && npm run build"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "1.30.0",
36
+ "zod": "4.5.4"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "24.3.0",
40
+ "typescript": "7.0.2",
41
+ "vitest": "4.1.11"
42
+ },
43
+ "license": "MIT"
44
+ }