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}/docbuilderworks on ourobscura-onlyoffice:8.3image. Body{"async":false,"url":"<script url>","token":<jwt of body>}+ headerAuthorization: Bearer <jwt of {"payload":body}>— exactly the dual signingonlyofficeForceSavedoes (handlers_office_apply.go:110). Response{"end":true,"urls":{"out.docx":"http://<host>/cache/..."}}; rewrite the URL host toONLYOFFICE_INTERNAL_URLbefore fetching (the advertised host is whatever Host header we sent).builder.OpenFile(url)→ edit →builder.SaveFilepreserves 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:
func (s *Server) onlyofficeDocbuilder(ctx context.Context, scriptURL string) (map[string]string, error)— clone the shape ofonlyofficeForceSave(handlers_office_apply.go:110-122): samesignJWTdual signing, POST tostrings.TrimRight(s.cfg.OnlyofficeInternalURL,"/") + "/docbuilder", body{"async":false,"url":scriptURL}. 90 s client timeout. Map doc-servererrorcodes to akernel.Error(office.builder_failed).- Script hosting: package-level
sync.Maptoken→{script string, exp time.Time}(mirror theofficeLiveKeysidiom,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}inserver.go:772(same unauthenticated-but-token-gated group; the token is the credential, exactly like content tokens). Servestext/plain, expired/unknown → 404. - Result fetch helper: GET each
urlsvalue with host rewritten toOnlyofficeInternalURL, 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:
- Licence:
moduleEnabled("office")runtime re-check (mirrorsemantic_search's re-check pattern,handlers_mcp.go:756). - Resolve doc as principal
p; explicitly refuse trashed/disposed docs — NOT inherited fromAddVersion, nothing below checksDeletedAt. doc.EditingLocked("preserve original") → refuse. Enforced only at editor-session mint (handlers_office.go:454), never inAddVersion. Published letters set it (handlers_publish_letter.go:115) — without this gate an agent can rewrite a published letter's artifact.refuseLetterBytes(ctx, docID, "mcp-edit")(handlers_dms.go:174) — a letter's bytes come from numbering/seal ceremonies; same refusal the upload door has.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").- Edit-guard suspension (00205):
s.editGuard.BlockedUntil(ctx, uid, docID)→ refuse while blocked (handlers_office.go:395pattern) — a tamper report suspends the MCP door too, not just the browser one. - Write access:
EffectiveDocumentAccess ≥ AccessReadWriteor content-admin — exactlysaveOfficeEdit's re-check (handlers_office.go:920-931). - DLP, BOTH gates:
allow_ai_processing(theread_documentgate,handlers_mcp.go:363-368) anddownloadAllowed(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. - Step-up/vault: if
s.dms.DocumentStepUpGatesays the doc is gated, refuse (office.stepup_required) — MCP has no step-up ceremony. - Checked-out doc:
AddVersionenforces; map itsdms.version.checked_outto a clear message. - Live-session conflict:
liveOfficeKey(officeSubDocument, docID)(handlers_office_apply.go:75) → if present, refuseoffice.editing_live. Belt-and-braces: also probe the command service with the computed keysanitizeKey(docID+"_"+officeVersion)(handlers_office.go:461) and treat anything buterrOfficeNoSessionas 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)
- Unit: script screening table (accepts
Api.GetDocument()..., rejectsbuilder.SaveFile, rejects 64 KB+, rejects empty); wrapper output for modify/recreate;mdscriptgoldens; token expiry sweep. - Integration (httptest): fake doc-server serving
/docbuilder(returns cannedurls) + the result file; fulledit_office_documentflow 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. - Module harness:
-tags moduleauditrun — tool visibility per licence set (edit + md-docx behindoffice; writer tools always). - CI runs no Go tests — the executor must run
go test ./...and the moduleaudit tag locally and paste results in the PR. - 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
- Never let the agent supply document bytes or
builder.*calls — the modify doctrine is structural, not advisory. - Letters are OUT:
document_idresolves documents only; letter docx flows through its own draftRev/apply machinery. officeTokenpurpose strings and the callback-vs-content TTL split (handlers_office.go:262-274) — use purpose"content", don't invent a new purpose.- Refuse, never queue, when a live editor session exists — silent races with an open editor overwrite human work.
AddVersionis put-before-tx; stream the result to it, don't re-buffer twice at 100 MB.- 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). - Licence lapse:
moduleEnabledalready 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)
- 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.
- DLP: reuse
allow_ai_processingfor edits plus thedownloadAllowedegress gate (consistency with editor-open; revisit a separateallow_ai_editingflag only if a customer asks). - Conflict policy: refuse-when-open.
- Body cap 8 MiB / upload cap 5 MiB decoded.
- pptx: pass-through best-effort, not advertised in tool descriptions.
- No new env vars, no migrations, no new containers.