think
16px
820px

Office-editing MCP — Phase 1 implementation plan

Status: PLAN ONLY — execution reserved for a later ultracode session.
Branch: feat/mcp-office-edit (off main)
Companion: 2026-08-21-office-editing-mcp.md (brainstorm + probe results)
Migrations required: NONE. No schema change anywhere in phase 1.

Goal

Six new MCP tools so agents can edit office files in place (OnlyOffice Document Builder engine) and file new work: edit_office_document, create_folder, create_document, upload_document, move_document, rename_document.

Non-goals (phase 2+)

Letters (they have their own draftRev/apply flow), pptx guarantees, deletes, folder tree moves, secure-folder/vault targets, sharing, dry_run, live co-editing, per-tool granular licence bits.

Pre-verified facts (probe, demo 2026-08-21 — do not re-derive)

  • POST {ONLYOFFICE_INTERNAL_URL}/docbuilder works on our obscura-onlyoffice:8.3 image. Body {"async":false,"url":"<script url>","token":<jwt of body>} + header Authorization: Bearer <jwt of {"payload":body}>exactly the dual signing onlyofficeForceSave does (handlers_office_apply.go:110). Response {"end":true,"urls":{"out.docx":"http://<host>/cache/..."}}; rewrite the URL host to ONLYOFFICE_INTERNAL_URL before fetching (the advertised host is whatever Host header we sent).
  • builder.OpenFile(url) → edit → builder.SaveFile preserves existing content byte-for-byte outside the edit. No watermark. ~2–4 s round trip.
  • The doc-server can only reach in-network URLs. Serve everything via ONLYOFFICE_OBSCURA_URL (http://obscura:8080), like editor sessions already do.

Architecture

agent ──MCP──▶ handlers_mcp.go (gates: licence, RBAC/ACL, DLP, live-session, step-up)
                     wraps script: OpenFile(content-token URL) + agent delta ops + SaveFile
                    
             POST /docbuilder (dual JWT) ──▶ doc-server fetches script + current bytes
                                                  from http://obscura:8080/office/...
                    
             fetch result bytes ──▶ dms.AddVersion (extract hook re-runs, filing intact)
                    
             agent verifies with existing preview_document

Work items

WI-1 — Builder client + script hosting (~1 day)

New file go/internal/httpapi/handlers_office_builder.go:

  1. func (s *Server) onlyofficeDocbuilder(ctx context.Context, scriptURL string) (map[string]string, error) — clone the shape of onlyofficeForceSave (handlers_office_apply.go:110-122): same signJWT dual signing, POST to strings.TrimRight(s.cfg.OnlyofficeInternalURL,"/") + "/docbuilder", body {"async":false,"url":scriptURL}. 90 s client timeout. Map doc-server error codes to a kernel.Error (office.builder_failed).
  2. Script hosting: package-level sync.Map token→{script string, exp time.Time} (mirror the officeLiveKeys idiom, handlers_office_apply.go:47). Token = 32 random bytes hex. TTL 5 min, swept lazily on read.
    Route: r.Get("/office/builder/{token}", s.OfficeBuilderScript) registered next to /office/content/{token} in server.go:772 (same unauthenticated-but-token-gated group; the token is the credential, exactly like content tokens). Serves text/plain, expired/unknown → 404.
  3. Result fetch helper: GET each urls value with host rewritten to OnlyofficeInternalURL, cap result size 100 MB.

No new config. Feature is available iff OnlyofficeJWTSecret != "" (same fail-closed rule the editor uses, config.go:271).

WI-2 — edit_office_document tool (~2–3 days, the core)

Registered in mcpToolDefs behind s.moduleEnabled(ctx, "office") (same spot as create_document_from_template, handlers_mcp.go:222). Dispatch case in mcpRunTool.

Args: document_id (req), script (req), mode ("modify" default | "recreate"), comment (optional, for the version).

Gate order (all before any doc-server call). Audited 2026-08-21 against BOTH existing write paths — the upload handler (handlers_dms.go:156) and the editor mint/save (handlers_office.go:378 OfficeConfig, :886 saveOfficeEdit). ⚠️ AddVersion itself enforces ONLY quota + check-out + vault sealing — every other guard lives in the HTTP handlers and must be repeated here or the MCP path silently bypasses it:

  1. Licence: moduleEnabled("office") runtime re-check (mirror semantic_search's re-check pattern, handlers_mcp.go:756).
  2. Resolve doc as principal p; explicitly refuse trashed/disposed docs — NOT inherited from AddVersion, nothing below checks DeletedAt.
  3. doc.EditingLocked ("preserve original") → refuse. Enforced only at editor-session mint (handlers_office.go:454), never in AddVersion. Published letters set it (handlers_publish_letter.go:115) — without this gate an agent can rewrite a published letter's artifact.
  4. refuseLetterBytes(ctx, docID, "mcp-edit") (handlers_dms.go:174) — a letter's bytes come from numbering/seal ceremonies; same refusal the upload door has.
  5. guardActiveWorkflow(r, "document", docID) (handlers_esign.go:485) — new bytes under a running workflow mean an approver approves version N and a signer seals N+1. The HTTP guard has assignee/admin override semantics; MCP refuses unconditionally (no override — an agent is never the "current assignee acting deliberately").
  6. Edit-guard suspension (00205): s.editGuard.BlockedUntil(ctx, uid, docID) → refuse while blocked (handlers_office.go:395 pattern) — a tamper report suspends the MCP door too, not just the browser one.
  7. Write access: EffectiveDocumentAccess ≥ AccessReadWrite or content-admin — exactly saveOfficeEdit's re-check (handlers_office.go:920-931).
  8. DLP, BOTH gates: allow_ai_processing (the read_document gate, handlers_mcp.go:363-368) and downloadAllowed (the editor-open egress gate, handlers_office.go:410-418) — an MCP edit pulls raw bytes to the doc-server, which is an egress on par with download; a no-download classification must block this channel exactly as it blocks OfficeConfig.
  9. Step-up/vault: if s.dms.DocumentStepUpGate says the doc is gated, refuse (office.stepup_required) — MCP has no step-up ceremony.
  10. Checked-out doc: AddVersion enforces; map its dms.version.checked_out to a clear message.
  11. Live-session conflict: liveOfficeKey(officeSubDocument, docID) (handlers_office_apply.go:75) → if present, refuse office.editing_live. Belt-and-braces: also probe the command service with the computed key sanitizeKey(docID+"_"+officeVersion) (handlers_office.go:461) and treat anything but errOfficeNoSession as live.

Script wrapping (the modify-doctrine enforcement):
- Reject the agent script if it matches \bbuilder\s*\. — the agent NEVER opens, creates, saves, or closes files; the server does. Also cap script at 64 KB, reject empty.
- Find the office-format version: same walk the editor does (handlers_office.go:431-439, officeDocType) → officeVersion, ext (docx/xlsx; pptx passes through if officeDocType recognises it — best-effort, don't advertise).
- Wrap:
- modify: builder.OpenFile("<ONLYOFFICE_OBSCURA_URL>/office/content/<officeToken(ctx,"content",docID,officeVersion,uid)>", "<ext>") — note officeToken (handlers_office.go:288) already applies the vault-short-TTL rule; uid = the API key's user.
- recreate: builder.CreateFile("<ext>").
- then the agent script verbatim, then builder.SaveFile("<ext>", "out.<ext>"); builder.CloseFile();.

Concurrency: global chan struct{} semaphore of 2 (copy the idiom from render/office.go:58 — do NOT reuse the Gotenberg channel, different engine) + a per-document sync.Map mutex so two agents can't race one doc.

Commit — model it on saveOfficeEdit (handlers_office.go:886), the editor's own save function:
- Content dedupe first: sha256 the result; if it equals the current version's ContentHash, land NOTHING and return "no changes were made" (handlers_office.go:891-899). An agent no-op script must not mint a noise version.
- MIME rule: map the saved filetype through officeOutMIME; fall back to the office source version's MIME — never the current version's (after a publish the current version is a PDF; stamping docx bytes application/pdf corrupts the representation chain, handlers_office.go:900-912).
- s.dms.AddVersion(ctx, p, docID, bytes.NewReader(result), mime) — role self-infers to RoleSource (correct: "uploads and office saves are source"); extract hook re-runs automatically; filing/filed_at untouched (the 00210 path lives below AddVersion). Preview cache needs nothing: it keys on content hash+fingerprint, a new version misses naturally. ConvertGeneration stays — bump only when the conversion request shape changes, which this doesn't.
- Post-save status reset: best-effort s.dms.SetDocumentStatus(ctx, docID, dmsdomain.StatusDraft), mirroring the upload handler (handlers_dms.go:240) — "new bytes mean what the client holds is no longer what the system holds": an approved/sent doc edited by an agent must return to draft and be re-issued. (The browser-editor callback deliberately skips this; an unattended agent edit is upload-shaped, not session-shaped.) This also feeds covers_current correctly: existing signatures stop covering current, which is the designed re-sign flow, not breakage.

Response: {document_id, new_version, size_bytes} + a text line telling the agent to verify with preview_document. Tool description must carry the doctrine: "MODIFIES the existing document in place. Write the smallest script that satisfies the request; existing content and formatting are preserved. Use mode:'recreate' only when explicitly asked to rewrite from scratch. Verify the result with preview_document."

Audit: auditMCPTool runs already (500-char arg truncation is fine for forensics of intent; enhancement later: log script sha256 alongside).

WI-3 — Writer tools (~1.5 days)

All dispatch cases in mcpRunTool; all run as the key's user; all get audit rows for free.

Tool Service call Gates
create_folder(name, parent_id?) s.dms.CreateFolder(ctx, uid, parentID, name, <defaults>) (service.go:642) — copy the defaults the HTTP route passes (visibility, requireStepUp=false, encryption="none"); never create secure/step-up folders via MCP RBAC folder-create as the HTTP route checks
create_document(title, content_md?, folder_id?, classification?, doc_type?) s.dms.CreateDocument (service.go:760) + AddVersion. Content: office licensed → compile markdown to a builder recreate script (WI-3a) → docx bytes; not licensed → store content_md as a text/markdown version (graceful degrade, still searchable) document.create via s.authz.Can — copy the block from create_document_from_template (handlers_mcp.go:875-882)
upload_document(filename, content_base64, folder_id?, title?, classification?, doc_type?) decode → sniff MIME (extension + magic) → CreateDocument + AddVersion. Extension must pass the existing allowedUploadExt allowlist (handlers_dms.go:201) — same door policy as the web upload same as create + size cap below
move_document(document_id, folder_id?) s.dms.MoveDocument (service.go:2414); nil folder = root ReadWrite on doc + write on target folder, mirroring the HTTP move route
rename_document(document_id, title) s.dms.RenameDocument (service.go:2440) ReadWrite on doc

Body-cap decision: mcpMaxBodyBytes is 1 MiB (handlers_mcp.go:44). Raise to 8 MiB (constant + comment update; MCP is API-key-authed, this is not an anonymous surface) and cap upload_document at 5 MiB decoded. Larger files → error message pointing at the web UI. Audit stays safe: args truncate at 500 chars, so base64 never bloats audit rows.

Tool defs: writer tools are core-MCP (no module bit); create_document's description must say the md→docx conversion needs the office module and what happens without it. Update the MCP server instructions string (serverInfo block, handlers_mcp.go:121) — the "read-only EXCEPT create_document_from_template" sentence is now wrong; rewrite to name the write tools and the confirm-with-user expectation.

WI-3a — Markdown→builder-script compiler (~1 day)

New package go/internal/platform/office/mdscript: Compile(md string) (script string, err error).
- Parser: add github.com/yuin/goldmark (pure Go, MIT, the standard; repo has no md parser today — verified go.mod).
- Scope v1: headings 1–3 (map to editor styles Heading 1..3), paragraphs, bold/italic/inline-code, bullet + numbered lists, simple pipe tables, hard rule. Everything else degrades to plain paragraph text — never error on unknown constructs.
- Output is a builder-API body only (no builder.* lines — WI-2's wrapper owns those). Escape all text through a single jsStr() helper (backslash, quote, newline, U+2028/29).
- Golden tests: md fixture in, script fixture out.

WI-4 — Tests (~1.5 days)

  1. Unit: script screening table (accepts Api.GetDocument()..., rejects builder.SaveFile, rejects 64 KB+, rejects empty); wrapper output for modify/recreate; mdscript goldens; token expiry sweep.
  2. Integration (httptest): fake doc-server serving /docbuilder (returns canned urls) + the result file; full edit_office_document flow against a seeded doc → new version lands, extract hook observed, status returns to draft. Refusal matrix: live key present / DLP deny (both gates) / step-up gated / no ReadWrite / module off / builder. in script / EditingLocked (incl. a published letter's doc) / active workflow / letter-artifact doc / trashed doc. Plus: no-op script → dedupe → NO new version; edit after publish → new version's MIME is the office source's, not PDF.
  3. Module harness: -tags moduleaudit run — tool visibility per licence set (edit + md-docx behind office; writer tools always).
  4. CI runs no Go tests — the executor must run go test ./... and the moduleaudit tag locally and paste results in the PR.
  5. Post-deploy manual e2e on demo (not automated): real edit through the MCP endpoint against a scratch doc, verify in the web editor; confirm no session-key weirdness by opening the doc in the editor after the MCP edit (new version ⇒ new key ⇒ fresh bytes — expected clean).

WI-5 — Docs & rollout (~0.5 day)

  • Update MCP module user docs / tool catalogue.
  • Demo deploy per standard procedure (routine); prod only when Efran says so.
  • Rollback: revert commit — no migrations, no state.

Sharp edges the executor must respect

  1. Never let the agent supply document bytes or builder.* calls — the modify doctrine is structural, not advisory.
  2. Letters are OUT: document_id resolves documents only; letter docx flows through its own draftRev/apply machinery.
  3. officeToken purpose strings and the callback-vs-content TTL split (handlers_office.go:262-274) — use purpose "content", don't invent a new purpose.
  4. Refuse, never queue, when a live editor session exists — silent races with an open editor overwrite human work.
  5. AddVersion is put-before-tx; stream the result to it, don't re-buffer twice at 100 MB.
  6. The doc-server fetches the script and content by URL from inside the compose network — every URL you mint must be based on ONLYOFFICE_OBSCURA_URL, never a host IP (demo firewalls bridge→host).
  7. Licence lapse: moduleEnabled already returns false on lapsed terms — no extra handling, but don't cache its result across the call.

Estimate

WI-1 builder client 1 day
WI-2 edit tool 2–3 days
WI-3 writer tools 1.5 days
WI-3a md compiler 1 day
WI-4 tests 1.5 days
WI-5 docs/rollout 0.5 day
Total ~7.5–8.5 dev-days

Decisions taken (defaults; flag in PR if changed)

  1. Versioning semantics: MCP edits follow the upload path's lifecycle (status → draft after new bytes, workflow guard with NO override, letter-bytes refusal), and the editor path's save mechanics (dedupe, MIME rule, save-time ACL re-check). Audited against both on 2026-08-21.
  2. DLP: reuse allow_ai_processing for edits plus the downloadAllowed egress gate (consistency with editor-open; revisit a separate allow_ai_editing flag only if a customer asks).
  3. Conflict policy: refuse-when-open.
  4. Body cap 8 MiB / upload cap 5 MiB decoded.
  5. pptx: pass-through best-effort, not advertised in tool descriptions.
  6. No new env vars, no migrations, no new containers.