offagent 0.10.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.
Files changed (39) hide show
  1. offagent/__init__.py +3 -0
  2. offagent/__main__.py +5 -0
  3. offagent/adapters/__init__.py +1 -0
  4. offagent/adapters/docx_adapter.py +1237 -0
  5. offagent/adapters/embedding_provider.py +132 -0
  6. offagent/adapters/pptx_adapter.py +940 -0
  7. offagent/adapters/xlsx_adapter.py +1266 -0
  8. offagent/app/__init__.py +1 -0
  9. offagent/app/progress.py +52 -0
  10. offagent/app/services.py +4267 -0
  11. offagent/config.py +287 -0
  12. offagent/domain/__init__.py +1 -0
  13. offagent/domain/locators.py +444 -0
  14. offagent/domain/models.py +477 -0
  15. offagent/domain/text_fragments.py +136 -0
  16. offagent/errors.py +29 -0
  17. offagent/indexing/__init__.py +1 -0
  18. offagent/indexing/store.py +795 -0
  19. offagent/interfaces/__init__.py +1 -0
  20. offagent/interfaces/cli.py +438 -0
  21. offagent/interfaces/cli_output.py +139 -0
  22. offagent/interfaces/cli_progress.py +120 -0
  23. offagent/interfaces/mcp.py +1145 -0
  24. offagent/interfaces/mcp_converters.py +80 -0
  25. offagent/interfaces/mcp_models.py +923 -0
  26. offagent/objects/__init__.py +3 -0
  27. offagent/objects/base.py +26 -0
  28. offagent/objects/docx_objects.py +951 -0
  29. offagent/objects/pptx_objects.py +895 -0
  30. offagent/objects/xlsx_objects.py +962 -0
  31. offagent/path_policy.py +42 -0
  32. offagent/storage/__init__.py +1 -0
  33. offagent/storage/versioning.py +31 -0
  34. offagent-0.10.0.dist-info/METADATA +546 -0
  35. offagent-0.10.0.dist-info/RECORD +39 -0
  36. offagent-0.10.0.dist-info/WHEEL +5 -0
  37. offagent-0.10.0.dist-info/entry_points.txt +2 -0
  38. offagent-0.10.0.dist-info/licenses/LICENSE +21 -0
  39. offagent-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1145 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Callable, TypeVar
6
+
7
+ from offagent.adapters import pptx_adapter, xlsx_adapter
8
+ from offagent.app.services import AppServices
9
+ from offagent.config import AppConfig
10
+ from offagent.errors import (
11
+ InvalidArgumentsError,
12
+ PolicyRefusedError,
13
+ StaleLocatorError,
14
+ TargetNotEditableError,
15
+ TargetNotFoundError,
16
+ )
17
+ from offagent.interfaces import mcp_converters
18
+ from offagent.interfaces.mcp_models import (
19
+ AddContentBlockRequest,
20
+ BatchEditRequest,
21
+ BatchResultModel,
22
+ CopyObjectRequest,
23
+ CreateDocumentRequest,
24
+ CreateObjectRequest,
25
+ DeleteObjectRequest,
26
+ DocxAddTableRequest,
27
+ DocxGetTablesResult,
28
+ DocxInsertPageBreakRequest,
29
+ DocxMergeTableCellsRequest,
30
+ DocxSetParagraphStyleRequest,
31
+ GetObjectRequest,
32
+ GetObjectResult,
33
+ GetNodeResult,
34
+ GetSectionResult,
35
+ GetStructureResult,
36
+ IndexDocumentsRequest,
37
+ IndexDocumentsResult,
38
+ IndexPathResult,
39
+ InsertContentRequest,
40
+ InsertContentResultModel,
41
+ ListChildrenRequest,
42
+ ListChildrenResult,
43
+ ListDocumentsResult,
44
+ MoveObjectRequest,
45
+ MutationResultModel,
46
+ NodeRequest,
47
+ PptxAddSlideRequest,
48
+ PptxAddTextShapeRequest,
49
+ PptxDuplicateSlideRequest,
50
+ PptxSetSlideLayoutRequest,
51
+ RefreshDocumentRequest,
52
+ RefreshDocumentResult,
53
+ SearchDocumentsRequest,
54
+ SearchDocumentsResult,
55
+ SearchObjectsResult,
56
+ SectionRequest,
57
+ SemanticDocumentRequest,
58
+ SetStructuralRoleRequest,
59
+ StyleBlockRequest,
60
+ StyleInlineRequest,
61
+ UpdateObjectRequest,
62
+ WriteNodeRequest,
63
+ WriteNodeResult,
64
+ XlsxInsertColumnsRequest,
65
+ XlsxInsertRowsRequest,
66
+ XlsxInsertRowsResultModel,
67
+ XlsxMergeCellsRequest,
68
+ XlsxSetFormulaRequest,
69
+ XlsxWriteRangeRequest,
70
+ )
71
+
72
+ try:
73
+ from mcp.server.fastmcp import FastMCP
74
+ from mcp.server.fastmcp.exceptions import ToolError
75
+ except (
76
+ ModuleNotFoundError
77
+ ): # pragma: no cover - exercised when MCP dependency is absent
78
+ FastMCP = None
79
+ ToolError = None
80
+
81
+ LOGGER = logging.getLogger(__name__)
82
+
83
+ EXPECTED_TOOL_ERRORS = (
84
+ FileNotFoundError,
85
+ InvalidArgumentsError,
86
+ TargetNotFoundError,
87
+ TargetNotEditableError,
88
+ PolicyRefusedError,
89
+ StaleLocatorError,
90
+ RuntimeError,
91
+ pptx_adapter.TargetNotEditableError,
92
+ xlsx_adapter.TargetNotAppendableError,
93
+ )
94
+
95
+ SERVER_INSTRUCTIONS = (
96
+ "Office Agent exposes MCP tools for Office document workflows. "
97
+ "Use `get_structure` to navigate a document, `get_section` to inspect one section, "
98
+ "`get_object` and `list_children` for typed object traversal, and `get_node` or `write_node` "
99
+ "for leaf-node reads and writes. Any locator returned by search or structure tools is valid in object "
100
+ "and node tools "
101
+ "for the same document version. Format-specific tools include `insert_content`, "
102
+ "`docx_get_tables`, DOCX/PPTX/XLSX escape hatches, and the overloaded `xlsx_insert_rows` tool. "
103
+ "`search_objects` is the canonical V2 search tool; `search_documents` remains as a deprecated compatibility alias."
104
+ )
105
+
106
+ T = TypeVar("T")
107
+
108
+
109
+ def build_mcp_server(config: AppConfig):
110
+ if FastMCP is None or ToolError is None:
111
+ raise RuntimeError("The MCP Python SDK is required to run `office-agent mcp`.")
112
+
113
+ services = AppServices(config)
114
+ mcp = FastMCP("Office Agent", instructions=SERVER_INSTRUCTIONS, json_response=True)
115
+
116
+ @mcp.tool(description="Index one or more Office document paths or directories.")
117
+ def index_documents(paths: list[str]) -> IndexDocumentsResult:
118
+ request = IndexDocumentsRequest.model_validate({"paths": paths})
119
+
120
+ def runner() -> IndexDocumentsResult:
121
+ results: list[IndexPathResult] = []
122
+ for raw_path in request.paths:
123
+ path = Path(raw_path).expanduser()
124
+ summary = services.index_path(path)
125
+ results.append(
126
+ IndexPathResult.from_index_summary(path.resolve(), summary)
127
+ )
128
+ return IndexDocumentsResult.from_results(results)
129
+
130
+ return _run_tool(runner)
131
+
132
+ @mcp.tool(description="Refresh an indexed document by document id.")
133
+ def refresh_document(document_id: str) -> RefreshDocumentResult:
134
+ request = RefreshDocumentRequest.model_validate({"document_id": document_id})
135
+
136
+ def runner() -> RefreshDocumentResult:
137
+ summary = services.refresh_document(request.document_id)
138
+ document = services.get_document(request.document_id)
139
+ return RefreshDocumentResult.from_refresh(document, summary)
140
+
141
+ return _run_tool(runner)
142
+
143
+ @mcp.tool(description="List indexed documents known to Office Agent.")
144
+ def list_documents() -> ListDocumentsResult:
145
+ return _run_tool(
146
+ lambda: ListDocumentsResult.from_documents(services.list_documents())
147
+ )
148
+
149
+ @mcp.tool(
150
+ description="Deprecated alias for `search_objects` that preserves the pre-V2 response shape."
151
+ )
152
+ def search_documents(
153
+ query: str,
154
+ file_type: str | None = None,
155
+ document_id: str | None = None,
156
+ mode: str = "keyword",
157
+ limit: int = 20,
158
+ ) -> SearchDocumentsResult:
159
+ request = SearchDocumentsRequest.model_validate(
160
+ {
161
+ "query": query,
162
+ "file_type": file_type,
163
+ "document_id": document_id,
164
+ "mode": mode,
165
+ "limit": limit,
166
+ }
167
+ )
168
+
169
+ def runner() -> SearchDocumentsResult:
170
+ document_path = None
171
+ if request.document_id is not None:
172
+ document_path = services.resolve_document_path(request.document_id)
173
+ hits = services.search_corpus(
174
+ request.query,
175
+ file_type=request.file_type,
176
+ document_path=document_path,
177
+ limit=request.limit,
178
+ mode=request.mode,
179
+ )
180
+ return SearchDocumentsResult.from_hits(hits)
181
+
182
+ return _run_tool(runner)
183
+
184
+ @mcp.tool(
185
+ description="Return the top-level structure for an indexed Office document."
186
+ )
187
+ def get_structure(document_id: str) -> GetStructureResult:
188
+ request = SemanticDocumentRequest.model_validate({"document_id": document_id})
189
+ return _run_tool(
190
+ lambda: mcp_converters.convert_get_structure(
191
+ services.get_structure(request.document_id)
192
+ )
193
+ )
194
+
195
+ @mcp.tool(
196
+ description="Return the full structured payload for one document section."
197
+ )
198
+ def get_section(
199
+ document_id: str,
200
+ section_id: str,
201
+ cell_range: str | None = None,
202
+ ) -> GetSectionResult:
203
+ request = SectionRequest.model_validate(
204
+ {
205
+ "document_id": document_id,
206
+ "section_id": section_id,
207
+ "cell_range": cell_range,
208
+ }
209
+ )
210
+ return _run_tool(
211
+ lambda: mcp_converters.convert_get_section(
212
+ services.get_section(
213
+ request.document_id,
214
+ request.section_id,
215
+ cell_range=request.cell_range,
216
+ )
217
+ )
218
+ )
219
+
220
+ @mcp.tool(description="Read the current content for a single node locator.")
221
+ def get_node(document_id: str, node_id: str) -> GetNodeResult:
222
+ request = NodeRequest.model_validate(
223
+ {"document_id": document_id, "node_id": node_id}
224
+ )
225
+ return _run_tool(
226
+ lambda: mcp_converters.convert_get_node(
227
+ services.get_node(request.document_id, request.node_id)
228
+ )
229
+ )
230
+
231
+ @mcp.tool(
232
+ description="Replace content at a single node locator and re-index the output document."
233
+ )
234
+ def write_node(
235
+ document_id: str,
236
+ node_id: str,
237
+ content: str,
238
+ output_mode: str = "versioned",
239
+ ) -> WriteNodeResult:
240
+ request = WriteNodeRequest.model_validate(
241
+ {
242
+ "document_id": document_id,
243
+ "node_id": node_id,
244
+ "content": content,
245
+ "output_mode": output_mode,
246
+ }
247
+ )
248
+ return _run_tool(
249
+ lambda: mcp_converters.convert_write_node(
250
+ services.write_node(
251
+ request.document_id,
252
+ request.node_id,
253
+ request.content,
254
+ output_mode=request.output_mode,
255
+ )
256
+ )
257
+ )
258
+
259
+ @mcp.tool(
260
+ description="Insert a new DOCX paragraph, optionally after an existing locator."
261
+ )
262
+ def insert_content(
263
+ document_id: str,
264
+ content: str,
265
+ style_name: str | None = None,
266
+ after_node_id: str | None = None,
267
+ output_mode: str = "versioned",
268
+ ) -> InsertContentResultModel:
269
+ request = InsertContentRequest.model_validate(
270
+ {
271
+ "document_id": document_id,
272
+ "content": content,
273
+ "style_name": style_name,
274
+ "after_node_id": after_node_id,
275
+ "output_mode": output_mode,
276
+ }
277
+ )
278
+ return _run_tool(
279
+ lambda: mcp_converters.convert_insert_content(
280
+ services.insert_content(
281
+ request.document_id,
282
+ request.content,
283
+ style_name=request.style_name,
284
+ after_node_id=request.after_node_id,
285
+ output_mode=request.output_mode,
286
+ )
287
+ )
288
+ )
289
+
290
+ @mcp.tool(
291
+ description="Create a new empty DOCX, PPTX, or XLSX document and index it immediately."
292
+ )
293
+ def create_document(
294
+ format: str,
295
+ output_path: str,
296
+ initial_sheet_name: str | None = None,
297
+ output_mode: str = "versioned",
298
+ ) -> MutationResultModel:
299
+ request = CreateDocumentRequest.model_validate(
300
+ {
301
+ "format": format,
302
+ "output_path": output_path,
303
+ "initial_sheet_name": initial_sheet_name,
304
+ "output_mode": output_mode,
305
+ }
306
+ )
307
+ return _run_tool(
308
+ lambda: mcp_converters.convert_mutation_result(
309
+ services.create_document(
310
+ request.format,
311
+ Path(request.output_path).expanduser(),
312
+ initial_sheet_name=request.initial_sheet_name,
313
+ output_mode=request.output_mode,
314
+ )
315
+ )
316
+ )
317
+
318
+ @mcp.tool(
319
+ description="Add a format-specific content block to an existing Office document."
320
+ )
321
+ def add_content_block(
322
+ document_id: str,
323
+ block_type: str,
324
+ properties: dict,
325
+ output_mode: str = "versioned",
326
+ ) -> MutationResultModel:
327
+ request = AddContentBlockRequest.model_validate(
328
+ {
329
+ "document_id": document_id,
330
+ "block_type": block_type,
331
+ "properties": properties,
332
+ "output_mode": output_mode,
333
+ }
334
+ )
335
+ return _run_tool(
336
+ lambda: mcp_converters.convert_mutation_result(
337
+ services.add_content_block(
338
+ request.document_id,
339
+ request.block_type,
340
+ request.properties,
341
+ output_mode=request.output_mode,
342
+ )
343
+ )
344
+ )
345
+
346
+ @mcp.tool(
347
+ description="Apply inline font styling to a DOCX run, PPTX text run, or XLSX cell."
348
+ )
349
+ def style_inline(
350
+ document_id: str,
351
+ locator: str,
352
+ style: dict,
353
+ range: dict | None = None,
354
+ clear_fields: list[str] | None = None,
355
+ output_mode: str = "versioned",
356
+ ) -> MutationResultModel:
357
+ request = StyleInlineRequest.model_validate(
358
+ {
359
+ "document_id": document_id,
360
+ "locator": locator,
361
+ "style": style,
362
+ "range": range,
363
+ "clear_fields": clear_fields or [],
364
+ "output_mode": output_mode,
365
+ }
366
+ )
367
+ return _run_tool(
368
+ lambda: mcp_converters.convert_mutation_result(
369
+ services.style_inline(
370
+ request.document_id,
371
+ request.locator,
372
+ request.style.to_domain(),
373
+ request.clear_fields,
374
+ text_range=None
375
+ if request.range is None
376
+ else request.range.to_domain(),
377
+ output_mode=request.output_mode,
378
+ )
379
+ )
380
+ )
381
+
382
+ @mcp.tool(
383
+ description="Apply block-level styling to a DOCX paragraph, PPTX paragraph, or XLSX cell."
384
+ )
385
+ def style_block(
386
+ document_id: str,
387
+ locator: str,
388
+ style: dict,
389
+ clear_fields: list[str] | None = None,
390
+ output_mode: str = "versioned",
391
+ ) -> MutationResultModel:
392
+ request = StyleBlockRequest.model_validate(
393
+ {
394
+ "document_id": document_id,
395
+ "locator": locator,
396
+ "style": style,
397
+ "clear_fields": clear_fields or [],
398
+ "output_mode": output_mode,
399
+ }
400
+ )
401
+ return _run_tool(
402
+ lambda: mcp_converters.convert_mutation_result(
403
+ services.style_block(
404
+ request.document_id,
405
+ request.locator,
406
+ request.style.to_domain(),
407
+ request.clear_fields,
408
+ output_mode=request.output_mode,
409
+ )
410
+ )
411
+ )
412
+
413
+ @mcp.tool(
414
+ description="Apply a DOCX structural role using standard Word paragraph styles."
415
+ )
416
+ def set_structural_role(
417
+ document_id: str,
418
+ locator: str,
419
+ role: str,
420
+ level: int | None = None,
421
+ output_mode: str = "versioned",
422
+ ) -> MutationResultModel:
423
+ request = SetStructuralRoleRequest.model_validate(
424
+ {
425
+ "document_id": document_id,
426
+ "locator": locator,
427
+ "role": role,
428
+ "level": level,
429
+ "output_mode": output_mode,
430
+ }
431
+ )
432
+ return _run_tool(
433
+ lambda: mcp_converters.convert_mutation_result(
434
+ services.set_structural_role(
435
+ request.document_id,
436
+ request.locator,
437
+ request.role,
438
+ level=request.level,
439
+ output_mode=request.output_mode,
440
+ )
441
+ )
442
+ )
443
+
444
+ @mcp.tool(
445
+ description="Return all DOCX tables with locators that can be passed to get_section."
446
+ )
447
+ def docx_get_tables(document_id: str) -> DocxGetTablesResult:
448
+ request = SemanticDocumentRequest.model_validate({"document_id": document_id})
449
+ return _run_tool(
450
+ lambda: mcp_converters.convert_docx_get_tables(
451
+ services.docx_get_tables(request.document_id)
452
+ )
453
+ )
454
+
455
+ _register_v2_tools(mcp, services)
456
+ _register_escape_hatch_tools(mcp, services)
457
+
458
+ return mcp
459
+
460
+
461
+ def _register_v2_tools(mcp, services: AppServices) -> None:
462
+ @mcp.tool(description="Return a structured V2 object payload for a typed locator.")
463
+ def get_object(document_id: str, locator: str) -> GetObjectResult:
464
+ request = GetObjectRequest.model_validate(
465
+ {"document_id": document_id, "locator": locator}
466
+ )
467
+ return _run_tool(
468
+ lambda: mcp_converters.convert_get_object(
469
+ services.get_object(request.document_id, request.locator)
470
+ )
471
+ )
472
+
473
+ @mcp.tool(description="List child objects for a V2 typed locator.")
474
+ def list_children(
475
+ document_id: str,
476
+ locator: str,
477
+ child_type: str | None = None,
478
+ limit: int | None = None,
479
+ ) -> ListChildrenResult:
480
+ request = ListChildrenRequest.model_validate(
481
+ {
482
+ "document_id": document_id,
483
+ "locator": locator,
484
+ "child_type": child_type,
485
+ "limit": limit,
486
+ }
487
+ )
488
+ return _run_tool(
489
+ lambda: mcp_converters.convert_list_children(
490
+ services.list_children(
491
+ request.document_id,
492
+ request.locator,
493
+ child_type=request.child_type,
494
+ limit=request.limit,
495
+ )
496
+ )
497
+ )
498
+
499
+ @mcp.tool(description="Create a new child object under a V2 parent locator.")
500
+ def create_object(
501
+ document_id: str,
502
+ parent_locator: str,
503
+ object_type: str,
504
+ properties: dict,
505
+ segments: list[dict] | None = None,
506
+ range: dict | None = None,
507
+ position: object | None = None,
508
+ output_mode: str = "versioned",
509
+ ) -> MutationResultModel:
510
+ request = CreateObjectRequest.model_validate(
511
+ {
512
+ "document_id": document_id,
513
+ "parent_locator": parent_locator,
514
+ "object_type": object_type,
515
+ "properties": properties,
516
+ "segments": segments,
517
+ "range": range,
518
+ "position": position,
519
+ "output_mode": output_mode,
520
+ }
521
+ )
522
+ return _run_tool(
523
+ lambda: mcp_converters.convert_mutation_result(
524
+ services.create_object(
525
+ request.document_id,
526
+ request.parent_locator,
527
+ request.object_type,
528
+ request.properties,
529
+ request.position,
530
+ None
531
+ if request.segments is None
532
+ else [fragment.to_domain() for fragment in request.segments],
533
+ None if request.range is None else request.range.to_domain(),
534
+ output_mode=request.output_mode,
535
+ )
536
+ )
537
+ )
538
+
539
+ @mcp.tool(description="Update a V2 object using editable properties.")
540
+ def update_object(
541
+ document_id: str,
542
+ locator: str,
543
+ properties: dict,
544
+ segments: list[dict] | None = None,
545
+ range: dict | None = None,
546
+ output_mode: str = "versioned",
547
+ ) -> MutationResultModel:
548
+ request = UpdateObjectRequest.model_validate(
549
+ {
550
+ "document_id": document_id,
551
+ "locator": locator,
552
+ "properties": properties,
553
+ "segments": segments,
554
+ "range": range,
555
+ "output_mode": output_mode,
556
+ }
557
+ )
558
+ return _run_tool(
559
+ lambda: mcp_converters.convert_mutation_result(
560
+ services.update_object(
561
+ request.document_id,
562
+ request.locator,
563
+ request.properties,
564
+ None
565
+ if request.segments is None
566
+ else [fragment.to_domain() for fragment in request.segments],
567
+ None if request.range is None else request.range.to_domain(),
568
+ output_mode=request.output_mode,
569
+ )
570
+ )
571
+ )
572
+
573
+ @mcp.tool(description="Move a V2 object within a valid parent container.")
574
+ def move_object(
575
+ document_id: str,
576
+ locator: str,
577
+ new_parent_locator: str,
578
+ position: object | None = None,
579
+ output_mode: str = "versioned",
580
+ ) -> MutationResultModel:
581
+ request = MoveObjectRequest.model_validate(
582
+ {
583
+ "document_id": document_id,
584
+ "locator": locator,
585
+ "new_parent_locator": new_parent_locator,
586
+ "position": position,
587
+ "output_mode": output_mode,
588
+ }
589
+ )
590
+ return _run_tool(
591
+ lambda: mcp_converters.convert_mutation_result(
592
+ services.move_object(
593
+ request.document_id,
594
+ request.locator,
595
+ request.new_parent_locator,
596
+ request.position,
597
+ output_mode=request.output_mode,
598
+ )
599
+ )
600
+ )
601
+
602
+ @mcp.tool(description="Copy a V2 object into a valid parent container.")
603
+ def copy_object(
604
+ document_id: str,
605
+ locator: str,
606
+ target_parent_locator: str,
607
+ position: object | None = None,
608
+ output_mode: str = "versioned",
609
+ ) -> MutationResultModel:
610
+ request = CopyObjectRequest.model_validate(
611
+ {
612
+ "document_id": document_id,
613
+ "locator": locator,
614
+ "target_parent_locator": target_parent_locator,
615
+ "position": position,
616
+ "output_mode": output_mode,
617
+ }
618
+ )
619
+ return _run_tool(
620
+ lambda: mcp_converters.convert_mutation_result(
621
+ services.copy_object(
622
+ request.document_id,
623
+ request.locator,
624
+ request.target_parent_locator,
625
+ request.position,
626
+ output_mode=request.output_mode,
627
+ )
628
+ )
629
+ )
630
+
631
+ @mcp.tool(
632
+ description="Apply a sequence of V2 object operations atomically to a single document."
633
+ )
634
+ def batch_edit(
635
+ document_id: str,
636
+ operations: list[dict],
637
+ output_mode: str = "versioned",
638
+ dry_run: bool = False,
639
+ ) -> BatchResultModel:
640
+ request = BatchEditRequest.model_validate(
641
+ {
642
+ "document_id": document_id,
643
+ "operations": operations,
644
+ "output_mode": output_mode,
645
+ "dry_run": dry_run,
646
+ }
647
+ )
648
+ return _run_tool(
649
+ lambda: mcp_converters.convert_batch_result(
650
+ services.batch_edit(
651
+ request.document_id,
652
+ request.operations,
653
+ output_mode=request.output_mode,
654
+ dry_run=request.dry_run,
655
+ )
656
+ )
657
+ )
658
+
659
+ @mcp.tool(
660
+ description="Delete a V2 object when the locator advertises delete capability."
661
+ )
662
+ def delete_object(
663
+ document_id: str,
664
+ locator: str,
665
+ output_mode: str = "versioned",
666
+ ) -> MutationResultModel:
667
+ request = DeleteObjectRequest.model_validate(
668
+ {
669
+ "document_id": document_id,
670
+ "locator": locator,
671
+ "output_mode": output_mode,
672
+ }
673
+ )
674
+ return _run_tool(
675
+ lambda: mcp_converters.convert_mutation_result(
676
+ services.delete_object(
677
+ request.document_id,
678
+ request.locator,
679
+ output_mode=request.output_mode,
680
+ )
681
+ )
682
+ )
683
+
684
+ @mcp.tool(
685
+ description="Search indexed Office document content and return V2 object locators."
686
+ )
687
+ def search_objects(
688
+ query: str,
689
+ file_type: str | None = None,
690
+ document_id: str | None = None,
691
+ mode: str = "keyword",
692
+ limit: int = 20,
693
+ ) -> SearchObjectsResult:
694
+ request = SearchDocumentsRequest.model_validate(
695
+ {
696
+ "query": query,
697
+ "file_type": file_type,
698
+ "document_id": document_id,
699
+ "mode": mode,
700
+ "limit": limit,
701
+ }
702
+ )
703
+
704
+ def runner() -> SearchObjectsResult:
705
+ document_path = None
706
+ if request.document_id is not None:
707
+ document_path = services.resolve_document_path(request.document_id)
708
+ hits = services.search_corpus(
709
+ request.query,
710
+ file_type=request.file_type,
711
+ document_path=document_path,
712
+ limit=request.limit,
713
+ mode=request.mode,
714
+ )
715
+ return SearchObjectsResult.from_hits(hits)
716
+
717
+ return _run_tool(runner)
718
+
719
+
720
+ def _register_escape_hatch_tools(mcp, services: AppServices) -> None:
721
+ @mcp.tool(
722
+ description="Apply a named DOCX paragraph style after validating the document style catalog."
723
+ )
724
+ def docx_set_paragraph_style(
725
+ document_id: str,
726
+ locator: str,
727
+ style_name: str,
728
+ output_mode: str = "versioned",
729
+ ) -> MutationResultModel:
730
+ request = DocxSetParagraphStyleRequest.model_validate(
731
+ {
732
+ "document_id": document_id,
733
+ "locator": locator,
734
+ "style_name": style_name,
735
+ "output_mode": output_mode,
736
+ }
737
+ )
738
+ return _run_tool(
739
+ lambda: mcp_converters.convert_mutation_result(
740
+ services.docx_set_paragraph_style(
741
+ request.document_id,
742
+ request.locator,
743
+ request.style_name,
744
+ output_mode=request.output_mode,
745
+ )
746
+ )
747
+ )
748
+
749
+ @mcp.tool(description="Insert a DOCX page break after the referenced paragraph.")
750
+ def docx_insert_page_break(
751
+ document_id: str,
752
+ locator: str,
753
+ output_mode: str = "versioned",
754
+ ) -> MutationResultModel:
755
+ request = DocxInsertPageBreakRequest.model_validate(
756
+ {
757
+ "document_id": document_id,
758
+ "locator": locator,
759
+ "output_mode": output_mode,
760
+ }
761
+ )
762
+ return _run_tool(
763
+ lambda: mcp_converters.convert_mutation_result(
764
+ services.docx_insert_page_break(
765
+ request.document_id,
766
+ request.locator,
767
+ output_mode=request.output_mode,
768
+ )
769
+ )
770
+ )
771
+
772
+ @mcp.tool(
773
+ description="Insert a DOCX table with optional widths, style, and after-position."
774
+ )
775
+ def docx_add_table(
776
+ document_id: str,
777
+ row_count: int,
778
+ column_count: int,
779
+ position: object | None = None,
780
+ column_widths: list[int] | None = None,
781
+ style_name: str | None = None,
782
+ output_mode: str = "versioned",
783
+ ) -> MutationResultModel:
784
+ request = DocxAddTableRequest.model_validate(
785
+ {
786
+ "document_id": document_id,
787
+ "row_count": row_count,
788
+ "column_count": column_count,
789
+ "position": position,
790
+ "column_widths": column_widths,
791
+ "style_name": style_name,
792
+ "output_mode": output_mode,
793
+ }
794
+ )
795
+ return _run_tool(
796
+ lambda: mcp_converters.convert_mutation_result(
797
+ services.docx_add_table(
798
+ request.document_id,
799
+ request.row_count,
800
+ request.column_count,
801
+ position=request.position,
802
+ column_widths=request.column_widths,
803
+ style_name=request.style_name,
804
+ output_mode=request.output_mode,
805
+ )
806
+ )
807
+ )
808
+
809
+ @mcp.tool(description="Merge a rectangular range of DOCX table cells.")
810
+ def docx_merge_table_cells(
811
+ document_id: str,
812
+ start_locator: str,
813
+ end_locator: str,
814
+ output_mode: str = "versioned",
815
+ ) -> MutationResultModel:
816
+ request = DocxMergeTableCellsRequest.model_validate(
817
+ {
818
+ "document_id": document_id,
819
+ "start_locator": start_locator,
820
+ "end_locator": end_locator,
821
+ "output_mode": output_mode,
822
+ }
823
+ )
824
+ return _run_tool(
825
+ lambda: mcp_converters.convert_mutation_result(
826
+ services.docx_merge_table_cells(
827
+ request.document_id,
828
+ request.start_locator,
829
+ request.end_locator,
830
+ output_mode=request.output_mode,
831
+ )
832
+ )
833
+ )
834
+
835
+ @mcp.tool(
836
+ description="Add a new PPTX slide using a validated layout index or layout name."
837
+ )
838
+ def pptx_add_slide(
839
+ document_id: str,
840
+ layout_index: int | None = None,
841
+ layout_name: str | None = None,
842
+ output_mode: str = "versioned",
843
+ ) -> MutationResultModel:
844
+ request = PptxAddSlideRequest.model_validate(
845
+ {
846
+ "document_id": document_id,
847
+ "layout_index": layout_index,
848
+ "layout_name": layout_name,
849
+ "output_mode": output_mode,
850
+ }
851
+ )
852
+ return _run_tool(
853
+ lambda: mcp_converters.convert_mutation_result(
854
+ services.pptx_add_slide(
855
+ request.document_id,
856
+ layout_index=request.layout_index,
857
+ layout_name=request.layout_name,
858
+ output_mode=request.output_mode,
859
+ )
860
+ )
861
+ )
862
+
863
+ @mcp.tool(description="Duplicate a PPTX slide into a target slide position.")
864
+ def pptx_duplicate_slide(
865
+ document_id: str,
866
+ locator: str,
867
+ position: int | None = None,
868
+ output_mode: str = "versioned",
869
+ ) -> MutationResultModel:
870
+ request = PptxDuplicateSlideRequest.model_validate(
871
+ {
872
+ "document_id": document_id,
873
+ "locator": locator,
874
+ "position": position,
875
+ "output_mode": output_mode,
876
+ }
877
+ )
878
+ return _run_tool(
879
+ lambda: mcp_converters.convert_mutation_result(
880
+ services.pptx_duplicate_slide(
881
+ request.document_id,
882
+ request.locator,
883
+ position=request.position,
884
+ output_mode=request.output_mode,
885
+ )
886
+ )
887
+ )
888
+
889
+ @mcp.tool(description="Reassign a PPTX slide to a validated layout.")
890
+ def pptx_set_slide_layout(
891
+ document_id: str,
892
+ locator: str,
893
+ layout_index: int | None = None,
894
+ layout_name: str | None = None,
895
+ output_mode: str = "versioned",
896
+ ) -> MutationResultModel:
897
+ request = PptxSetSlideLayoutRequest.model_validate(
898
+ {
899
+ "document_id": document_id,
900
+ "locator": locator,
901
+ "layout_index": layout_index,
902
+ "layout_name": layout_name,
903
+ "output_mode": output_mode,
904
+ }
905
+ )
906
+ return _run_tool(
907
+ lambda: mcp_converters.convert_mutation_result(
908
+ services.pptx_set_slide_layout(
909
+ request.document_id,
910
+ request.locator,
911
+ layout_index=request.layout_index,
912
+ layout_name=request.layout_name,
913
+ output_mode=request.output_mode,
914
+ )
915
+ )
916
+ )
917
+
918
+ @mcp.tool(
919
+ description="Add a text box shape to a PPTX slide at the provided position and size."
920
+ )
921
+ def pptx_add_text_shape(
922
+ document_id: str,
923
+ locator: str,
924
+ text: str,
925
+ left: int,
926
+ top: int,
927
+ width: int,
928
+ height: int,
929
+ output_mode: str = "versioned",
930
+ ) -> MutationResultModel:
931
+ request = PptxAddTextShapeRequest.model_validate(
932
+ {
933
+ "document_id": document_id,
934
+ "locator": locator,
935
+ "text": text,
936
+ "left": left,
937
+ "top": top,
938
+ "width": width,
939
+ "height": height,
940
+ "output_mode": output_mode,
941
+ }
942
+ )
943
+ return _run_tool(
944
+ lambda: mcp_converters.convert_mutation_result(
945
+ services.pptx_add_text_shape(
946
+ request.document_id,
947
+ request.locator,
948
+ request.text,
949
+ left=request.left,
950
+ top=request.top,
951
+ width=request.width,
952
+ height=request.height,
953
+ output_mode=request.output_mode,
954
+ )
955
+ )
956
+ )
957
+
958
+ @mcp.tool(description="Write a 2D value grid into an XLSX range locator.")
959
+ def xlsx_write_range(
960
+ document_id: str,
961
+ locator: str,
962
+ values: list[list[object]],
963
+ output_mode: str = "versioned",
964
+ ) -> MutationResultModel:
965
+ request = XlsxWriteRangeRequest.model_validate(
966
+ {
967
+ "document_id": document_id,
968
+ "locator": locator,
969
+ "values": values,
970
+ "output_mode": output_mode,
971
+ }
972
+ )
973
+ return _run_tool(
974
+ lambda: mcp_converters.convert_mutation_result(
975
+ services.xlsx_write_range(
976
+ request.document_id,
977
+ request.locator,
978
+ request.values,
979
+ output_mode=request.output_mode,
980
+ )
981
+ )
982
+ )
983
+
984
+ @mcp.tool(
985
+ description=(
986
+ "Append rows to an XLSX worksheet using `sheet_name` plus `rows` or `records`, "
987
+ "or insert blank rows using `locator`, `row_number`, and `count`."
988
+ )
989
+ )
990
+ def xlsx_insert_rows(
991
+ document_id: str,
992
+ sheet_name: str | None = None,
993
+ rows: list[list[str]] | None = None,
994
+ records: list[dict[str, str]] | None = None,
995
+ locator: str | None = None,
996
+ row_number: int | None = None,
997
+ count: int | None = None,
998
+ output_mode: str = "versioned",
999
+ ) -> XlsxInsertRowsResultModel:
1000
+ request = XlsxInsertRowsRequest.model_validate(
1001
+ {
1002
+ "document_id": document_id,
1003
+ "sheet_name": sheet_name,
1004
+ "rows": rows,
1005
+ "records": records,
1006
+ "locator": locator,
1007
+ "row_number": row_number,
1008
+ "count": count,
1009
+ "output_mode": output_mode,
1010
+ }
1011
+ )
1012
+
1013
+ def runner() -> XlsxInsertRowsResultModel:
1014
+ if (
1015
+ request.locator is not None
1016
+ or request.row_number is not None
1017
+ or request.count is not None
1018
+ ):
1019
+ if (
1020
+ request.locator is None
1021
+ or request.row_number is None
1022
+ or request.count is None
1023
+ ):
1024
+ raise InvalidArgumentsError(
1025
+ "xlsx_insert_rows requires locator, row_number, and count for row insertion mode."
1026
+ )
1027
+ result = services.xlsx_insert_rows_at(
1028
+ request.document_id,
1029
+ request.locator,
1030
+ request.row_number,
1031
+ request.count,
1032
+ output_mode=request.output_mode,
1033
+ )
1034
+ return mcp_converters.convert_xlsx_insert_rows_result(result)
1035
+
1036
+ if request.sheet_name is None:
1037
+ raise InvalidArgumentsError(
1038
+ "xlsx_insert_rows requires sheet_name when using append rows mode."
1039
+ )
1040
+ result = services.xlsx_insert_rows(
1041
+ request.document_id,
1042
+ request.sheet_name,
1043
+ rows=request.rows,
1044
+ records=request.records,
1045
+ output_mode=request.output_mode,
1046
+ )
1047
+ return mcp_converters.convert_xlsx_insert_rows_result(result)
1048
+
1049
+ return _run_tool(runner)
1050
+
1051
+ @mcp.tool(description="Insert one or more blank columns into an XLSX worksheet.")
1052
+ def xlsx_insert_columns(
1053
+ document_id: str,
1054
+ locator: str,
1055
+ column_index: int,
1056
+ count: int,
1057
+ output_mode: str = "versioned",
1058
+ ) -> MutationResultModel:
1059
+ request = XlsxInsertColumnsRequest.model_validate(
1060
+ {
1061
+ "document_id": document_id,
1062
+ "locator": locator,
1063
+ "column_index": column_index,
1064
+ "count": count,
1065
+ "output_mode": output_mode,
1066
+ }
1067
+ )
1068
+ return _run_tool(
1069
+ lambda: mcp_converters.convert_mutation_result(
1070
+ services.xlsx_insert_columns(
1071
+ request.document_id,
1072
+ request.locator,
1073
+ request.column_index,
1074
+ request.count,
1075
+ output_mode=request.output_mode,
1076
+ )
1077
+ )
1078
+ )
1079
+
1080
+ @mcp.tool(description="Write a validated formula string into an XLSX cell.")
1081
+ def xlsx_set_formula(
1082
+ document_id: str,
1083
+ locator: str,
1084
+ formula: str,
1085
+ output_mode: str = "versioned",
1086
+ ) -> MutationResultModel:
1087
+ request = XlsxSetFormulaRequest.model_validate(
1088
+ {
1089
+ "document_id": document_id,
1090
+ "locator": locator,
1091
+ "formula": formula,
1092
+ "output_mode": output_mode,
1093
+ }
1094
+ )
1095
+ return _run_tool(
1096
+ lambda: mcp_converters.convert_mutation_result(
1097
+ services.xlsx_set_formula(
1098
+ request.document_id,
1099
+ request.locator,
1100
+ request.formula,
1101
+ output_mode=request.output_mode,
1102
+ )
1103
+ )
1104
+ )
1105
+
1106
+ @mcp.tool(description="Merge an XLSX rectangular range after overlap validation.")
1107
+ def xlsx_merge_cells(
1108
+ document_id: str,
1109
+ locator: str,
1110
+ output_mode: str = "versioned",
1111
+ ) -> MutationResultModel:
1112
+ request = XlsxMergeCellsRequest.model_validate(
1113
+ {
1114
+ "document_id": document_id,
1115
+ "locator": locator,
1116
+ "output_mode": output_mode,
1117
+ }
1118
+ )
1119
+ return _run_tool(
1120
+ lambda: mcp_converters.convert_mutation_result(
1121
+ services.xlsx_merge_cells(
1122
+ request.document_id,
1123
+ request.locator,
1124
+ output_mode=request.output_mode,
1125
+ )
1126
+ )
1127
+ )
1128
+
1129
+
1130
+ def run_mcp_server(config: AppConfig) -> None:
1131
+ build_mcp_server(config).run(transport="stdio")
1132
+
1133
+
1134
+ def _run_tool(callback: Callable[[], T]) -> T:
1135
+ try:
1136
+ return callback()
1137
+ except EXPECTED_TOOL_ERRORS as exc:
1138
+ if ToolError is None: # pragma: no cover - guarded by build_mcp_server
1139
+ raise RuntimeError(str(exc)) from exc
1140
+ raise ToolError(str(exc)) from exc
1141
+ except Exception as exc: # pragma: no cover - exercised through integration tests
1142
+ LOGGER.exception("Unhandled MCP tool failure")
1143
+ if ToolError is None:
1144
+ raise RuntimeError("Internal Office Agent MCP tool failure.") from exc
1145
+ raise ToolError("Internal Office Agent MCP tool failure.") from exc