docx-cli 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.
docx_cli/mcp_server.py ADDED
@@ -0,0 +1,380 @@
1
+ """docx-cli MCP stdio server: JSON-RPC 2.0 tool surface (v0.1.0, A-05).
2
+
3
+ Паттерн sud-cli (mcp_server.py): tools/list, tools/call, единый content-конверт
4
+ с isError; алиас-толерантность параметров через _pick (два имени с разными
5
+ значениями — явная ошибка, не молчаливый выбор). Межрепозиторийной зависимости
6
+ нет — это наша реализация того же контракта.
7
+
8
+ Права (sud W6a): каждый инструмент несёт поле permission read|write (и
9
+ permission-поле в каждой inputSchema); write-тулы (docx_build, docx_normalize,
10
+ docx_md_build) требуют явный confirm: true — без него status=needs_confirmation
11
+ и НИКАКИХ побочных эффектов (файл не пишется). needs_confirmation — данные,
12
+ не транспортная ошибка (isError=False).
13
+
14
+ Схемы: type object, required минимальный, additionalProperties:false —
15
+ неизвестный параметр явная ошибка, а не молчаливое игнорирование (fail-closed).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import io
21
+ import json
22
+ import sys
23
+ import tempfile
24
+ from contextlib import redirect_stdout
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import docx_cli
29
+ from docx_cli import __version__ as _PKG_VERSION
30
+
31
+ CONFIRM_PROPERTY = {
32
+ "type": "boolean",
33
+ "description": "write-инструмент: без confirm=true вернёт "
34
+ "status=needs_confirmation, запись не производится",
35
+ }
36
+
37
+
38
+ def _schema(properties: dict, required: list[str] | None = None,
39
+ write: bool = False) -> dict:
40
+ props = dict(properties)
41
+ props["permission"] = dict(PROPERTY_PERMISSION_DEFAULT)
42
+ if write:
43
+ props["confirm"] = dict(CONFIRM_PROPERTY)
44
+ return {"type": "object", "properties": props,
45
+ "required": required or [], "additionalProperties": False}
46
+
47
+
48
+ # permission-поле объявлено в КАЖДОЙ схеме (контракт §1); значение сверяется
49
+ # с декларируемым правом тула при диспатче (fail-closed на mismatch).
50
+ PROPERTY_PERMISSION_DEFAULT = {
51
+ "type": "string",
52
+ "enum": ["read", "write"],
53
+ "description": "декларируемое право: read|write; несовпадение с правом "
54
+ "тула — явная ошибка",
55
+ }
56
+
57
+ _PATH = {"type": "string", "description": "путь к файлу"}
58
+
59
+ TOOLS: list[dict[str, Any]] = [
60
+ {"name": "docx_build", "permission": "write",
61
+ "description": "Собрать .docx из DocSpec (машинный контракт профиля "
62
+ "gost|compact|court|vs_sud|soyu_rayon) по указанному пути. "
63
+ "write: требует confirm=true.",
64
+ "schema": _schema({
65
+ "spec": {"type": "object",
66
+ "description": "DocSpec JSON (profile/output/header/title/body)"},
67
+ "out": {**_PATH, "description": "путь итогового .docx; CLI-алиас: output"},
68
+ "output": {**_PATH, "description": "CLI-алиас out"},
69
+ }, required=["spec"], write=True)},
70
+ {"name": "docx_validate", "permission": "read",
71
+ "description": "Проверить существующий .docx валидатором оформления "
72
+ "(gost|review). Только чтение.",
73
+ "schema": _schema({
74
+ "file": {**_PATH, "description": "путь к .docx; CLI-алиас: path"},
75
+ "path": {**_PATH, "description": "CLI-алиас file"},
76
+ "mode": {"type": "string", "enum": ["gost", "review"], "default": "gost"},
77
+ "strict": {"type": "boolean", "default": False,
78
+ "description": "w/skip -> warnings_present semantics (as CLI --strict)"}},
79
+ required=["file"])},
80
+ {"name": "docx_scan", "permission": "read",
81
+ "description": "Аудит входящего .docx на скрытые LLM-векторы "
82
+ "(inject_scan как есть: vanish/микрошрифт/tracked/…). "
83
+ "Только чтение.",
84
+ "schema": _schema({
85
+ "file": {**_PATH, "description": "путь к .docx; CLI-алиас: path"},
86
+ "path": {**_PATH, "description": "CLI-алиас file"},
87
+ "a2a_args": {"type": "boolean", "default": False,
88
+ "description": "добавить bus-free a2a_command в конверт"}})},
89
+ {"name": "docx_normalize", "permission": "write",
90
+ "description": "Нормализовать шрифты/интервалы .docx к Times New Roman. "
91
+ "write: требует confirm=true; каталог — только yes=true.",
92
+ "schema": _schema({
93
+ "path": {**_PATH, "description": "файл или каталог .docx"},
94
+ "file": {**_PATH, "description": "CLI-алиас path"},
95
+ "body_size": {"type": "integer", "default": 12},
96
+ "header_size": {"type": "integer", "default": 11},
97
+ "dry_run": {"type": "boolean", "default": False,
98
+ "description": "показать изменения без записи"},
99
+ "backup": {"type": "boolean", "default": True},
100
+ "yes": {"type": "boolean", "default": False,
101
+ "description": "подтвердить каталожный режим"}},
102
+ required=["path"], write=True)},
103
+ {"name": "docx_md_build", "permission": "write",
104
+ "description": "Собрать процессуальный .docx из Markdown (шапка, титул, "
105
+ "**bold**, вложенные списки и pipe-таблицы v0.10). "
106
+ "write: требует confirm=true.",
107
+ "schema": _schema({
108
+ "md": {"type": "string",
109
+ "description": "текст markdown; альтернатива md_path"},
110
+ "md_path": {**_PATH, "description": "путь к .md (вместо md)"},
111
+ "out": {**_PATH, "description": "путь итогового .docx; CLI-алиас: output"},
112
+ "output": {**_PATH, "description": "CLI-алиас out"},
113
+ "author": {"type": "string", "default": "colleague"}},
114
+ required=["out"], write=True)},
115
+ {"name": "docx_doctor", "permission": "read",
116
+ "description": "Проверить окружение (python, python-docx, lxml, pydantic, "
117
+ "запись cwd). Только чтение.",
118
+ "schema": _schema({
119
+ "verify_signature": {"type": "boolean", "default": False},
120
+ "dist": {"type": "string", "default": "dist"}})},
121
+ {"name": "docx_agent_read", "permission": "read",
122
+ "description": "Ограниченное JSON-чтение .docx (окно + курсор). Только "
123
+ "чтение.",
124
+ "schema": _schema({
125
+ "file": {**_PATH, "description": "путь к .docx; CLI-алиас: path"},
126
+ "path": {**_PATH, "description": "CLI-алиас file"},
127
+ "start": {"type": "integer", "default": 0},
128
+ "max_chars": {"type": "integer", "default": 4000,
129
+ "description": "CLI-алиас max"},
130
+ "max": {"type": "integer", "description": "CLI-алиас max_chars"}})},
131
+ {"name": "docx_verify_forbidden", "permission": "read",
132
+ "description": "Проверить .docx (+опц. .md) на запрещённые строки "
133
+ "(desync-guard). Только чтение.",
134
+ "schema": _schema({
135
+ "docx": {**_PATH, "description": "путь к .docx; CLI-алиас: path"},
136
+ "path": {**_PATH, "description": "CLI-алиас docx"},
137
+ "md": {**_PATH, "description": "опциональный .md к проверке"},
138
+ "forbid": {"type": "array", "items": {"type": "string"},
139
+ "description": "запрещённые подстроки"},
140
+ "forbid_file": {**_PATH, "description": "файл списка паттернов"}},
141
+ required=["docx"])},
142
+ ]
143
+
144
+ TOOLS_BY_NAME = {t["name"]: t for t in TOOLS}
145
+
146
+
147
+ def _pick(values: dict, names: tuple[str, ...], label: str):
148
+ """Значение под любым из допустимых имён (CLI-алиас ↔ имя движка).
149
+ Два имени с РАЗНЫМИ значениями — явная ошибка (паттерн sud)."""
150
+ found = {n: values.pop(n) for n in names if n in values}
151
+ if not found:
152
+ return None
153
+ distinct = {repr(v) for v in found.values()}
154
+ if len(distinct) > 1:
155
+ raise ValueError(f"conflicting values for {label}: {sorted(found)} — "
156
+ "передайте только одно имя")
157
+ return next(iter(found.values()))
158
+
159
+
160
+ def _run_captured(func, ns: argparse.Namespace) -> dict:
161
+ """Запуск cmd_* из cli.py с перехватом stdout-конверта: MCP не дублирует
162
+ логику CLI, а переиспользует ровно тот же код и ровно тот же конверт."""
163
+ from docx_cli import cli as _cli
164
+ buf = io.StringIO()
165
+ with redirect_stdout(buf):
166
+ exit_code = func(ns)
167
+ text = buf.getvalue().strip()
168
+ payload = json.loads(text) if text else {}
169
+ payload.setdefault("exit_code", exit_code)
170
+ return payload
171
+
172
+
173
+ def _call(name: str, args: dict) -> dict:
174
+ """Диспатч инструментов. Ошибки — исключения (handle_request конвертит
175
+ в isError=True), сайд-эффекты только после confirm-гейта в _dispatch_gated."""
176
+ values = dict(args or {})
177
+ if name == "docx_build":
178
+ from docx_cli.builder import build_document
179
+ from docx_cli.spec import DocSpec
180
+ out = _pick(values, ("out", "output"), "out/output")
181
+ if out is None:
182
+ raise ValueError("out is required (путь итогового .docx)")
183
+ spec = DocSpec.from_dict(values["spec"])
184
+ return build_document(spec, str(out))
185
+ if name == "docx_validate":
186
+ from docx_cli import cli as _cli
187
+ file = _pick(values, ("file", "path"), "file/path")
188
+ if file is None:
189
+ raise ValueError("file is required")
190
+ return _run_captured(_cli.cmd_validate, argparse.Namespace(
191
+ file=str(file), json=True,
192
+ strict=bool(values.get("strict", False)),
193
+ mode=str(values.get("mode", "gost"))))
194
+ if name == "docx_scan":
195
+ from docx_cli import cli as _cli
196
+ file = _pick(values, ("file", "path"), "file/path")
197
+ if file is None:
198
+ raise ValueError("file is required")
199
+ return _run_captured(_cli.cmd_scan, argparse.Namespace(
200
+ file=str(file), json=True,
201
+ a2a_args=bool(values.get("a2a_args", False))))
202
+ if name == "docx_normalize":
203
+ from docx_cli.fonts_normalize import normalize_path
204
+ path = _pick(values, ("path", "file"), "path/file")
205
+ if path is None:
206
+ raise ValueError("path is required")
207
+ p = Path(str(path))
208
+ if p.is_dir() and not bool(values.get("yes", False)):
209
+ return {"ok": False, "status": "invalid_usage",
210
+ "reason": "directory mode requires yes=true"}
211
+ res = normalize_path(str(p),
212
+ body_size=int(values.get("body_size", 12)),
213
+ header_size=int(values.get("header_size", 11)),
214
+ dry_run=bool(values.get("dry_run", False)),
215
+ backup=bool(values.get("backup", True)),
216
+ yes=bool(values.get("yes", False)))
217
+ res.update({"ok": True, "status": "ok"})
218
+ return res
219
+ if name == "docx_md_build":
220
+ from docx_cli.md_court import md_build
221
+ out = _pick(values, ("out", "output"), "out/output")
222
+ if out is None:
223
+ raise ValueError("out is required")
224
+ md_text = values.get("md")
225
+ md_path = values.get("md_path")
226
+ if md_text is not None and md_path is not None:
227
+ raise ValueError("pass only one of md / md_path")
228
+ if md_text is None and md_path is None:
229
+ raise ValueError("md or md_path is required")
230
+ if md_text is not None:
231
+ tmp = Path(tempfile.mkdtemp(prefix="docx_mcp_md_")) / "input.md"
232
+ tmp.write_text(str(md_text), encoding="utf-8")
233
+ source = str(tmp)
234
+ else:
235
+ source = str(md_path)
236
+ res = md_build(source, str(out), str(values.get("author", "colleague")))
237
+ res.update({"ok": True, "status": "ok"})
238
+ return res
239
+ if name == "docx_doctor":
240
+ from docx_cli import cli as _cli
241
+ return _run_captured(_cli.cmd_doctor, argparse.Namespace(
242
+ json=True,
243
+ verify_signature=bool(values.get("verify_signature", False)),
244
+ dist=str(values.get("dist", "dist"))))
245
+ if name == "docx_agent_read":
246
+ from docx_cli import cli as _cli
247
+ file = _pick(values, ("file", "path"), "file/path")
248
+ if file is None:
249
+ raise ValueError("file is required")
250
+ max_chars = _pick(values, ("max_chars", "max"), "max_chars/max")
251
+ if max_chars is None:
252
+ max_chars = 4000
253
+ return _run_captured(_cli.cmd_agent_read, argparse.Namespace(
254
+ file=str(file), start=int(values.get("start", 0)),
255
+ max_chars=int(max_chars)))
256
+ if name == "docx_verify_forbidden":
257
+ from docx_cli import cli as _cli
258
+ docx_path = _pick(values, ("docx", "path"), "docx/path")
259
+ if docx_path is None:
260
+ raise ValueError("docx is required")
261
+ return _run_captured(_cli.cmd_verify_forbidden, argparse.Namespace(
262
+ docx=str(docx_path), md=values.get("md"),
263
+ forbid=list(values.get("forbid", [])),
264
+ forbid_file=values.get("forbid_file"), json=True))
265
+ raise KeyError(f"unknown tool: {name}")
266
+
267
+
268
+ def _check_arguments(tool: dict, args: dict) -> dict:
269
+ """fail-closed валидация аргументов ДО движка: unknown fields и
270
+ permission-mismatch не смещаются confirm-гейтом."""
271
+ if not isinstance(args, dict):
272
+ raise TypeError("arguments must be an object")
273
+ values = dict(args)
274
+ schema = tool["schema"]
275
+ allowed = set((schema.get("properties") or {}).keys())
276
+ unknown = set(values) - allowed
277
+ if unknown:
278
+ raise ValueError(f"unknown fields: {sorted(unknown)}")
279
+ perm = values.pop("permission", None)
280
+ if perm is not None and perm != tool["permission"]:
281
+ raise ValueError(
282
+ f"permission_mismatch: tool {tool['name']} declares "
283
+ f"'{tool['permission']}', got '{perm}'")
284
+ for field in schema.get("required", []):
285
+ if field not in values:
286
+ raise ValueError(f"missing required field: {field}")
287
+ confirm = None
288
+ if tool["permission"] == "write":
289
+ confirm = values.pop("confirm", None)
290
+ return {"values": values, "confirm": confirm}
291
+
292
+
293
+ def _dispatch_gated(name: str, args: dict) -> Any:
294
+ tool = TOOLS_BY_NAME.get(name)
295
+ if tool is None:
296
+ raise KeyError(f"unknown tool: {name}")
297
+ prepared = _check_arguments(tool, args)
298
+ if tool["permission"] == "write" and prepared["confirm"] is not True:
299
+ # needs_confirmation — ДАННЫЕ, не ошибка транспорта; запись не
300
+ # производилась ни байта (сайд-эффекты только в _call ниже).
301
+ return {"status": "needs_confirmation", "permission": "write",
302
+ "tool": name,
303
+ "detail": "write-инструмент требует явный confirm: true — "
304
+ "запись не произведена"}
305
+ return _call(name, prepared["values"])
306
+
307
+
308
+ def tool_list() -> dict:
309
+ return {"tools": [{"name": t["name"], "description": t["description"],
310
+ "inputSchema": t["schema"], "permission": t["permission"]}
311
+ for t in TOOLS]}
312
+
313
+
314
+ def handle_request(request: dict) -> dict | None:
315
+ method = request.get("method")
316
+ request_id = request.get("id")
317
+ if method == "initialize":
318
+ result = {"protocolVersion": "2024-11-05",
319
+ "capabilities": {"tools": {}},
320
+ "serverInfo": {"name": "docx-cli", "version": _PKG_VERSION}}
321
+ elif method == "notifications/initialized":
322
+ return None
323
+ elif method == "ping":
324
+ result = {}
325
+ elif method == "tools/list":
326
+ result = tool_list()
327
+ elif method == "tools/call":
328
+ try:
329
+ params = request.get("params", {})
330
+ value = _dispatch_gated(params["name"], params.get("arguments", {}))
331
+ if hasattr(value, "to_dict"):
332
+ value = value.to_dict()
333
+ result = {"content": [{"type": "text",
334
+ "text": json.dumps(value, ensure_ascii=False,
335
+ default=str)}],
336
+ "isError": False}
337
+ except (KeyError, TypeError, ValueError, OSError) as exc:
338
+ result = {"content": [{"type": "text", "text": str(exc)}],
339
+ "isError": True}
340
+ except Exception as exc: # доменные ошибки: stdio не умирает
341
+ result = {"content": [{"type": "text",
342
+ "text": f"unexpected: {type(exc).__name__}: {exc}"}],
343
+ "isError": True}
344
+ else:
345
+ return {"jsonrpc": "2.0", "id": request_id,
346
+ "error": {"code": -32601, "message": "Method not found"}}
347
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
348
+
349
+
350
+ def serve_stdio(input_stream=None, output_stream=None) -> None:
351
+ input_stream = input_stream or sys.stdin
352
+ output_stream = output_stream or sys.stdout
353
+ for line in input_stream:
354
+ if not line.strip():
355
+ continue
356
+ try:
357
+ frame = json.loads(line)
358
+ except json.JSONDecodeError as exc:
359
+ response = {"jsonrpc": "2.0", "id": None,
360
+ "error": {"code": -32700, "message": f"Parse error: {exc}"}}
361
+ else:
362
+ if not isinstance(frame, dict):
363
+ response = {"jsonrpc": "2.0", "id": None,
364
+ "error": {"code": -32600,
365
+ "message": "Invalid Request: not an object"}}
366
+ else:
367
+ response = handle_request(frame)
368
+ if response is not None:
369
+ output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
370
+ output_stream.flush()
371
+
372
+
373
+ def run_tools_dump() -> int:
374
+ print(json.dumps(tool_list(), ensure_ascii=False))
375
+ return 0
376
+
377
+
378
+ def run_stdio_server() -> int:
379
+ serve_stdio()
380
+ return 0