ocr-software-mcp 0.1.0__py3-none-any.whl
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.
- ocr_software_mcp/__init__.py +2 -0
- ocr_software_mcp/__main__.py +3 -0
- ocr_software_mcp/server.py +472 -0
- ocr_software_mcp-0.1.0.dist-info/METADATA +128 -0
- ocr_software_mcp-0.1.0.dist-info/RECORD +9 -0
- ocr_software_mcp-0.1.0.dist-info/WHEEL +5 -0
- ocr_software_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- ocr_software_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- ocr_software_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""MCP server (stdio) for the ocr-software.com API. Thin wrapper: every tool is a few calls of the ocr-software package.
|
|
2
|
+
|
|
3
|
+
Settings by environment only: OCR_KEY (never printed, never in a tool result), OCR_BASE_URL, OCR_OUT_DIR, OCR_ALLOWED_DIR.
|
|
4
|
+
The names OCR_SOFTWARE_API_KEY, OCR_SOFTWARE_BASE_URL and OCR_SOFTWARE_ALLOWED_DIR of the package work too.
|
|
5
|
+
Retries, limits and document checks live in ocr_software.client (one copy for the CLI and this server).
|
|
6
|
+
Spec of every path, field and error code: <base>/openapi.json and agents.md of the API.
|
|
7
|
+
|
|
8
|
+
Design goal: few rounds for a foreign agent. quote gives a quote_id, one submit_job call can submit, wait and save, job_status
|
|
9
|
+
really waits, get_result tells which pages and words to check. State (quotes, job to file) lives in memory only: a restart
|
|
10
|
+
of the server loses it, the agent then runs quote again.
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
23
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
24
|
+
from mcp.shared.exceptions import McpError
|
|
25
|
+
from mcp.types import ClientCapabilities, ElicitationCapability
|
|
26
|
+
from ocr_software import Client, OcrError
|
|
27
|
+
from ocr_software.advice import default_pipeline, summary
|
|
28
|
+
from ocr_software.advice import eur as _eur
|
|
29
|
+
from ocr_software.advice import price_sentence as _price_sentence
|
|
30
|
+
from ocr_software.client import read_document
|
|
31
|
+
from pydantic import BaseModel, Field
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
|
|
35
|
+
RESULT_EXT = {"json": "json", "text": "txt", "md": "md", "hocr": "hocr", "pdf": "pdf"}
|
|
36
|
+
CATALOG_KEYS = ("pipeline", "formats", "features", "preview_note", "unavailable_note")
|
|
37
|
+
QUOTE_TTL_S = 3600
|
|
38
|
+
MAX_WAIT_S = 300 # one tool call; clients cut calls off after a while, the agent calls again
|
|
39
|
+
POLL_SLICE_S = 55 # one long poll of the API (the API holds up to 60 s)
|
|
40
|
+
POLL_MIN_S = 1.0 # never poll one job faster than this, however many pages finish (integrationen-regeln.md: do not flood)
|
|
41
|
+
LOW_CONFIDENCE = 0.9 # below this a word is listed as uncertain; the real hard words of the gold set sit at 0.83
|
|
42
|
+
UNCERTAIN_FLAGS = ("differs", "low_conf", "no_ctc", "not_in_model") # flags of scan-vlm-ctc, see pipelines.md
|
|
43
|
+
INLINE_CHARS = 30000 # most text get_result(pages=...) returns in the chat
|
|
44
|
+
MAX_REMEMBERED = 50
|
|
45
|
+
|
|
46
|
+
INSTRUCTIONS = ("Read PDFs and images with ocr-software.com. Order: list_pipelines (show it with the default, the human picks the pipeline; without a choice quote uses the default and says so), quote (nothing runs; "
|
|
47
|
+
"show say_to_human to the human), after a yes submit_job(quote_id, human_confirmed=true, wait_seconds=45, format=...) "
|
|
48
|
+
"(job_status if it is not done yet). Results go to files; get_result reads pages or searches without opening the file; "
|
|
49
|
+
"uncertain_words lists words to check against the page image.")
|
|
50
|
+
|
|
51
|
+
mcp = FastMCP("ocr-software", instructions=INSTRUCTIONS)
|
|
52
|
+
mcp._mcp_server.version = __version__ # else initialize reports the version of the MCP SDK
|
|
53
|
+
|
|
54
|
+
_quotes = {} # quote_id -> what the human was shown; one submit_job uses it up
|
|
55
|
+
_job_files = {} # job_id -> document path (results go next to it; pdf needs the original)
|
|
56
|
+
_results = {} # job_id -> (bytes, parsed) json result of a finished job
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _remember(store, key, value):
|
|
60
|
+
store[key] = value
|
|
61
|
+
while len(store) > MAX_REMEMBERED:
|
|
62
|
+
del store[next(iter(store))]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tool_error(e):
|
|
66
|
+
if e.code == "no_key":
|
|
67
|
+
return ToolError("No API key: OCR_KEY (or OCR_SOFTWARE_API_KEY) is not set. Ask the human to put the API key (starts with ocr_) "
|
|
68
|
+
"into the env field of this MCP server in the client configuration, not into the shell of the agent.")
|
|
69
|
+
if e.code == "not_ready":
|
|
70
|
+
return ToolError("The job is still running. Call job_status with this job_id (it waits for you), then get_result.")
|
|
71
|
+
return ToolError(str(e))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _client():
|
|
75
|
+
try:
|
|
76
|
+
return Client()
|
|
77
|
+
except OcrError as e:
|
|
78
|
+
raise _tool_error(e)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _call(func, *args, **kwargs):
|
|
82
|
+
"""Run a blocking client call without blocking the server; an OcrError becomes a ToolError with the API error code."""
|
|
83
|
+
try:
|
|
84
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
85
|
+
except OcrError as e:
|
|
86
|
+
raise _tool_error(e)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_path(path):
|
|
90
|
+
"""Say what is wrong with a path before the client reports a generic 'not a file'."""
|
|
91
|
+
if os.path.isdir(path):
|
|
92
|
+
raise ToolError("this is a folder, not a document: %s. Give the path of one PDF, PNG, JPEG or TIFF file." % path)
|
|
93
|
+
if not os.path.exists(path):
|
|
94
|
+
hint = ""
|
|
95
|
+
if os.name == "nt" and re.match(r"^/[A-Za-z]/", path):
|
|
96
|
+
hint = " It looks like a Git Bash path; on Windows give C:/Users/... or C:\\Users\\..."
|
|
97
|
+
raise ToolError("file not found: %s.%s" % (path, hint))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Confirm(BaseModel):
|
|
101
|
+
confirm: bool = Field(description="Start the job")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _human_confirms(ctx, sentence):
|
|
105
|
+
"""True or False if the client can ask the human directly (MCP elicitation), None if it cannot."""
|
|
106
|
+
if ctx is None:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
if not ctx.request_context.session.check_client_capability(ClientCapabilities(elicitation=ElicitationCapability())):
|
|
110
|
+
return None
|
|
111
|
+
answer = await ctx.elicit(sentence + " Start the job?", _Confirm)
|
|
112
|
+
except McpError:
|
|
113
|
+
return None
|
|
114
|
+
return answer.action == "accept" and answer.data.confirm
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _job_view(job):
|
|
118
|
+
"""The job state without what an agent does not need: charges only when there are some, the queue only while waiting."""
|
|
119
|
+
view = {k: job[k] for k in ("job_id", "state", "pages", "pages_done", "stop") if job.get(k) not in (None, "")}
|
|
120
|
+
for k in ("pages_failed", "pages_not_run"):
|
|
121
|
+
if job.get(k):
|
|
122
|
+
view[k] = job[k]
|
|
123
|
+
if job.get("charged_millicents"):
|
|
124
|
+
view["charged"] = _eur(job["charged_millicents"])
|
|
125
|
+
queue = job.get("queue") or {}
|
|
126
|
+
if job.get("state") == "running" and queue.get("pages_ahead"):
|
|
127
|
+
view["queue"] = {k: queue[k] for k in ("position", "pages_ahead", "estimated_wait_seconds", "estimated_done_seconds") if k in queue}
|
|
128
|
+
if job.get("state") == "done":
|
|
129
|
+
problems = ["%d page(s) failed" % job["pages_failed"] if job.get("pages_failed") else "",
|
|
130
|
+
"%d page(s) not run" % job["pages_not_run"] if job.get("pages_not_run") else "",
|
|
131
|
+
"stopped: %s" % job["stop"] if job.get("stop") else ""]
|
|
132
|
+
if any(problems):
|
|
133
|
+
view["problem"] = "done, but not every page was read: " + ", ".join(p for p in problems if p) + ". Tell the human."
|
|
134
|
+
return view
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _wait(ctx, client, job_id, wait_seconds):
|
|
138
|
+
"""Hold until the job is done or wait_seconds are over; one long poll at a time, progress to the client on every change."""
|
|
139
|
+
deadline = time.monotonic() + max(0, min(MAX_WAIT_S, int(wait_seconds)))
|
|
140
|
+
while True:
|
|
141
|
+
started = time.monotonic()
|
|
142
|
+
job = await _call(client.status, job_id, int(min(POLL_SLICE_S, math.ceil(max(0.0, deadline - started)))))
|
|
143
|
+
if job.get("state") == "done" or time.monotonic() >= deadline:
|
|
144
|
+
return job
|
|
145
|
+
if ctx is not None:
|
|
146
|
+
try:
|
|
147
|
+
await ctx.report_progress(job.get("pages_done", 0), job.get("pages"), "%s of %s pages read" % (job.get("pages_done", 0), job.get("pages")))
|
|
148
|
+
except Exception: # progress is a courtesy; a client that cannot take it must not fail the job
|
|
149
|
+
pass
|
|
150
|
+
await asyncio.sleep(max(0.0, POLL_MIN_S - (time.monotonic() - started)))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---- result files and the quick check ----
|
|
154
|
+
|
|
155
|
+
def _documents_dir():
|
|
156
|
+
docs = Path.home() / "Documents"
|
|
157
|
+
return docs if docs.is_dir() else Path.home()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _inside(path, folder):
|
|
161
|
+
try:
|
|
162
|
+
base = os.path.normcase(os.path.realpath(folder)) # Windows: c:\ and C:\ are one place
|
|
163
|
+
return os.path.commonpath([os.path.normcase(os.path.realpath(path)), base]) == base
|
|
164
|
+
except ValueError: # other drive on Windows
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _out_folder(job_id, requested):
|
|
169
|
+
"""OCR_OUT_DIR of the human wins. Else ocr-results next to the document, else in Documents (never the folder of the app).
|
|
170
|
+
A relative OCR_OUT_DIR is relative to the same place. The agent may ask for out_dir only if the human set no OCR_OUT_DIR."""
|
|
171
|
+
configured = os.environ.get("OCR_OUT_DIR")
|
|
172
|
+
base = Path(_job_files[job_id]).resolve().parent if job_id in _job_files else _documents_dir()
|
|
173
|
+
if requested:
|
|
174
|
+
if configured:
|
|
175
|
+
raise ToolError("OCR_OUT_DIR is set by the human; do not pass out_dir.")
|
|
176
|
+
allowed = os.environ.get("OCR_ALLOWED_DIR") or os.environ.get("OCR_SOFTWARE_ALLOWED_DIR")
|
|
177
|
+
folder = base / requested
|
|
178
|
+
if allowed and not _inside(folder, allowed):
|
|
179
|
+
raise ToolError("out_dir is outside the allowed folder.")
|
|
180
|
+
return folder
|
|
181
|
+
return base / (configured or "ocr-results")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_new(folder, job_id, ext, data):
|
|
185
|
+
"""Write to <job_id>.<ext>, or <job_id>-1.<ext>, -2 ... if it exists: never overwrite a file."""
|
|
186
|
+
try:
|
|
187
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
except OSError as e:
|
|
189
|
+
raise ToolError("cannot create the result folder %s: %s. Pass out_dir or ask the human to set OCR_OUT_DIR." % (folder, e.strerror))
|
|
190
|
+
for n in range(1000):
|
|
191
|
+
path = (folder / ("%s%s.%s" % (job_id, "-%d" % n if n else "", ext))).resolve()
|
|
192
|
+
try:
|
|
193
|
+
with open(path, "xb") as f:
|
|
194
|
+
f.write(data)
|
|
195
|
+
return str(path)
|
|
196
|
+
except FileExistsError:
|
|
197
|
+
continue
|
|
198
|
+
except OSError as e:
|
|
199
|
+
raise ToolError("cannot write the result to %s: %s. Pass out_dir or ask the human to set OCR_OUT_DIR." % (folder, e.strerror))
|
|
200
|
+
raise ToolError("too many result files for %s in %s" % (job_id, folder))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _words(page):
|
|
204
|
+
return [w for line in page.get("lines", []) for w in line["words"]]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _uncertain(word):
|
|
208
|
+
return word["confidence"] < LOW_CONFIDENCE or word.get("flag") in UNCERTAIN_FLAGS
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _multi_column(words, width):
|
|
212
|
+
"""Hint, not proof: a vertical band of 4 percent of the page width between 20 and 80 percent stays empty in (nearly) every
|
|
213
|
+
line and enough words stand on both sides. scan-ctc reads such a page line by line across the columns."""
|
|
214
|
+
boxes = [w["box"] for w in words if w.get("box")]
|
|
215
|
+
if len(boxes) < 60 or not width:
|
|
216
|
+
return False
|
|
217
|
+
bins = 50
|
|
218
|
+
cover = [0] * bins
|
|
219
|
+
for x0, _, x1, _ in boxes:
|
|
220
|
+
for b in range(max(0, int(x0 / width * bins)), min(bins - 1, int(x1 / width * bins)) + 1):
|
|
221
|
+
cover[b] += 1
|
|
222
|
+
free = [b for b in range(10, 41) if cover[b] <= len(boxes) * 0.03]
|
|
223
|
+
for b in free:
|
|
224
|
+
if b + 1 in free:
|
|
225
|
+
left = sum(1 for box in boxes if box[2] <= (b + 1) * width / bins)
|
|
226
|
+
right = sum(1 for box in boxes if box[0] >= (b + 1) * width / bins)
|
|
227
|
+
if left >= len(boxes) * 0.25 and right >= len(boxes) * 0.25:
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _summary(result):
|
|
233
|
+
"""Words, mean confidence and uncertain words per page, and a note where something needs a look."""
|
|
234
|
+
rows, total, conf_sum, uncertain = [], 0, 0.0, 0
|
|
235
|
+
for page in result.get("pages", []):
|
|
236
|
+
words = _words(page)
|
|
237
|
+
unc = sum(1 for w in words if _uncertain(w))
|
|
238
|
+
mean = sum(w["confidence"] for w in words) / len(words) if words else None
|
|
239
|
+
notes = []
|
|
240
|
+
if page.get("status") != "done":
|
|
241
|
+
notes.append("%s%s" % (page.get("status"), ": " + page["error"]["code"] if page.get("error") else ""))
|
|
242
|
+
elif not words:
|
|
243
|
+
notes.append("no words found")
|
|
244
|
+
if _multi_column(words, page.get("width") or max((w["box"][2] for w in words if w.get("box")), default=0)):
|
|
245
|
+
notes.append("probably several columns: scan-ctc reads line by line across them, the order of the text may be mixed; "
|
|
246
|
+
"tell the human, another pipeline may read columns in order")
|
|
247
|
+
notes += [w["code"] for w in page.get("warnings", [])]
|
|
248
|
+
if mean is not None and mean < LOW_CONFIDENCE:
|
|
249
|
+
notes.append("low mean confidence")
|
|
250
|
+
total, conf_sum, uncertain = total + len(words), conf_sum + sum(w["confidence"] for w in words), uncertain + unc
|
|
251
|
+
rows.append({"page": page["page"], "words": len(words), "mean_confidence": None if mean is None else round(mean, 3),
|
|
252
|
+
"uncertain_words": unc, **({"note": "; ".join(notes)} if notes else {})})
|
|
253
|
+
out = {"pages": len(rows), "words": total, "mean_confidence": round(conf_sum / total, 3) if total else None, "uncertain_words": uncertain}
|
|
254
|
+
out["per_page"] = rows if len(rows) <= 8 else [r for r in rows if "note" in r]
|
|
255
|
+
if uncertain:
|
|
256
|
+
out["check"] = ("uncertain_words lists them (confidence below %s or a differing reading); look at them in the page image "
|
|
257
|
+
"before relying on them." % LOW_CONFIDENCE)
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _page_numbers(spec):
|
|
262
|
+
"""'3', '1-5', '2,4,7-9' -> sorted page numbers."""
|
|
263
|
+
numbers = set()
|
|
264
|
+
for part in spec.replace(" ", "").split(","):
|
|
265
|
+
m = re.fullmatch(r"(\d{1,6})(?:-(\d{1,6}))?", part)
|
|
266
|
+
if not m or (m.group(2) and int(m.group(2)) < int(m.group(1))):
|
|
267
|
+
raise ToolError("pages must look like 3 or 1-5 or 2,4,7-9")
|
|
268
|
+
numbers.update(range(int(m.group(1)), int(m.group(2) or m.group(1)) + 1))
|
|
269
|
+
return sorted(numbers)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _page_text(page):
|
|
273
|
+
return "\n".join(" ".join(w["text"] for w in line["words"]) for line in page.get("lines", []))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _load_result(client, job_id):
|
|
277
|
+
"""(bytes, parsed) json result of a finished job, kept in memory: the check, pages, search and uncertain_words use it."""
|
|
278
|
+
if job_id not in _results:
|
|
279
|
+
raw = client.result(job_id, "json")
|
|
280
|
+
try:
|
|
281
|
+
_remember(_results, job_id, (raw, json.loads(raw)))
|
|
282
|
+
except ValueError:
|
|
283
|
+
raise OcrError("the result of %s is not JSON: check OCR_BASE_URL" % client.base_url, "bad_response")
|
|
284
|
+
return _results[job_id]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _deliver(client, job_id, fmt, out_dir, source_path):
|
|
288
|
+
"""Blocking: fetch the result, write it to a file, return path, size and the quick check."""
|
|
289
|
+
raw, parsed = _load_result(client, job_id)
|
|
290
|
+
if fmt == "pdf":
|
|
291
|
+
source = source_path or _job_files.get(job_id)
|
|
292
|
+
if not source:
|
|
293
|
+
raise ToolError("format pdf needs the original document: pass source_path (the server does not know the file of this job).")
|
|
294
|
+
_check_path(source)
|
|
295
|
+
data = client.result(job_id, "pdf", source)
|
|
296
|
+
elif fmt == "json":
|
|
297
|
+
data = raw
|
|
298
|
+
else:
|
|
299
|
+
data = client.result(job_id, fmt)
|
|
300
|
+
path = _write_new(_out_folder(job_id, out_dir), job_id, RESULT_EXT[fmt], data)
|
|
301
|
+
return {"path": path, "bytes": len(data), "format": fmt, "check": _summary(parsed)}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _finish(client, job_id, fmt, out_dir, source_path=None):
|
|
305
|
+
return await _call(_deliver, client, job_id, fmt, out_dir, source_path)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _check_format(fmt):
|
|
309
|
+
if fmt is not None and fmt not in RESULT_EXT:
|
|
310
|
+
raise ToolError("format must be one of %s" % ", ".join(RESULT_EXT))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---- tools ----
|
|
314
|
+
|
|
315
|
+
@mcp.tool()
|
|
316
|
+
async def list_pipelines(detail: bool = False) -> dict:
|
|
317
|
+
"""List what the service offers (no key needed): per pipeline one line (summary: what it delivers, price per page in euros,
|
|
318
|
+
when to choose another one), formats and features, and default_pipeline: the fastest and cheapest available one, used when no pipeline is named. Show the available ones
|
|
319
|
+
to the human with the default and let the human pick; never choose a pipeline yourself. detail=true adds
|
|
320
|
+
descriptions, capabilities, measurements and hardware (long, rarely needed)."""
|
|
321
|
+
catalog = await _call(_client().catalog)
|
|
322
|
+
if detail:
|
|
323
|
+
return catalog
|
|
324
|
+
default = default_pipeline(catalog.get("pipelines", []))
|
|
325
|
+
pipelines = []
|
|
326
|
+
for p in catalog.get("pipelines", []):
|
|
327
|
+
row = {k: p[k] for k in CATALOG_KEYS if k in p}
|
|
328
|
+
row["summary"] = summary(p, default)
|
|
329
|
+
row.update({k: p[k] for k in ("available", "orderable") if p.get(k) is False})
|
|
330
|
+
pipelines.append(row)
|
|
331
|
+
return {"default_pipeline": default, "pipelines": pipelines}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@mcp.tool()
|
|
335
|
+
async def quote(file_path: str, pipeline: str | None = None, dpi: int | None = None) -> dict:
|
|
336
|
+
"""Dry run: nothing runs, nothing is billed. Returns quote_id, the page count and say_to_human, one sentence with the
|
|
337
|
+
price: show it to the human word for word and get a yes before submit_job. pipeline is name@version from list_pipelines; if the
|
|
338
|
+
human named none, leave it out: the default (fastest and cheapest available) is used and the sentence names it.
|
|
339
|
+
The quote_id is for one document, one pipeline and one job."""
|
|
340
|
+
_check_path(file_path)
|
|
341
|
+
client = _client()
|
|
342
|
+
chosen = not pipeline
|
|
343
|
+
if chosen:
|
|
344
|
+
pipeline = await _call(client.default_pipeline)
|
|
345
|
+
q = await _call(client.quote, file_path, pipeline, dpi)
|
|
346
|
+
digest = hashlib.sha256(await _call(read_document, file_path, client.allowed_dir)).hexdigest()
|
|
347
|
+
quote_id = "q_" + secrets.token_hex(6)
|
|
348
|
+
q.setdefault("pipeline", pipeline)
|
|
349
|
+
sentence = _price_sentence(q)
|
|
350
|
+
if chosen:
|
|
351
|
+
sentence = "No pipeline was chosen, so the default is used: the fastest and cheapest available one. " + sentence
|
|
352
|
+
_remember(_quotes, quote_id, {"path": file_path, "sha256": digest, "pipeline": pipeline, "dpi": dpi, "pages": q.get("pages", 0),
|
|
353
|
+
"budget": q.get("price_millicents") or None, "sentence": sentence, "time": time.monotonic()})
|
|
354
|
+
out = {"quote_id": quote_id, "pipeline": pipeline, "pages": q.get("pages"), "say_to_human": sentence}
|
|
355
|
+
if q.get("warnings"):
|
|
356
|
+
out["warnings"] = [w.get("message", w.get("code", "")) for w in q["warnings"]]
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@mcp.tool()
|
|
361
|
+
async def submit_job(quote_id: str, human_confirmed: bool = False, wait_seconds: int = 0, format: str | None = None,
|
|
362
|
+
client_ref: str | None = None, out_dir: str | None = None, ctx: Context = None) -> dict:
|
|
363
|
+
"""Start the job of a quote (document, pipeline and price are bound to the quote_id; the budget is the quoted price).
|
|
364
|
+
Set human_confirmed=true only after the human saw say_to_human and agreed; if the client can ask the human itself it asks
|
|
365
|
+
when you leave it false. wait_seconds (0 to 300): hold until the job is done; format (json, text, md, hocr, pdf) then
|
|
366
|
+
also saves the result like get_result. Without wait_seconds it returns at once: poll job_status, keep the job_id."""
|
|
367
|
+
_check_format(format)
|
|
368
|
+
entry = _quotes.get(quote_id)
|
|
369
|
+
if not entry or time.monotonic() - entry["time"] > QUOTE_TTL_S:
|
|
370
|
+
raise ToolError("unknown or expired quote_id (kept 1 hour, one job each, lost when the server restarts). Run quote again.")
|
|
371
|
+
if not human_confirmed:
|
|
372
|
+
answer = await _human_confirms(ctx, entry["sentence"])
|
|
373
|
+
if answer is None:
|
|
374
|
+
raise ToolError("Show say_to_human of the quote to the human and set human_confirmed only after a yes.")
|
|
375
|
+
if not answer:
|
|
376
|
+
raise ToolError("The human did not agree. Nothing was started.")
|
|
377
|
+
entry = _quotes.pop(quote_id, None) # taken before any await: two parallel calls with one quote_id start one job at most
|
|
378
|
+
if entry is None:
|
|
379
|
+
raise ToolError("this quote_id is already in use by another call. Run quote again if you need a new job.")
|
|
380
|
+
try:
|
|
381
|
+
client = _client()
|
|
382
|
+
digest = hashlib.sha256(await _call(read_document, entry["path"], client.allowed_dir)).hexdigest()
|
|
383
|
+
if digest != entry["sha256"]:
|
|
384
|
+
raise ToolError("the file changed since the quote. Run quote again and show the human the new quote.")
|
|
385
|
+
# the client sends an Idempotency-Key: its retries never create a second job or charge
|
|
386
|
+
job = await _call(client.submit, entry["path"], entry["pipeline"], None, None, entry["dpi"], client_ref, entry["budget"])
|
|
387
|
+
except BaseException: # nothing was started: the human's yes stays valid for a retry
|
|
388
|
+
_quotes.setdefault(quote_id, entry)
|
|
389
|
+
raise
|
|
390
|
+
_remember(_job_files, job.get("job_id"), entry["path"])
|
|
391
|
+
if wait_seconds <= 0:
|
|
392
|
+
return _job_view(job)
|
|
393
|
+
job = await _wait(ctx, client, job["job_id"], wait_seconds)
|
|
394
|
+
view = _job_view(job)
|
|
395
|
+
if job.get("state") == "done" and format:
|
|
396
|
+
view.update(await _finish(client, job["job_id"], format, out_dir))
|
|
397
|
+
return view
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@mcp.tool()
|
|
401
|
+
async def job_status(job_id: str, wait_seconds: int = 45, format: str | None = None, out_dir: str | None = None,
|
|
402
|
+
ctx: Context = None) -> dict:
|
|
403
|
+
"""State and counts of a job. Holds until the job is done or wait_seconds (0 to 300) are over, and reports progress
|
|
404
|
+
meanwhile: one call is enough, do not poll in a loop; if state is still running call again. When state is done, check
|
|
405
|
+
problem (pages failed or not run) and tell the human. format (json, text, md, hocr, pdf) saves the result when done."""
|
|
406
|
+
_check_format(format)
|
|
407
|
+
client = _client()
|
|
408
|
+
job = await _wait(ctx, client, job_id, wait_seconds)
|
|
409
|
+
view = _job_view(job)
|
|
410
|
+
if job.get("state") == "done" and format:
|
|
411
|
+
view.update(await _finish(client, job_id, format, out_dir))
|
|
412
|
+
return view
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
async def get_result(job_id: str, format: str = "json", pages: str | None = None, search: str | None = None,
|
|
417
|
+
out_dir: str | None = None, source_path: str | None = None) -> dict:
|
|
418
|
+
"""Result of a finished job. Default: write it to a file (format json, text, md, hocr or pdf) and return path, size and
|
|
419
|
+
check: words, mean confidence and uncertain words per page, notes such as probably several columns. The content stays
|
|
420
|
+
out of the chat. pages ('3', '1-5') returns the text of those pages here, search returns the matching lines with
|
|
421
|
+
page numbers, neither writes a file. out_dir: folder for the file, default ocr-results next to the document. pdf (searchable PDF)
|
|
422
|
+
needs the original: source_path, or the file of a job started by this server. Results expire after about 24 hours.
|
|
423
|
+
In the json: confidence is 0 to 1, box is [x0,y0,x1,y1] in pixels of the page image (page width, height). verified (per word) is
|
|
424
|
+
true only where a second reader confirmed it (scan-vlm-ctc@2); safe_ok (per page) is always false today. Judge by confidence."""
|
|
425
|
+
_check_format(format)
|
|
426
|
+
client = _client()
|
|
427
|
+
if not (pages or search):
|
|
428
|
+
return await _finish(client, job_id, format, out_dir, source_path)
|
|
429
|
+
result = (await _call(_load_result, client, job_id))[1]
|
|
430
|
+
if pages:
|
|
431
|
+
wanted = _page_numbers(pages)
|
|
432
|
+
text = "\n\n".join("## Page %d\n%s" % (p["page"], _page_text(p)) for p in result["pages"] if p["page"] in wanted)
|
|
433
|
+
return {"text": text[:INLINE_CHARS], "truncated": len(text) > INLINE_CHARS}
|
|
434
|
+
needle = search.lower()
|
|
435
|
+
hits = [{"page": p["page"], "line": line} for p in result["pages"] for line in _page_text(p).split("\n") if needle in line.lower()]
|
|
436
|
+
return {"matches": hits[:20], "total": len(hits)}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@mcp.tool()
|
|
440
|
+
async def uncertain_words(job_id: str, max_confidence: float = LOW_CONFIDENCE, limit: int = 20, pages: str | None = None) -> dict:
|
|
441
|
+
"""Words the OCR read with low confidence (below max_confidence, default 0.9: the words of the gold set that were really
|
|
442
|
+
wrong sat at about 0.83) or flagged as differing readings (scan-vlm-ctc), least
|
|
443
|
+
sure first: page, word, confidence, other_reading if there is a second one, and box [x0,y0,x1,y1] in pixels of the page
|
|
444
|
+
image (page_size gives width and height). Check these against the image of the page, do not trust them blindly, and tell
|
|
445
|
+
the human what you corrected. Needs a finished job."""
|
|
446
|
+
client = _client()
|
|
447
|
+
result = (await _call(_load_result, client, job_id))[1]
|
|
448
|
+
wanted = set(_page_numbers(pages)) if pages else None
|
|
449
|
+
found, sizes = [], {}
|
|
450
|
+
for page in result["pages"]:
|
|
451
|
+
if wanted is not None and page["page"] not in wanted:
|
|
452
|
+
continue
|
|
453
|
+
for w in _words(page):
|
|
454
|
+
if w["confidence"] < max_confidence or w.get("flag") in UNCERTAIN_FLAGS:
|
|
455
|
+
row = {"page": page["page"], "text": w["text"], "confidence": round(w["confidence"], 3)}
|
|
456
|
+
if w.get("ctc_text") and w["ctc_text"] != w["text"]:
|
|
457
|
+
row["other_reading"] = w["ctc_text"]
|
|
458
|
+
if w.get("box"):
|
|
459
|
+
row["box"] = [round(v) for v in w["box"]]
|
|
460
|
+
found.append(row)
|
|
461
|
+
sizes[page["page"]] = [page.get("width"), page.get("height")]
|
|
462
|
+
found.sort(key=lambda r: r["confidence"])
|
|
463
|
+
shown = found[:max(1, min(100, limit))]
|
|
464
|
+
return {"total": len(found), "words": shown, "page_size": {p: sizes[p] for p in sorted({r["page"] for r in shown})}}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main():
|
|
468
|
+
mcp.run()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ocr-software-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the ocr-software.com OCR API: quote, submit, poll, fetch text, Markdown, JSON, hOCR.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://ocr-software.com/
|
|
7
|
+
Project-URL: Repository, https://github.com/ocr-software/ocr-software-mcp
|
|
8
|
+
Keywords: ocr,mcp,mcp-server,pdf,document-ai
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: mcp==1.30.0
|
|
14
|
+
Requires-Dist: ocr-software==0.1.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest==9.0.2; extra == "test"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# OCR MCP server for ocr-software.com: let Claude, Cursor and other agents read PDFs and scans
|
|
20
|
+
|
|
21
|
+
<picture>
|
|
22
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ocr-software/ocr-software-mcp/main/assets/banner-dark.svg">
|
|
23
|
+
<img alt="OCR for AI agents: an agent calls OCR and gets text, word box and confidence back" src="https://raw.githubusercontent.com/ocr-software/ocr-software-mcp/main/assets/banner-light.svg" width="100%">
|
|
24
|
+
</picture>
|
|
25
|
+
|
|
26
|
+
Give your AI agent a way to read documents: this MCP server sends PDF, PNG, JPEG and TIFF files to the [ocr-software.com OCR API](https://ocr-software.com/) and writes the result (JSON with word boxes, text, Markdown or hOCR) to a file. The API is hosted in Helsinki, EU (Hetzner). It also works against the package on your own server, which takes the same calls: set `OCR_BASE_URL` to its address ([install guide](https://ocr-software.com/docs/install/)).
|
|
27
|
+
|
|
28
|
+
The server is small and strict by design. A person picks the pipeline, sees the page count first, and says yes before anything runs. More on the idea and on what to expect: [OCR for AI agents (MCP)](https://ocr-software.com/integrations/mcp/).
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
Six tools. The API itself is described in the [API guide](https://ocr-software.com/docs/api-guide/), the rules for agents in the [agent guide](https://ocr-software.com/docs/agents.md). The normal path is three calls: `list_pipelines`, `quote`, `submit_job` with `wait_seconds` and `format`.
|
|
33
|
+
|
|
34
|
+
| Tool | What it does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `list_pipelines` | Short list: per pipeline one summary line (what it delivers, price per page in euros, when to choose another one), formats and features, and `default_pipeline` (the fastest and cheapest available one, used by `quote` when no pipeline is named), from `GET /v1/catalog` (no key needed); `detail=true` gives the full catalog. The human picks the pipeline, the agent never does. |
|
|
37
|
+
| `quote` | Dry run of `POST /v1/jobs`. `pipeline` is optional: without it the default is used and `say_to_human` says so. Returns a `quote_id` and `say_to_human`, a plain sentence with the page count and the amounts in euros. While billing is off it says that you pay EUR 0.00 today, shows the list price for information only and names the fair-use page and the contact. Nothing runs, nothing is billed. The quote is bound to the file (hash), the pipeline, the dpi and the price, lives one hour in the memory of the server and is used up by one job. |
|
|
38
|
+
| `submit_job` | Starts the job from the `quote_id`; the budget comes from the quote, so the agent never handles it. `human_confirmed=true` only after the human saw `say_to_human`; if the client supports it, leave it out and the client asks the human itself. `wait_seconds` (up to 300) waits for the end with progress messages, `format` then writes the result in the same call. Sends an Idempotency-Key, so a retry never creates a second job or a second charge. |
|
|
39
|
+
| `job_status` | Waits up to `wait_seconds` (default 45) until the job is done, with progress messages, in one call. `done` does not mean every page worked: it says `problem` if pages failed or did not run. The queue block shows only while the job waits. |
|
|
40
|
+
| `get_result` | Fetches `json`, `text`, `md`, `hocr` or `pdf` (searchable PDF, needs the original document), writes it to a file and returns the path, the size and a quick check: words, mean confidence, uncertain words and a note for pages that look multi-column or failed. With `pages` ("2-4") or `search` it returns text or matching lines instead of a file. Results expire after about 24 hours. |
|
|
41
|
+
| `uncertain_words` | The words the OCR is least sure about: page, word, confidence, the other reading where the pipeline has two (`differs`), the box in pixels and the page size. The agent should check these against the image. |
|
|
42
|
+
|
|
43
|
+
`verified` (per word) and `safe_ok` (per page) in the JSON result are flags of the OCR service, not of this server. `verified` is true when a second reader confirmed the word: `scan-vlm-ctc@2` sets it where the character reader read the same text (`flag` ok); the other pipelines have one reader and leave it false. `safe_ok` is always false today (not calibrated), which says nothing against the page. Judge a word by `confidence` (0 to 1) and `flag`. `box` is `[x0, y0, x1, y1]` in pixels of the page image, origin top left; the page's `width`, `height` and `dpi` say which image, and PDF points are pixels * 72 / dpi. `uncertain_words` lists words with `confidence` below 0.9 (the really wrong words of the gold set sat at about 0.83) or a differing second reading.
|
|
44
|
+
|
|
45
|
+
Price: the API counts in millicents (100,000 millicents = 1 EUR); this server speaks euros. Today nothing is charged: `quote` says "you pay EUR 0.00 today", shows the list price for information and names the fair-use rules (https://ocr-software.com/legal/fair-usage/) and info@ocr-software.com for more volume.
|
|
46
|
+
|
|
47
|
+
The job budget is internal: `quote` gives the price in the plain sentence, the server sends the quoted price as the cap, and the job stops before a page that would exceed it. If the operator set no prices, no cap is sent.
|
|
48
|
+
|
|
49
|
+
Retries follow the agent guide: 429, 500, 502, 503, 504 and network errors up to 5 times with growing waits. A `Retry-After` over 60 seconds goes back to the human at once. Other 4xx errors are never retried.
|
|
50
|
+
|
|
51
|
+
## Settings
|
|
52
|
+
|
|
53
|
+
Environment variables only.
|
|
54
|
+
|
|
55
|
+
| Variable | Meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `OCR_KEY` (or `OCR_SOFTWARE_API_KEY`) | API key (starts with `ocr_`). Required except for `list_pipelines`. It belongs only in the `env` field of this server in the config of your client, not in the shell of the agent. Never print it, never paste it into the chat. Get one at [ocr-software.com/order](https://ocr-software.com/order/). |
|
|
58
|
+
| `OCR_BASE_URL` | Default `https://api.ocr-software.com`. For a self-hosted service, for example `http://127.0.0.1:8080`. |
|
|
59
|
+
| `OCR_OUT_DIR` | Where `get_result` writes; if set, the agent cannot choose another folder. Default: the folder `ocr-results` next to the document, for an unknown job `~/Documents/ocr-results`, never the folder of the app. A relative value is relative to that place. Without it the agent may pass `out_dir` (inside `OCR_ALLOWED_DIR` if set). An existing file is never overwritten (`j1-1.json` instead). |
|
|
60
|
+
| `OCR_ALLOWED_DIR` | Optional. `quote` and `submit_job` then read only files below this folder; symlinks and `..` do not escape. |
|
|
61
|
+
|
|
62
|
+
## Safety rules built in
|
|
63
|
+
|
|
64
|
+
- The agent cannot start a job without a `quote_id` and a yes of the human, and the quoted price caps the cost: the job stops before a page that would exceed it. A file that changed since the quote is refused.
|
|
65
|
+
- Every document is checked locally before it is sent: PDF, PNG, JPEG or TIFF by its first bytes, a regular file, at most 100 MiB.
|
|
66
|
+
- Results go to files, not into the conversation, so large documents do not fill the context.
|
|
67
|
+
- The key is read from the environment and never appears in a tool result.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
Python 3.10 or newer. The package `ocr-software-mcp` runs the server (it needs the `ocr-software` package, installed with it).
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
uvx ocr-software-mcp # run without installing (uv)
|
|
75
|
+
pipx install ocr-software-mcp # or install once; the command is then ocr-software-mcp
|
|
76
|
+
pip install ocr-software-mcp # inside a venv
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Claude Desktop
|
|
80
|
+
|
|
81
|
+
Add this to `claude_desktop_config.json`, then restart. The file is in `~/Library/Application Support/Claude/` on macOS and in `%APPDATA%\Claude\` on Windows:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"mcpServers": {"ocr-software": {"command": "uvx", "args": ["ocr-software-mcp"],
|
|
85
|
+
"env": {"OCR_KEY": "ocr_..."}}}}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
With pipx use `"command": "ocr-software-mcp"` and no `args`.
|
|
89
|
+
|
|
90
|
+
On Windows, `uv` must be on the PATH that Claude Desktop sees (install it with `winget install astral-sh.uv`, then restart Claude Desktop); otherwise use `pipx` and `"command": "ocr-software-mcp"`.
|
|
91
|
+
|
|
92
|
+
### Claude Code
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
claude mcp add ocr-software --env OCR_KEY=ocr_... -- uvx ocr-software-mcp
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Cursor
|
|
99
|
+
|
|
100
|
+
Use the same JSON as for Claude Desktop in `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (all projects).
|
|
101
|
+
|
|
102
|
+
### Your own server
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
"env": {"OCR_KEY": "ocr_...", "OCR_BASE_URL": "http://127.0.0.1:8080"}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The names `OCR_SOFTWARE_API_KEY`, `OCR_SOFTWARE_BASE_URL` and `OCR_SOFTWARE_ALLOWED_DIR` of the `ocr-software` package and its CLI are read too; the `OCR_` names above win if both are set.
|
|
109
|
+
|
|
110
|
+
## Tests
|
|
111
|
+
|
|
112
|
+
A fake local service, no network and no key needed. In a fresh clone, install the package with its test extra (this brings `mcp` and `ocr-software`), then run pytest:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
python -m venv .venv
|
|
116
|
+
.venv/bin/pip install -e ".[test]" # Windows: .venv\Scripts\pip install -e ".[test]"
|
|
117
|
+
.venv/bin/python -m pytest -p no:cacheprovider . # Windows: .venv\Scripts\python -m pytest -p no:cacheprovider .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT, see `LICENSE`. More about the service, its pipelines and output formats: [ocr-software.com](https://ocr-software.com/).
|
|
123
|
+
|
|
124
|
+
## Provider and legal
|
|
125
|
+
|
|
126
|
+
The OCR runs on the [ocr-software.com](https://ocr-software.com/) API, operated by Helm & Nagel GmbH. [Terms](https://ocr-software.com/legal/saas-terms/) · [Privacy](https://ocr-software.com/legal/privacy/) · [Imprint](https://ocr-software.com/legal/imprint/) · [Security](https://ocr-software.com/legal/security/)
|
|
127
|
+
|
|
128
|
+
mcp-name: io.github.ocr-software/ocr-software-mcp
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ocr_software_mcp/__init__.py,sha256=35LeDG0DQAjgET1-wFAD7W7o6aRl70dnhFiGUpM6-JI,69
|
|
2
|
+
ocr_software_mcp/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
|
|
3
|
+
ocr_software_mcp/server.py,sha256=eseJKpuJ8UtJZI_xeWoTRYni_LKIr9iWznHD7xLAeuA,24702
|
|
4
|
+
ocr_software_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=E_lY7BQrEo1ekTWu4ZrM1nXk9oe7Lcoye3TqknyW0DI,1073
|
|
5
|
+
ocr_software_mcp-0.1.0.dist-info/METADATA,sha256=L8stA1eRu9sxHFHlUD2ojf4NicczVP02QI_sJwFsymE,10008
|
|
6
|
+
ocr_software_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
ocr_software_mcp-0.1.0.dist-info/entry_points.txt,sha256=lbxFqEA63VfgpubYbKfmjjDm3QjDW8bSpi4gzSt7pbA,66
|
|
8
|
+
ocr_software_mcp-0.1.0.dist-info/top_level.txt,sha256=PAEmeHXc9V4lL5vL_6uZXwE7u5SMmdoxVLPFQhuwL9I,17
|
|
9
|
+
ocr_software_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ocr-software.com
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ocr_software_mcp
|