refigure 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (75) hide show
  1. refigure-0.2.0/README.md → refigure-0.3.0/PKG-INFO +78 -0
  2. refigure-0.2.0/PKG-INFO → refigure-0.3.0/README.md +46 -30
  3. {refigure-0.2.0 → refigure-0.3.0}/pyproject.toml +10 -2
  4. {refigure-0.2.0 → refigure-0.3.0}/refigure/__init__.py +2 -0
  5. {refigure-0.2.0 → refigure-0.3.0}/refigure/api.py +35 -0
  6. {refigure-0.2.0 → refigure-0.3.0}/refigure/cli.py +21 -1
  7. refigure-0.3.0/refigure/mcp/__init__.py +30 -0
  8. refigure-0.3.0/refigure/mcp/_lru.py +159 -0
  9. refigure-0.3.0/refigure/mcp/auth.py +102 -0
  10. refigure-0.3.0/refigure/mcp/cli.py +373 -0
  11. refigure-0.3.0/refigure/mcp/exceptions.py +19 -0
  12. refigure-0.3.0/refigure/mcp/server.py +1252 -0
  13. refigure-0.3.0/refigure/mcp/state.py +197 -0
  14. refigure-0.3.0/refigure/mcp/vlm_cache.py +104 -0
  15. {refigure-0.2.0 → refigure-0.3.0}/refigure/vlm/__init__.py +179 -52
  16. refigure-0.3.0/refigure/vlm/cache.py +106 -0
  17. refigure-0.3.0/tests/unit/mcp/conftest.py +20 -0
  18. refigure-0.3.0/tests/unit/mcp/test_auth.py +142 -0
  19. refigure-0.3.0/tests/unit/mcp/test_batch.py +450 -0
  20. refigure-0.3.0/tests/unit/mcp/test_bridge.py +126 -0
  21. refigure-0.3.0/tests/unit/mcp/test_cli.py +445 -0
  22. refigure-0.3.0/tests/unit/mcp/test_http.py +197 -0
  23. refigure-0.3.0/tests/unit/mcp/test_lru.py +162 -0
  24. refigure-0.3.0/tests/unit/mcp/test_prompts.py +197 -0
  25. refigure-0.3.0/tests/unit/mcp/test_resources.py +115 -0
  26. refigure-0.3.0/tests/unit/mcp/test_schema_pin.py +207 -0
  27. refigure-0.3.0/tests/unit/mcp/test_state.py +244 -0
  28. refigure-0.3.0/tests/unit/mcp/test_tools.py +441 -0
  29. refigure-0.3.0/tests/unit/mcp/test_vlm_cache.py +85 -0
  30. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_cli.py +51 -0
  31. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_optional_dependency_guards.py +1 -0
  32. refigure-0.3.0/tests/unit/test_vlm_max_markers.py +155 -0
  33. refigure-0.3.0/tests/unit/vlm/test_soffice_profile_isolation.py +188 -0
  34. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/vlm/test_vlm.py +43 -14
  35. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/vlm/test_vlm_cache.py +59 -0
  36. refigure-0.3.0/tests/unit/xlsx/__init__.py +0 -0
  37. refigure-0.2.0/refigure/vlm/cache.py +0 -70
  38. {refigure-0.2.0 → refigure-0.3.0}/.gitignore +0 -0
  39. {refigure-0.2.0 → refigure-0.3.0}/ATTRIBUTION.md +0 -0
  40. {refigure-0.2.0 → refigure-0.3.0}/LICENSE +0 -0
  41. {refigure-0.2.0 → refigure-0.3.0}/NOTICE +0 -0
  42. {refigure-0.2.0 → refigure-0.3.0}/refigure/__main__.py +0 -0
  43. {refigure-0.2.0 → refigure-0.3.0}/refigure/_io.py +0 -0
  44. {refigure-0.2.0 → refigure-0.3.0}/refigure/core/__init__.py +0 -0
  45. {refigure-0.2.0 → refigure-0.3.0}/refigure/core/chart_data.py +0 -0
  46. {refigure-0.2.0 → refigure-0.3.0}/refigure/core/chart_render.py +0 -0
  47. {refigure-0.2.0 → refigure-0.3.0}/refigure/core/zipsafe.py +0 -0
  48. {refigure-0.2.0 → refigure-0.3.0}/refigure/docx/__init__.py +0 -0
  49. {refigure-0.2.0 → refigure-0.3.0}/refigure/docx_groups.py +0 -0
  50. {refigure-0.2.0 → refigure-0.3.0}/refigure/py.typed +0 -0
  51. {refigure-0.2.0 → refigure-0.3.0}/refigure/vlm/client.py +0 -0
  52. {refigure-0.2.0 → refigure-0.3.0}/refigure/xlsx/__init__.py +0 -0
  53. {refigure-0.2.0 → refigure-0.3.0}/refigure/xlsx/charts.py +0 -0
  54. {refigure-0.2.0 → refigure-0.3.0}/tests/__init__.py +0 -0
  55. {refigure-0.2.0 → refigure-0.3.0}/tests/support.py +0 -0
  56. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/__init__.py +0 -0
  57. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/__init__.py +0 -0
  58. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_data.py +0 -0
  59. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_render.py +0 -0
  60. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_render_missing_mermaidx.py +0 -0
  61. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_render_visual.py +0 -0
  62. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/docx/__init__.py +0 -0
  63. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/docx/test_docx.py +0 -0
  64. {refigure-0.2.0/tests/unit/vlm → refigure-0.3.0/tests/unit/mcp}/__init__.py +0 -0
  65. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_concurrency.py +0 -0
  66. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_docx_chart_group_coexistence.py +0 -0
  67. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_docx_groups.py +0 -0
  68. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_io.py +0 -0
  69. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_property_based.py +0 -0
  70. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_robustness.py +0 -0
  71. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_xml_security.py +0 -0
  72. {refigure-0.2.0/tests/unit/xlsx → refigure-0.3.0/tests/unit/vlm}/__init__.py +0 -0
  73. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/vlm/test_vlm_client.py +0 -0
  74. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/xlsx/test_xlsx.py +0 -0
  75. {refigure-0.2.0 → refigure-0.3.0}/tests/unit/xlsx/test_xlsx_charts.py +0 -0
@@ -1,3 +1,35 @@
1
+ Metadata-Version: 2.5
2
+ Name: refigure
3
+ Version: 0.3.0
4
+ Summary: DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction (no rasterize/OCR/VLM needed) + optional VLM interpretation for figures with no native chart data
5
+ Project-URL: Homepage, https://github.com/HelgDemidov/refigure
6
+ Project-URL: Repository, https://github.com/HelgDemidov/refigure
7
+ Author-email: HelgDemidov <helg.demidov88@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: lxml<7,>=6.1
18
+ Provides-Extra: docx
19
+ Requires-Dist: mammoth<2,>=1.12; extra == 'docx'
20
+ Requires-Dist: markdownify<2,>=1.2; extra == 'docx'
21
+ Provides-Extra: mcp
22
+ Requires-Dist: mcp<3,>=2.0; extra == 'mcp'
23
+ Provides-Extra: vlm
24
+ Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm'
25
+ Provides-Extra: vlm-direct
26
+ Requires-Dist: anthropic<1,>=0.120; extra == 'vlm-direct'
27
+ Requires-Dist: openai<4,>=3.1.0; extra == 'vlm-direct'
28
+ Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm-direct'
29
+ Provides-Extra: xlsx
30
+ Requires-Dist: openpyxl<4,>=3.1; extra == 'xlsx'
31
+ Description-Content-Type: text/markdown
32
+
1
33
  # refigure
2
34
 
3
35
  **Converters where figures survive.**
@@ -100,6 +132,9 @@ refigure report.docx --vlm # needs the system soffice/LibreOf
100
132
  chart/group counts + `vlm_used`), not a bare string.
101
133
  - **CLI included** — `refigure` console command, stdin/stdout-first, native
102
134
  batch mode, typed exit codes (see below).
135
+ - **MCP server included** — `refigure-mcp` console command (`[mcp]` extra),
136
+ stdio or Streamable HTTP, tools/resources/prompts, batch conversion with
137
+ per-file isolation (see below).
103
138
 
104
139
  ## CLI
105
140
 
@@ -133,6 +168,49 @@ Exit codes:
133
168
  | 5 | the format's extra (`[docx]`/`[xlsx]`) isn't installed |
134
169
  | 6 | unexpected internal error |
135
170
 
171
+ ## MCP server
172
+
173
+ `refigure-mcp` — the same converters as an
174
+ [MCP](https://modelcontextprotocol.io) server, for agents/IDEs that speak
175
+ the protocol directly instead of shelling out to a CLI or importing the
176
+ library:
177
+
178
+ ```bash
179
+ pip install "refigure[mcp,docx,xlsx]"
180
+ refigure-mcp # stdio — the MCP client launches it
181
+ ```
182
+
183
+ ```json
184
+ {
185
+ "mcpServers": {
186
+ "refigure": { "command": "refigure-mcp" }
187
+ }
188
+ }
189
+ ```
190
+
191
+ Three tools — `convert_docx`, `convert_xlsx`, and `convert_batch` (multiple
192
+ files in one call: one bad file reports its own error without aborting the
193
+ rest) — each registered only if its format extra is actually installed.
194
+ `use_vlm`/`--vlm-provider` and friends work the same as the CLI. A result
195
+ too large to inline is stored and handed back as a
196
+ `refigure://conversion/{id}` resource instead of inflating the tool
197
+ response. Two prompts (`ingest_for_rag`, `explain_conversion_warnings`)
198
+ help a client pick the right tool/VLM settings for the job.
199
+
200
+ Streamable HTTP is opt-in, for a shared/remote deployment — bearer-token
201
+ auth is required, not optional:
202
+
203
+ ```bash
204
+ echo "sk-... = alice" > tokens.txt
205
+ refigure-mcp --transport http --mcp-auth-token-file tokens.txt
206
+ ```
207
+
208
+ Per-caller rate-limiting (protects the operator's own spend from a
209
+ leaked/runaway token) applies automatically over HTTP, together with a
210
+ fairness soft-cap once 2+ callers are configured; `refigure-mcp --help`
211
+ covers every tuning flag (concurrency, timeouts, resource-store limits,
212
+ batch size, VLM ceiling).
213
+
136
214
  ## Real examples
137
215
 
138
216
  Full `convert()` output on real, openly-licensed documents — not
@@ -1,33 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: refigure
3
- Version: 0.2.0
4
- Summary: DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction, no rasterize/OCR/VLM
5
- Project-URL: Homepage, https://github.com/HelgDemidov/refigure
6
- Project-URL: Repository, https://github.com/HelgDemidov/refigure
7
- Author-email: HelgDemidov <helg.demidov88@gmail.com>
8
- License-Expression: Apache-2.0
9
- License-File: LICENSE
10
- License-File: NOTICE
11
- Classifier: Development Status :: 2 - Pre-Alpha
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: Apache Software License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Topic :: Text Processing :: Markup :: Markdown
16
- Requires-Python: >=3.10
17
- Requires-Dist: lxml<7,>=6.1
18
- Provides-Extra: docx
19
- Requires-Dist: mammoth<2,>=1.12; extra == 'docx'
20
- Requires-Dist: markdownify<2,>=1.2; extra == 'docx'
21
- Provides-Extra: vlm
22
- Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm'
23
- Provides-Extra: vlm-direct
24
- Requires-Dist: anthropic<1,>=0.120; extra == 'vlm-direct'
25
- Requires-Dist: openai<4,>=3.1.0; extra == 'vlm-direct'
26
- Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm-direct'
27
- Provides-Extra: xlsx
28
- Requires-Dist: openpyxl<4,>=3.1; extra == 'xlsx'
29
- Description-Content-Type: text/markdown
30
-
31
1
  # refigure
32
2
 
33
3
  **Converters where figures survive.**
@@ -130,6 +100,9 @@ refigure report.docx --vlm # needs the system soffice/LibreOf
130
100
  chart/group counts + `vlm_used`), not a bare string.
131
101
  - **CLI included** — `refigure` console command, stdin/stdout-first, native
132
102
  batch mode, typed exit codes (see below).
103
+ - **MCP server included** — `refigure-mcp` console command (`[mcp]` extra),
104
+ stdio or Streamable HTTP, tools/resources/prompts, batch conversion with
105
+ per-file isolation (see below).
133
106
 
134
107
  ## CLI
135
108
 
@@ -163,6 +136,49 @@ Exit codes:
163
136
  | 5 | the format's extra (`[docx]`/`[xlsx]`) isn't installed |
164
137
  | 6 | unexpected internal error |
165
138
 
139
+ ## MCP server
140
+
141
+ `refigure-mcp` — the same converters as an
142
+ [MCP](https://modelcontextprotocol.io) server, for agents/IDEs that speak
143
+ the protocol directly instead of shelling out to a CLI or importing the
144
+ library:
145
+
146
+ ```bash
147
+ pip install "refigure[mcp,docx,xlsx]"
148
+ refigure-mcp # stdio — the MCP client launches it
149
+ ```
150
+
151
+ ```json
152
+ {
153
+ "mcpServers": {
154
+ "refigure": { "command": "refigure-mcp" }
155
+ }
156
+ }
157
+ ```
158
+
159
+ Three tools — `convert_docx`, `convert_xlsx`, and `convert_batch` (multiple
160
+ files in one call: one bad file reports its own error without aborting the
161
+ rest) — each registered only if its format extra is actually installed.
162
+ `use_vlm`/`--vlm-provider` and friends work the same as the CLI. A result
163
+ too large to inline is stored and handed back as a
164
+ `refigure://conversion/{id}` resource instead of inflating the tool
165
+ response. Two prompts (`ingest_for_rag`, `explain_conversion_warnings`)
166
+ help a client pick the right tool/VLM settings for the job.
167
+
168
+ Streamable HTTP is opt-in, for a shared/remote deployment — bearer-token
169
+ auth is required, not optional:
170
+
171
+ ```bash
172
+ echo "sk-... = alice" > tokens.txt
173
+ refigure-mcp --transport http --mcp-auth-token-file tokens.txt
174
+ ```
175
+
176
+ Per-caller rate-limiting (protects the operator's own spend from a
177
+ leaked/runaway token) applies automatically over HTTP, together with a
178
+ fairness soft-cap once 2+ callers are configured; `refigure-mcp --help`
179
+ covers every tuning flag (concurrency, timeouts, resource-store limits,
180
+ batch size, VLM ceiling).
181
+
166
182
  ## Real examples
167
183
 
168
184
  Full `convert()` output on real, openly-licensed documents — not
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "refigure"
7
- version = "0.2.0"
8
- description = "DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction, no rasterize/OCR/VLM"
7
+ version = "0.3.0"
8
+ description = "DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction (no rasterize/OCR/VLM needed) + optional VLM interpretation for figures with no native chart data"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  license-files = ["LICENSE", "NOTICE"]
@@ -57,9 +57,17 @@ vlm = ["pdfplumber>=0.11,<0.12"]
57
57
  # [vlm] here reflects a genuine dependency, not a bug to route around by
58
58
  # flattening client.py out of the package.
59
59
  vlm-direct = ["refigure[vlm]", "openai>=3.1.0,<4", "anthropic>=0.120,<1"]
60
+ # MCP server (v2, stage 10) — thin protocol wrapper over docx.convert()/
61
+ # xlsx.convert(), no self-referential coupling to [docx]/[xlsx]/[vlm]
62
+ # (unlike vlm-direct above): refigure/mcp/__init__.py's guard is the only
63
+ # module-level dependency, a bare `refigure[mcp]` is valid with 0 tools
64
+ # registered — see docs/mcp-server/mcp-server-architecture/
65
+ # mcp-server-architecture.md §2.
66
+ mcp = ["mcp>=2.0,<3"]
60
67
 
61
68
  [project.scripts]
62
69
  refigure = "refigure.cli:main"
70
+ refigure-mcp = "refigure.mcp.cli:main"
63
71
 
64
72
  [project.urls]
65
73
  Homepage = "https://github.com/HelgDemidov/refigure"
@@ -6,6 +6,7 @@ from .api import (
6
6
  CorruptArchiveError,
7
7
  MissingOptionalDependencyError,
8
8
  UnsupportedFormatError,
9
+ VlmMarkerLimitExceededError,
9
10
  )
10
11
 
11
12
  try:
@@ -24,4 +25,5 @@ __all__ = [
24
25
  "CorruptArchiveError",
25
26
  "MissingOptionalDependencyError",
26
27
  "UnsupportedFormatError",
28
+ "VlmMarkerLimitExceededError",
27
29
  ]
@@ -255,6 +255,35 @@ class Config:
255
255
  language-sensitivity — see ``vlm_verify``'s own docstring and
256
256
  ``witness_defects``'s."""
257
257
 
258
+ vlm_max_markers: int | None = None
259
+ """Maximum number of VLM markers this call may resolve with a PAID
260
+ call (docx-only — XLSX has no VLM path). ``None`` (default): no
261
+ ceiling, current behavior. A caller that sets this gets a pre-flight
262
+ check in ``vlm.enhance_docx_markdown``, before any paid call: every
263
+ marker that would incur at least one paid call — a cache-miss, OR (
264
+ when ``vlm_verify=True``) a cache-hit whose ``judge_verdict`` is still
265
+ unset (the judge-backfill pass is itself a paid call; without counting
266
+ it, a fully-cached document under ``vlm_verify=True`` would silently
267
+ bypass the ceiling) — is counted once, via a single memoized cache
268
+ probe reused by the main resolution loops (no doubled reads against a
269
+ networked ``VlmCacheBackend``, no TOCTOU window between the count and
270
+ the paid calls). Exceeding the count raises
271
+ ``VlmMarkerLimitExceededError`` immediately, before the first paid
272
+ call — no partial processing.
273
+
274
+ Motivation: input size alone does not bound the number of paid VLM
275
+ calls a document can trigger — a 1.46 MB real-world document carrying
276
+ 9990 VLM-eligible markers was the concrete case that motivated this
277
+ field. This is the one lever specifically for VLM cost/latency
278
+ escalation; it is orthogonal to ``strict`` (which narrows exactly one
279
+ unrelated failure mode, a missing ``soffice`` binary) and to the
280
+ zero-loss-on-failure contract every other VLM failure path already
281
+ has (PR #16's security-audit contract) — an unavailable cache backend
282
+ still degrades every PROBE to "treat as a miss" (``vlm.
283
+ _cache_get_safely``'s existing behavior), which can trigger this
284
+ ceiling honestly (those calls really would have been paid), not a
285
+ weakening of that contract."""
286
+
258
287
 
259
288
  @dataclass
260
289
  class ConversionResult:
@@ -287,3 +316,9 @@ class MissingOptionalDependencyError(Exception):
287
316
  binary a pip extra can't provide (``soffice``/LibreOffice, needed to
288
317
  render a composite DOCX group under ``Config(use_vlm=True,
289
318
  strict=True)`` — see ``Config.strict``'s docstring)."""
319
+
320
+
321
+ class VlmMarkerLimitExceededError(Exception):
322
+ """The document carries more VLM-eligible markers requiring a paid call
323
+ than ``Config.vlm_max_markers`` allows (see its docstring) — raised
324
+ before any paid call, not after partial processing."""
@@ -33,6 +33,7 @@ EXIT_UNSUPPORTED_FORMAT = 3
33
33
  EXIT_CORRUPT_ARCHIVE = 4
34
34
  EXIT_MISSING_DEPENDENCY = 5
35
35
  EXIT_INTERNAL_ERROR = 6
36
+ EXIT_VLM_MARKER_LIMIT = 7
36
37
 
37
38
  _SUFFIX_TO_FORMAT = {".docx": "docx", ".xlsx": "xlsx"}
38
39
 
@@ -67,7 +68,12 @@ def _exit_code_for(exc: Exception) -> int:
67
68
  # Imported lazily too — importing refigure.api at module level is cheap
68
69
  # (lxml-only, see api.py), but the exception CLASSES only need to exist
69
70
  # once we actually have an exception to classify.
70
- from .api import CorruptArchiveError, MissingOptionalDependencyError, UnsupportedFormatError
71
+ from .api import (
72
+ CorruptArchiveError,
73
+ MissingOptionalDependencyError,
74
+ UnsupportedFormatError,
75
+ VlmMarkerLimitExceededError,
76
+ )
71
77
 
72
78
  if isinstance(exc, UnsupportedFormatError):
73
79
  return EXIT_UNSUPPORTED_FORMAT
@@ -75,6 +81,8 @@ def _exit_code_for(exc: Exception) -> int:
75
81
  return EXIT_CORRUPT_ARCHIVE
76
82
  if isinstance(exc, MissingOptionalDependencyError):
77
83
  return EXIT_MISSING_DEPENDENCY
84
+ if isinstance(exc, VlmMarkerLimitExceededError):
85
+ return EXIT_VLM_MARKER_LIMIT
78
86
  return EXIT_INTERNAL_ERROR
79
87
 
80
88
 
@@ -200,6 +208,16 @@ def build_parser() -> argparse.ArgumentParser:
200
208
  "putting a secret in argv/shell history."
201
209
  ),
202
210
  )
211
+ vlm_group.add_argument(
212
+ "--vlm-max-markers",
213
+ metavar="N",
214
+ type=int,
215
+ help=(
216
+ "Cap on VLM markers requiring a paid call (Config(vlm_max_markers=...)). "
217
+ "Exceeding it exits with code 7 before any paid call. Unset: no cap "
218
+ "(the library default)."
219
+ ),
220
+ )
203
221
  verbosity = parser.add_mutually_exclusive_group()
204
222
  verbosity.add_argument(
205
223
  "-v",
@@ -271,6 +289,8 @@ def _build_config(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
271
289
  kwargs["vlm_model"] = args.vlm_model
272
290
  if args.vlm_judge_mode is not None:
273
291
  kwargs["vlm_judge_mode"] = args.vlm_judge_mode
292
+ if args.vlm_max_markers is not None:
293
+ kwargs["vlm_max_markers"] = args.vlm_max_markers
274
294
  if args.vlm:
275
295
  # Provider/key-file resolution only matters when VLM is actually
276
296
  # enabled — otherwise --vlm-provider openai with no --vlm would
@@ -0,0 +1,30 @@
1
+ """MCP-server subpackage (v2, stage 10) — a thin protocol wrapper over
2
+ ``refigure.docx.convert()``/``refigure.xlsx.convert()``, not new
3
+ conversion logic. See ``docs/mcp-server/mcp-server-architecture/
4
+ mcp-server-architecture.md`` for the full design.
5
+
6
+ Guard is the first same-package-import-adjacent statement in this file —
7
+ same discipline as ``refigure/vlm/__init__.py``'s own guard docstring
8
+ explains: a module-level ``try/except ImportError`` is only effective if
9
+ it runs before any OTHER same-package import that could itself
10
+ transitively raise an unguarded ``ImportError``. This subpackage currently
11
+ has no such transitive risk (``server.py``/``cli.py`` only import
12
+ ``..api`` at module level, itself ``lxml``-only), but the ordering
13
+ discipline holds regardless of today's safety being circumstantial.
14
+
15
+ No self-referential coupling to ``[docx]``/``[xlsx]``/``[vlm]`` (unlike
16
+ ``vlm-direct``, see ``pyproject.toml``'s comment there) — a bare
17
+ ``refigure[mcp]`` install is valid, it just registers 0 tools (``docx``/
18
+ ``xlsx`` are imported lazily, per-tool, inside ``server.py``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from ..api import MissingOptionalDependencyError
24
+
25
+ try:
26
+ import mcp as _mcp_sdk # noqa: F401 - import-for-guard, see module docstring
27
+ except ImportError as exc: # pragma: no cover - see tests/unit/test_optional_dependency_guards.py
28
+ raise MissingOptionalDependencyError(
29
+ "refigure[mcp] is required to use the refigure-mcp server"
30
+ ) from exc
@@ -0,0 +1,159 @@
1
+ """Generic bounded LRU + byte-budget store, shared by ``ServerState``
2
+ (``state.py``) and ``BoundedLruVlmCache`` (``vlm_cache.py``) — architecture
3
+ doc §7-bis: "the same LRU utility as the resource store", not two
4
+ independent implementations of the same eviction policy.
5
+
6
+ Byte-budget eviction is O(1) per operation: every entry carries its OWN
7
+ precomputed size (this class never (re)computes it — the caller does, and
8
+ is expected to do so OUTSIDE any lock it holds), and a running total is
9
+ adjusted by plain addition/subtraction on insert/evict. Recomputing every
10
+ remaining entry's size on each eviction (e.g. re-encoding/re-hashing the
11
+ whole store under the lock) would reproduce the exact stall the
12
+ architecture review measured for that approach: 34-60ms of event-loop
13
+ blocking vs. <1ms for this incremental counter.
14
+
15
+ Thread-safe via a plain ``threading.Lock`` — correct for both of this
16
+ class's callers even though they run in different execution contexts:
17
+ ``ServerState`` is only ever touched from the event loop (architecture
18
+ doc §4's "mutations only from the event loop" invariant), while
19
+ ``BoundedLruVlmCache`` is written from worker threads (core's
20
+ ``enhance_docx_markdown`` calls ``VlmCacheBackend.set()`` synchronously,
21
+ inside the ``anyio.to_thread`` bridge). A plain lock protects either
22
+ caller identically — no async-specific primitive needed here.
23
+
24
+ Phase 3 (``docs/mcp-server/mcp-server-phase3-http-auth/
25
+ mcp-server-phase3-http-auth-2026-08-21.md`` §1) adds two additive members —
26
+ existing callers that don't pass ``on_evict``/don't call ``remove()`` see
27
+ no behavior change: an optional ``on_evict`` callback, invoked for entries
28
+ evicted by ``insert()``'s own count/byte-budget loop (``ServerState``'s
29
+ per-caller soft-cap accounting needs to know which OTHER caller's entry a
30
+ given ``insert()`` just evicted — this class has no other way to tell it),
31
+ and ``remove()``, an explicit point-eviction the caller already knows
32
+ about (deliberately NOT routed through ``on_evict`` — the caller doing the
33
+ removing already updates its own bookkeeping at the call site, invoking
34
+ the callback too would double-count).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import threading
40
+ from collections import OrderedDict
41
+ from typing import Callable, Generic, TypeVar
42
+
43
+ # Not PEP 695 generics (`class BoundedLru[T]`) — this repo's floor is
44
+ # Python 3.10 (`pyproject.toml`'s `requires-python = ">=3.10"`), that
45
+ # syntax is 3.12+.
46
+ _T = TypeVar("_T")
47
+
48
+
49
+ class BoundedLru(Generic[_T]):
50
+ """A dict-like LRU store bounded by BOTH entry count and total byte
51
+ size — whichever limit is hit first evicts the oldest entries until
52
+ neither is exceeded. Has no notion of time itself; a TTL (if the
53
+ caller wants one, as ``ServerState`` does) is layered on top by
54
+ storing a timestamp inside the value and checking it after ``get()``,
55
+ not something this class enforces.
56
+
57
+ A single entry larger than ``max_bytes`` on its own is kept, not
58
+ evicted-on-arrival: eviction never drops the store to zero entries
59
+ just because the one remaining entry is large — a caller calling
60
+ ``get()`` right after ``insert()`` must always find what it just
61
+ inserted. This only matters for a pathologically oversized single
62
+ entry; every real caller in this codebase keeps individual entries
63
+ well under their respective budgets (see ``state.py``/
64
+ ``vlm_cache.py``)."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ max_entries: int,
70
+ max_bytes: int,
71
+ on_evict: Callable[[str, _T, int], None] | None = None,
72
+ ) -> None:
73
+ self._max_entries = max_entries
74
+ self._max_bytes = max_bytes
75
+ self._on_evict = on_evict
76
+ self._lock = threading.Lock()
77
+ self._data: OrderedDict[str, tuple[_T, int]] = OrderedDict()
78
+ self._total_bytes = 0
79
+
80
+ def insert(self, key: str, value: _T, size_bytes: int) -> None:
81
+ """Insert (or replace) ``key``, then evict oldest entries while
82
+ over either limit. ``size_bytes`` must be precomputed by the
83
+ caller BEFORE calling this — see the module docstring; computing
84
+ it here (e.g. from ``value``) would put arbitrary caller-defined
85
+ serialization cost back under the lock, exactly what this class
86
+ exists to avoid.
87
+
88
+ ``on_evict`` (if set) is called once per entry evicted by THIS
89
+ call's own count/byte-budget loop — AFTER ``self._lock`` has
90
+ already been released (never while held: a lock this class
91
+ doesn't own must never be acquired while this one is, and
92
+ ``ServerState``'s own callback does exactly that with its own
93
+ ``self._lock`` — see the inline comment below for the full
94
+ lock-ordering rationale). A replacement of ``key`` itself
95
+ (already present, same key re-inserted) is NOT reported via
96
+ ``on_evict`` — that's a value update, not an eviction, and the
97
+ caller performing the ``insert()`` already knows about it
98
+ directly."""
99
+ evicted: list[tuple[str, _T, int]] = []
100
+ with self._lock:
101
+ if key in self._data:
102
+ _, old_size = self._data.pop(key)
103
+ self._total_bytes -= old_size
104
+ self._data[key] = (value, size_bytes)
105
+ self._total_bytes += size_bytes
106
+ while len(self._data) > 1 and (
107
+ len(self._data) > self._max_entries or self._total_bytes > self._max_bytes
108
+ ):
109
+ evicted_key, (evicted_value, evicted_size) = self._data.popitem(last=False)
110
+ self._total_bytes -= evicted_size
111
+ if self._on_evict is not None:
112
+ evicted.append((evicted_key, evicted_value, evicted_size))
113
+ # Callbacks fire AFTER releasing the lock (not each iteration inside
114
+ # the loop above): a lock this class doesn't own must never be
115
+ # acquired while this one is held, and ServerState's own callback
116
+ # (§4 of the phase-3 spec) does exactly that (its own `self._lock`).
117
+ # Reporting collected evictions post-unlock avoids any risk of a
118
+ # lock-ordering cycle, at the cost of a caller theoretically
119
+ # observing `on_evict` fire slightly after `insert()`'s own state
120
+ # change is externally visible — irrelevant here, every real
121
+ # `on_evict` caller is on the same single-threaded event loop.
122
+ if self._on_evict is not None:
123
+ for evicted_key, evicted_value, evicted_size in evicted:
124
+ self._on_evict(evicted_key, evicted_value, evicted_size)
125
+
126
+ def remove(self, key: str) -> _T | None:
127
+ """Explicit point-eviction of ``key`` — the value it held, or
128
+ ``None`` if ``key`` wasn't present. Deliberately does NOT invoke
129
+ ``on_evict`` (see the module/class docstrings): the caller
130
+ removing a specific key already knows it's doing so and updates
131
+ its own bookkeeping at the call site — ``ServerState``'s soft-cap
132
+ self-eviction (phase-3 spec §4) is the one real caller."""
133
+ with self._lock:
134
+ entry = self._data.pop(key, None)
135
+ if entry is None:
136
+ return None
137
+ value, size_bytes = entry
138
+ self._total_bytes -= size_bytes
139
+ return value
140
+
141
+ def get(self, key: str) -> _T | None:
142
+ """The value for ``key``, or ``None`` on a miss. A hit touches the
143
+ entry as most-recently-used (moves it to the end) — a genuine LRU
144
+ read, not just a lookup."""
145
+ with self._lock:
146
+ if key not in self._data:
147
+ return None
148
+ self._data.move_to_end(key)
149
+ value, _ = self._data[key]
150
+ return value
151
+
152
+ def __len__(self) -> int:
153
+ with self._lock:
154
+ return len(self._data)
155
+
156
+ @property
157
+ def total_bytes(self) -> int:
158
+ with self._lock:
159
+ return self._total_bytes
@@ -0,0 +1,102 @@
1
+ """Token-file loading + the static-token ``TokenVerifier`` implementation
2
+ behind ``refigure-mcp --transport http``. See
3
+ ``docs/mcp-server/mcp-server-phase3-http-auth/
4
+ mcp-server-phase3-http-auth-2026-08-21.md`` §2 for the design this module
5
+ implements, and architecture doc §6 for the requirements it comes from.
6
+
7
+ This module is a config-loading/auth-verification concern, not a
8
+ conversion-tool concern — its own ``ValueError``s are handled at CLI
9
+ startup (``cli.py``, before ``build_server()`` is even called), never
10
+ routed through ``server.py``'s ``_TYPED_EXCEPTIONS``/``_call_and_wrap_errors``
11
+ (that's for exceptions a live tool call can raise, which this isn't).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hmac
17
+ from pathlib import Path
18
+
19
+ from mcp.server.auth.provider import AccessToken, TokenVerifier
20
+
21
+ from .state import _LOCAL_CALLER_ID
22
+
23
+
24
+ def _normalize(value: str) -> str:
25
+ """The one normalization function used both when loading the token
26
+ file and (indirectly, via the tuples ``_StaticTokenVerifier`` is built
27
+ from) when verifying a request's bearer token — architecture doc §6:
28
+ "одна функция нормализации ... на стартовую дедупликацию и
29
+ рантайм-проверку". Case-sensitive, ``strip()`` only — no case-folding,
30
+ a bearer token is opaque data, not a human-typed identifier."""
31
+ return value.strip()
32
+
33
+
34
+ def load_token_file(path: Path) -> dict[str, str]:
35
+ """Parse a ``--mcp-auth-token-file``: one ``token = caller_id`` pair
36
+ per non-blank line (exactly one ``=``; no comment syntax — the
37
+ architecture doc doesn't call for one, and this format doesn't need
38
+ it). Returns ``{token: caller_id}``.
39
+
40
+ Raises ``ValueError`` (line number included in the message) on: a
41
+ line with no ``=`` or more than one; an empty token or ``caller_id``
42
+ after normalization; ``caller_id == "__local__"`` (reserved for the
43
+ stdio/in-memory sentinel, architecture doc §6 — accepting it here
44
+ would let a configured HTTP caller collide with the unauthenticated
45
+ local identity); the SAME token value mapped to two DIFFERENT
46
+ ``caller_id``s (almost certainly a typo — silently letting the last
47
+ line win would hide a real misconfiguration). The same token mapped
48
+ to the SAME ``caller_id`` twice is a harmless idempotent duplicate,
49
+ not an error. DIFFERENT tokens mapped to the same ``caller_id`` is
50
+ the normal token-rotation case (architecture doc §6) and needs no
51
+ special handling at all — it's just two distinct dict keys."""
52
+ token_map: dict[str, str] = {}
53
+ for lineno, raw_line in enumerate(path.read_text().splitlines(), start=1):
54
+ line = raw_line.strip()
55
+ if not line:
56
+ continue
57
+ if line.count("=") != 1:
58
+ raise ValueError(
59
+ f"{path}:{lineno}: expected exactly one 'token = caller_id' pair, got {raw_line!r}"
60
+ )
61
+ raw_token, raw_caller_id = line.split("=", 1)
62
+ token = _normalize(raw_token)
63
+ caller_id = _normalize(raw_caller_id)
64
+ if not token or not caller_id:
65
+ raise ValueError(f"{path}:{lineno}: token and caller_id must both be non-empty")
66
+ if caller_id == _LOCAL_CALLER_ID:
67
+ raise ValueError(
68
+ f"{path}:{lineno}: caller_id {_LOCAL_CALLER_ID!r} is reserved "
69
+ "for the stdio/unauthenticated identity, not usable in a token file"
70
+ )
71
+ existing = token_map.get(token)
72
+ if existing is not None and existing != caller_id:
73
+ raise ValueError(
74
+ f"{path}:{lineno}: token already maps to caller_id {existing!r}, "
75
+ f"got a second, different caller_id {caller_id!r} for the same token value"
76
+ )
77
+ token_map[token] = caller_id
78
+ return token_map
79
+
80
+
81
+ class _StaticTokenVerifier(TokenVerifier):
82
+ """``TokenVerifier`` (SDK protocol, ``mcp.server.auth.provider``) over a
83
+ fixed ``{token: caller_id}`` map loaded once at server startup — no
84
+ live-reload (phase-3 spec, out of scope; architecture doc §6 phrases
85
+ it conditionally, "если появится").
86
+
87
+ Verification is a linear scan with ``hmac.compare_digest`` per
88
+ candidate, NOT a dict lookup by token value — architecture doc §6:
89
+ "сравнение — constant-time". A dict lookup is faster (O(1) vs. O(n))
90
+ but its timing depends on the incoming token's hash/equality path in
91
+ a way ``compare_digest`` is specifically designed to avoid; the token
92
+ list in a self-hosted deployment is small (single/low-double digits),
93
+ so the O(n) scan costs nothing that matters here."""
94
+
95
+ def __init__(self, token_map: dict[str, str]) -> None:
96
+ self._entries: tuple[tuple[str, str], ...] = tuple(token_map.items())
97
+
98
+ async def verify_token(self, token: str) -> AccessToken | None:
99
+ for configured_token, caller_id in self._entries:
100
+ if hmac.compare_digest(token, configured_token):
101
+ return AccessToken(token=token, client_id=caller_id, scopes=[])
102
+ return None